From 7f00b89d3dd35b5198b1eb5edf8c4733be8d8eb8 Mon Sep 17 00:00:00 2001 From: kogeler <25884155+kogeler@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:02:25 +0300 Subject: [PATCH] rewrite polkadot-nominations --- .gitignore | 1 + polkadot-nominations/.gitignore | 5 +- polkadot-nominations/.papi/polkadot-api.json | 12 - polkadot-nominations/AGENTS.md | 346 ++++-- polkadot-nominations/Makefile | 154 +++ polkadot-nominations/README.md | 343 ++++-- polkadot-nominations/config.json.example | 7 +- polkadot-nominations/index.js | 128 -- polkadot-nominations/index.ts | 134 +++ polkadot-nominations/package-lock.json | 1038 +++++++++++------ polkadot-nominations/package.json | 16 +- polkadot-nominations/src/analysis.ts | 270 +++++ polkadot-nominations/src/chain.js | 47 - polkadot-nominations/src/chain.ts | 73 ++ polkadot-nominations/src/cli.ts | 33 + polkadot-nominations/src/config-schema.ts | 85 ++ polkadot-nominations/src/config.js | 15 - polkadot-nominations/src/config.ts | 10 + polkadot-nominations/src/constants.ts | 10 + polkadot-nominations/src/rewards.js | 159 --- polkadot-nominations/src/rewards.ts | 626 ++++++++++ polkadot-nominations/src/stats.js | 23 - polkadot-nominations/src/stats.ts | 39 + polkadot-nominations/test/analysis.test.ts | 206 ++++ polkadot-nominations/test/chain.test.ts | 64 + polkadot-nominations/test/cli.test.ts | 20 + .../test/config-schema.test.ts | 38 + .../test/fixtures/rpc-config.json | 7 + .../test/integration/rpc.test.ts | 59 + .../test/live/cli-live.test.ts | 286 +++++ polkadot-nominations/test/rewards.test.ts | 291 +++++ polkadot-nominations/test/stats.test.ts | 22 + polkadot-nominations/tsconfig.json | 21 + 33 files changed, 3656 insertions(+), 932 deletions(-) delete mode 100644 polkadot-nominations/.papi/polkadot-api.json create mode 100644 polkadot-nominations/Makefile delete mode 100644 polkadot-nominations/index.js create mode 100644 polkadot-nominations/index.ts create mode 100644 polkadot-nominations/src/analysis.ts delete mode 100644 polkadot-nominations/src/chain.js create mode 100644 polkadot-nominations/src/chain.ts create mode 100644 polkadot-nominations/src/cli.ts create mode 100644 polkadot-nominations/src/config-schema.ts delete mode 100644 polkadot-nominations/src/config.js create mode 100644 polkadot-nominations/src/config.ts create mode 100644 polkadot-nominations/src/constants.ts delete mode 100644 polkadot-nominations/src/rewards.js create mode 100644 polkadot-nominations/src/rewards.ts delete mode 100644 polkadot-nominations/src/stats.js create mode 100644 polkadot-nominations/src/stats.ts create mode 100644 polkadot-nominations/test/analysis.test.ts create mode 100644 polkadot-nominations/test/chain.test.ts create mode 100644 polkadot-nominations/test/cli.test.ts create mode 100644 polkadot-nominations/test/config-schema.test.ts create mode 100644 polkadot-nominations/test/fixtures/rpc-config.json create mode 100644 polkadot-nominations/test/integration/rpc.test.ts create mode 100644 polkadot-nominations/test/live/cli-live.test.ts create mode 100644 polkadot-nominations/test/rewards.test.ts create mode 100644 polkadot-nominations/test/stats.test.ts create mode 100644 polkadot-nominations/tsconfig.json diff --git a/.gitignore b/.gitignore index a78e4f8..6b3de0e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ node_modules sms-to-telegram/sms-to-telegram plans +tmp/ \ No newline at end of file diff --git a/polkadot-nominations/.gitignore b/polkadot-nominations/.gitignore index 8a3c893..32c1072 100644 --- a/polkadot-nominations/.gitignore +++ b/polkadot-nominations/.gitignore @@ -1,5 +1,6 @@ node_modules/ -.papi/descriptors/ -.papi/metadata/ +dist/ +.papi/ config.json nominations.json +package-lock.json.next diff --git a/polkadot-nominations/.papi/polkadot-api.json b/polkadot-nominations/.papi/polkadot-api.json deleted file mode 100644 index 9eabf30..0000000 --- a/polkadot-nominations/.papi/polkadot-api.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": 0, - "descriptorPath": ".papi/descriptors", - "entries": { - "ah": { - "wsUrl": "wss://rpc-assethub.novasama-tech.org", - "metadata": ".papi/metadata/ah.scale", - "genesis": "0x68d56f15f85d3136970ec16946040bc1752654e906147f7e43e9d539d7c3de2f", - "codeHash": "0xd8c8c132cd1f51504af2cd45c8dd7c5565dff0a4e9d7fde72daa8975c58ef961" - } - } -} diff --git a/polkadot-nominations/AGENTS.md b/polkadot-nominations/AGENTS.md index 3204992..f9c6eea 100644 --- a/polkadot-nominations/AGENTS.md +++ b/polkadot-nominations/AGENTS.md @@ -2,7 +2,43 @@ ## Project Overview -A Node.js CLI tool that fetches and analyzes staking nominations for a specific validator on the Polkadot network via Asset Hub RPC. Uses the [polkadot-api (PAPI)](https://papi.how/) library for typed chain queries. +A container-only, strictly typed TypeScript CLI with separate modes for +nomination reports, network-wide self-stake statistics, and per-validator +information on Polkadot Asset Hub. It uses +[polkadot-api (PAPI)](https://papi.how/) for typed queries. + +### Mandatory Container-Only Rule + +**Never run `node`, `npm`, `npx`, or any JavaScript test directly on the host.** +This applies to agents, maintainers, CI commands, live runs, dependency updates, +PAPI codegen, and tests. Use only the Podman-backed Make targets: + +- `make nominations` +- `make self-stake-stats` +- `make validator-info` +- `make typecheck` +- `make test` +- `make test-live` +- `make package-lock` +- `make run ARGS="--help"` + +No host path is mounted into the container. A filtered project archive is +streamed over stdin and extracted into container tmpfs. Dependencies, compiler +output, metadata, `.papi/polkadot-api.json`, and generated descriptors must +remain inside the ephemeral container. The CLI also rejects direct host +execution. + +Rootless Podman is mandatory and enforced by `check-podman`. Every container +runs under `--userns=keep-id` with all capabilities dropped, +`no-new-privileges`, a read-only root filesystem, private namespaces, no host +aliases or published ports, resource limits, and only size-limited `/work` and +`/tmp` tmpfs mounts. Do not weaken this profile or add a host bind mount without +an explicit requirement and security review. + +`make run` validates `ARGS` against the fixed CLI flag allowlist before building +the shell recipe. Keep user-controlled values out of generated host-shell text; +pass runtime values such as `CONFIG_PATH` and `RPC_URL` through Podman +environment handling. ## Current Implementation @@ -10,126 +46,240 @@ A Node.js CLI tool that fetches and analyzes staking nominations for a specific ``` polkadot-nominations/ - index.js Entry point — orchestrates fetching, filtering, enrichment - config.json User configuration (validator, percentiles, minStakeDot) - package.json ES modules, polkadot-api dependency, postinstall codegen - .papi/ - polkadot-api.json PAPI chain config (wsUrl, genesis, codeHash) — committed to git - descriptors/ Generated type descriptors — gitignored, rebuilt on npm install - metadata/ Downloaded chain metadata — gitignored, rebuilt on npm install + index.ts Container guard and CLI command orchestration + Makefile Ephemeral Podman runs, tests, and lock-file generation + config.json Local config (validators, percentiles, filters, RPC) + package.json ES modules, scripts, and pinned dependency ranges + tsconfig.json Strict TypeScript 7 compiler configuration src/ - config.js Loads config.json and .papi/polkadot-api.json, exports constants - chain.js RPC connection and data fetching (validators, nominators, ledgers) - rewards.js Per-era validator payout computation (commission + own-stake reward) - stats.js Percentile calculation with configurable direction and prefix - .gitignore Excludes node_modules, .papi/descriptors, .papi/metadata + analysis.ts Pure nomination/self-stake/validator-info report builders + chain.ts RPC connection and data fetching (validators, nominators, ledgers) + cli.ts CLI flag parsing and help text + config-schema.ts Pure unknown-to-Config validation and defaults + config.ts Runtime config.json loader + constants.ts DOT units and default Asset Hub RPC URL + rewards.ts Actual personal payouts and self-stake reward projections + stats.ts Percentile calculation with configurable direction and prefix + test/ + *.test.ts Deterministic unit tests with no live chain dependency + fixtures/ Tracked RPC-only config used to bootstrap test containers + integration/ Lightweight real-RPC typed API checks + live/ Full CLI checks using random current active validators + dist/ Container-only compiler output; never written to the host + .gitignore Excludes node_modules, all generated .papi data, local config README.md User-facing documentation ``` ### What It Does -1. Connects to Polkadot Asset Hub via WebSocket RPC (default: `wss://rpc-assethub.novasama-tech.org`) -2. Fetches three datasets **in parallel** via `Promise.all`: - - `Staking.Validators.getEntries()` — all validators with commission (Perbill) - - `Staking.Nominators.getEntries()` — all nominators with their target lists - - `Staking.Ledger.getEntries()` — all staking ledgers with active stake -3. Builds lookup maps: `commissions` (validator address -> commission %), `stakes` (stash -> active DOT) -4. Filters nominations to those targeting the configured validator -5. Optionally filters by minimum stake (`minStakeDot`, 0 = disabled) -6. Drops targets that are no longer registered validators (not in `Staking.Validators`) -7. Enriches each target with its commission % -8. Computes commission percentiles per nomination (descending — higher percentile = lower commission) -9. Computes global commission percentiles across all **unique** targets (deduplicated by address) -10. Computes global stake percentiles across all matching nominators (ascending — standard) -11. Computes the validator's own reward for the last `rewardEras` completed eras (commission cut + own-stake reward) with a per-era claimed flag -12. Outputs JSON to stdout, logs to stderr +The CLI requires exactly one mode: + +1. `--nominations` fetches nominations for every configured address, filters by + `minStakeDot`, enriches targets with commission, and computes commission and + nominator-stake percentiles. Output is keyed by validator address. +2. `--self-stake-stats` scans every registered validator. It reports the share + of active and all validators with at least 10,000 DOT self-stake. Percentiles + include all validators meeting that minimum, with values above 30,000 DOT + capped to 30,000 rather than excluded. +3. `--validator-info` emits a validator-address-keyed map with current self-stake, + historical personal rewards including the validator incentive, and independent + higher-self-stake reward scenarios through the live runtime hard cap. + +All modes emit JSON to stdout and operational logs to stderr. They fetch only +the chain datasets needed by the selected mode, with independent queries run in +parallel via `Promise.all`. + +### Test Suites + +- `make typecheck` compiles all production and test TypeScript with no output. +- `make test` runs all deterministic unit tests and the RPC integration suite in + one ephemeral container. The integration test loads the current active set, + randomly chooses one address from it, and verifies typed validator + preferences, live reward-curve constants, and completed-era incentive data. +- `make test-live` is separate because it performs full storage scans and runs + every CLI mode end to end. It loads the current active set, randomly samples + three addresses, creates a temporary config inside container tmpfs, and checks + `--nominations`, `--self-stake-stats`, and `--validator-info`. +- Live tests must never embed or copy the validator addresses from the local + `config.json`. Validator samples must always be derived at runtime from the + current active set returned by RPC. +- Both suites require RPC access. Neither suite may write `node_modules`, `.papi` + data, generated descriptors, or temporary configs to the host. ### Key Technical Details - **Runtime**: Node.js >= 22 (ES modules) +- **Container image**: the multi-architecture `node:22-bookworm-slim` OCI index + is pinned by digest in `Makefile`; dependency and image updates must update + this digest deliberately +- **Language/compiler**: TypeScript `^7.0.2`; `strict`, + `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `noImplicitReturns`, + and `verbatimModuleSyntax` are enabled. Source imports use `.js` extensions as + required by NodeNext ESM and compile into container-only `dist/`. - **Chain**: Polkadot Asset Hub (staking migrated from relay chain as of runtime v2.0.0) -- **RPC library**: `polkadot-api` (PAPI) `^2.1.7` with generated type descriptors. Note the v2 breaking change: the ws provider is imported from `polkadot-api/ws` (the old `polkadot-api/ws-provider` subpath was removed) +- **RPC library**: `polkadot-api` (PAPI) `^2.2.2` with generated type descriptors. The ws provider is imported from `polkadot-api/ws` - **WebSocket**: Uses `ws` npm package passed as `websocketClass` to `getWsProvider` (Node 22 has native WebSocket but PAPI needs the class passed explicitly) - **Commission format**: Stored on-chain as Perbill (0..1,000,000,000), converted to % with 4 decimal precision -- **Stake format**: Stored as Planck (bigint), converted to whole DOT via `active / 10n**10n` -- **RPC URL**: Read from `.papi/polkadot-api.json` (entries.ah.wsUrl), overridable via `RPC_URL` env var -- **PAPI codegen**: `npx papi add ah -w ` downloads metadata and generates typed descriptors; `npx papi` regenerates from existing config; runs automatically on `npm install` via postinstall +- **Stake format**: Stored as Planck (`bigint`), converted to DOT with four decimal places without truncating the fractional part +- **RPC URL**: Read from `config.json.rpcUrl`, defaulting to `wss://rpc-assethub.novasama-tech.org`; `RPC_URL` overrides it +- **PAPI codegen**: each Make run executes `npm ci --ignore-scripts`, then the lock-resolved `./node_modules/.bin/papi add ah -w ` inside container tmpfs. `npm_config_ignore_scripts=true` also applies to npm started internally by PAPI. No `.papi` file is sourced from or written to the host. PAPI's `tsc-prog@2.3.0` is moved under `@polkadot-api/cli/node_modules` before codegen so it resolves the CLI's TypeScript 6 dependency; project builds still use root TypeScript 7. +- **Host isolation**: project files are copied through a filtered stdin tar stream, + never through a bind mount. The container is rootless/non-root, has zero Linux + capabilities, cannot gain privileges, has a read-only root filesystem and + private namespaces, and receives explicit CPU, memory, swap, and PID limits. + Only `make package-lock` intentionally writes one host file from stdout. +- **Lock maintenance**: `make package-lock` runs `npm install --package-lock-only --ignore-scripts` in Podman and replaces only the host `package-lock.json` ### config.json Schema ```json { - "validator": "SS58 address of the target validator", + "validators": ["SS58 address", "another SS58 address"], "percentiles": [0.25, 0.5, 0.75, 0.9], "minStakeDot": 0, - "rewardEras": 20 + "rewardEras": 20, + "rpcUrl": "wss://rpc-assethub.novasama-tech.org" } ``` -- `validator` — required, SS58 address to filter nominations for +- `validators` — required non-empty unique list used by address-specific modes - `percentiles` — optional, array of thresholds (0-1), default `[0.5, 0.75, 0.9]` - `minStakeDot` — optional, minimum active stake in whole DOT, default `0` (disabled) - `rewardEras` — optional, number of most recent completed eras to report validator rewards for, default `20` +- `rpcUrl` — optional non-empty WebSocket URL; defaults to the current Asset Hub RPC above ### Output Structure +Address-specific reports are maps keyed by the exact configured address. + +`--nominations`: + ```json { - "validator": "SS58...", - "total_nominators": 212, - "unique_targets": 352, - "commission_p25": 5, - "commission_p50": 3, - "commission_p75": 1.5, - "commission_p90": 0.1, - "stake_p25": 878, - "stake_p50": 2236, - "stake_p75": 8061, - "stake_p90": 23569.3, - "rewards": { - "eras_requested": 20, - "active_era": 1852, - "is_validator": true, - "current_commission_pct": 0, - "latest_era_commission_pct": 10, - "reward_collapse_pending": true, - "total_reward": 412.5, - "unclaimed_reward": 55.3, - "eras": [ + "SS58...": { + "total_nominators": 212, + "unique_targets": 352, + "commission_p50": 3, + "stake_p50": 2236, + "nominations": [ { - "era": 1851, - "active": true, - "has_data": true, - "reward": 21.4567, - "commission_reward": 21.4567, - "own_stake_reward": 0, - "reward_planck": "214567000000", - "commission_pct": 10, - "own_stake": 0, - "total_stake": 1602477.39, - "claimed": false, - "claimed_pages": 0, - "total_pages": 1 + "nominator": "SS58...", + "stake": 6717, + "targets": [{ "address": "SS58...", "commission": 4 }], + "commission_p50": 3, + "submitted_in": 1738, + "suppressed": false } ] + } +} +``` + +`--self-stake-stats`: + +```json +{ + "minimum_self_stake_dot": 10000, + "active_validators": { + "total": 297, + "meeting_minimum": 240, + "meeting_minimum_pct": 80.81 }, - "nominations": [ - { - "nominator": "SS58...", - "stake": 6717, - "targets": [ - { "address": "SS58...", "commission": 4 } - ], - "commission_p50": 3, - "commission_p75": 1, - "commission_p90": 0, - "submitted_in": 1738, - "suppressed": false + "all_validators": { + "total": 1300, + "meeting_minimum": 500, + "meeting_minimum_pct": 38.46 + }, + "reward_effective_self_stake": { + "minimum_dot": 10000, + "cap_dot": 30000, + "validator_count": 500, + "capped_validator_count": 200, + "self_stake_p50": 25000, + "self_stake_p90": 30000 + } +} +``` + +`reward_effective_self_stake` models the network top-up competition. Every +registered validator with at least 10,000 DOT remains in the percentile sample. +For this calculation only, `min(actual_self_stake, 30_000)` is used. A validator +above 30,000 DOT therefore still affects the distribution as a 30,000 DOT +validator; it is not removed. This cap does not change its actual self-stake or +the reward earned directly on that own stake. + +`--validator-info`: + +```json +{ + "SS58...": { + "self_stake": 10000, + "rewards": { + "eras_requested": 20, + "active_era": 2257, + "is_validator": true, + "current_commission_pct": 0, + "latest_era_commission_pct": 10, + "reward_collapse_pending": false, + "total_reward": 829.848, + "unclaimed_reward": 41.4924, + "eras": [ + { + "era": 2256, + "reward": 41.4924, + "commission_reward": 0.7063, + "own_stake_reward": 0, + "validator_incentive_reward": 40.7861 + } + ] + }, + "reward_projection": { + "basis": { + "eras_requested": 20, + "eras_used": 20, + "current_self_stake": 10000, + "actual_total_reward": 829.848, + "actual_average_reward_per_era": 41.4924 + }, + "curve": { + "minimum_self_stake": 10000, + "optimum_self_stake": 30000, + "hard_cap_self_stake": 100000, + "slope_factor": 0.5 + }, + "scenarios": [ + { + "self_stake": 12500, + "additional_self_stake": 2500, + "estimated_total_reward": 927.12, + "estimated_average_reward_per_era": 46.356, + "estimated_total_commission_reward": 14.126, + "estimated_total_own_stake_reward": 43.22, + "estimated_total_validator_incentive_reward": 869.774, + "increase_pct": 11.7217 + } + ] } - ] + } } ``` +The scenario grid is derived from live runtime constants. It advances by 2,500 +DOT through `OptimumSelfStake` (currently 30,000 DOT), then by 10,000 DOT up to +the current `HardCapSelfStake` (`C`). Exact optimum and hard-cap values are +always included. Only targets strictly above the validator's current ledger +self-stake are emitted, so a validator already at or above `C` has no scenarios. + +Each validator is modeled independently against its own actual reward history. +Only completed active eras with incentive data participate in `basis` and the +projection. Historical reward points, commission, nominator stake, incentive +budget, and other validators' weights are held fixed; the selected validator's +self-stake, exposure share, curve weight, and resulting total incentive weight +are recomputed. `increase_pct` compares the scenario total with +`basis.actual_total_reward` and is `null` for a zero baseline. The projection +does not model elections or future changes in points, commissions, nominators, +budgets, or competitors. + ### Chain Storage Items Used | Storage | Key | Value | Used For | @@ -143,10 +293,19 @@ polkadot-nominations/ | `Staking.ErasValidatorPrefs` | (EraIndex, SS58) | `{ commission: Perbill, blocked: bool }` | Per-era commission | | `Staking.ErasStakersOverview` | (EraIndex, SS58) | `{ total: u128, own: u128, nominator_count: u32, page_count: u32 }` | Per-era exposure (own/total, page count) | | `Staking.ClaimedRewards` | (EraIndex, SS58) | `u32[]` | Claimed page indices for the era | +| `Staking.ErasValidatorIncentiveBudget` | EraIndex | `u128` | Separate validator incentive pool for an era | +| `Staking.ErasValidatorIncentiveWeight` | (EraIndex, SS58) | `u128` | Snapshotted validator incentive weight | +| `Staking.ErasSumValidatorIncentiveWeight` | EraIndex | `u128` | Sum of snapshotted incentive weights | +| `Staking.MinValidatorBond` | — | `u128` | Live minimum self-stake and first scenario target | +| `Staking.OptimumSelfStake` | — | `u128` | Curve transition and scenario step boundary | +| `Staking.HardCapSelfStake` | — | `u128` | Live upper scenario boundary `C` | +| `Staking.SelfStakeSlopeFactor` | — | `Perbill` | Dampening factor above optimum self-stake | -The first three are fetched via `.getEntries()` (full storage scan) in a single parallel batch. The reward items are read per-era via `.getValue()` for the validator across the last `rewardEras` eras. +Full storage scans are selected per command rather than always fetched together. +Reward items are read via `.getValue()` for each configured validator across the +last `rewardEras` completed eras. -### Reward Computation (src/rewards.js) +### Reward Computation (src/rewards.ts) Replicates `pallet_staking::do_payout_stakers_by_page`. The **validator's own** reward for an era is: @@ -155,16 +314,31 @@ validator_total_payout = ErasValidatorReward(era) * validator_points / total_poi commission_payout = commission * validator_total_payout // Perbill leftover = validator_total_payout - commission_payout own_stake_reward = leftover * overview.own / overview.total -personal_reward = commission_payout + own_stake_reward +validator_incentive = incentive_budget * validator_weight / sum_weights +personal_reward = commission_payout + own_stake_reward + validator_incentive ``` Nominator payouts are intentionally excluded. Arithmetic is done in `BigInt` Planck to match on-chain integer math, then converted to DOT. +The validator incentive curve uses the live `OptimumSelfStake` (`T`), +`HardCapSelfStake` (`C`), and `SelfStakeSlopeFactor` (`k`): + +``` +weight(s) = sqrt(s) when s <= T +weight(s) = sqrt(T + k^2 * (s - T)) when T < s <= C +weight(s) = weight(C) when s > C +``` + +The separate incentive is paid only to validators with nonzero era reward +points. The what-if calculation replaces the selected validator's snapshotted +weight in the historical total with `weight(target_self_stake)` before dividing +the historical incentive budget. + **Claimed flag:** the own-stake reward is paid only with page `0`; commission is spread across all pages. A reward is therefore *fully* claimed only when `ClaimedRewards(era, validator).length == overview.page_count`. Most validators have a single page, so this is effectively binary. **Migration boundary:** eras predating the Asset Hub staking migration (and the not-yet-finalized current era) have no `ErasValidatorReward`, so they are reported with `has_data: false`, `active: false`, `reward: 0`. Requesting more `rewardEras` than exist since the migration is safe. -**Commission-change detection:** per-era commission comes from `ErasValidatorPrefs`, which is snapshotted at era start. A live commission change (e.g. the 0% referendum) only shows in `current_commission_pct` (from `Staking.Validators`) until the next era snapshots it. `reward_collapse_pending` is `true` when the latest era's reward is commission-only (`own_stake_reward == 0`) and the current commission is now lower than the snapshot — i.e. rewards have not dropped yet but will fall to ~0 for a validator with no own-stake cushion. This is exactly the situation for a 0%-commission validator that lacks the self-stake minimum. +**Commission-change detection:** per-era commission comes from `ErasValidatorPrefs`, which is snapshotted at era start. A live commission change only shows in `current_commission_pct` (from `Staking.Validators`) until the next era snapshots it. `reward_collapse_pending` is `true` only when the latest reward is commission-only, with neither own-stake nor incentive income, and the current commission is lower than the snapshot. ### Typical Data Volumes (Polkadot, Feb 2025) diff --git a/polkadot-nominations/Makefile b/polkadot-nominations/Makefile new file mode 100644 index 0000000..1db394e --- /dev/null +++ b/polkadot-nominations/Makefile @@ -0,0 +1,154 @@ +SHELL := /bin/bash +.SHELLFLAGS := -eu -o pipefail -c + +IMAGE ?= docker.io/library/node:22-bookworm-slim@sha256:d649c27dae7ba0137b3cef5dd75baa422c08dc3d9e3fc0c23dfb172dc3cc6436 +PODMAN ?= podman +ARGS ?= --help +CONFIG_PATH ?= +BUILD_COMMAND ?= npm run build +ALLOWED_ARGS := --help -h --nominations --self-stake-stats --validator-info + +export CONFIG_PATH + +ifneq ($(filter run,$(MAKECMDGOALS)),) +ifneq ($(words $(strip $(ARGS))),1) +$(error ARGS must contain exactly one CLI flag) +endif +ifeq ($(filter $(strip $(ARGS)),$(ALLOWED_ARGS)),) +$(error ARGS must be one of: $(ALLOWED_ARGS)) +endif +endif + +SOURCE_EXCLUDES := \ + --exclude=./.git \ + --exclude=./.papi \ + --exclude=./dist \ + --exclude=./node_modules \ + --exclude=./nominations.json \ + --exclude=./package-lock.json.next + +CONTAINER_FLAGS := \ + --rm \ + --quiet \ + --interactive \ + --pull=missing \ + --read-only \ + --read-only-tmpfs=false \ + --userns=keep-id \ + --cap-drop=ALL \ + --security-opt=no-new-privileges \ + --network=private \ + --no-hosts \ + --ipc=private \ + --pid=private \ + --uts=private \ + --cgroupns=private \ + --pids-limit=512 \ + --memory=2g \ + --memory-swap=2g \ + --cpus=2 \ + --umask=077 \ + --log-driver=none \ + --tmpfs=/work:rw,exec,nosuid,nodev,size=1g,mode=1777 \ + --tmpfs=/tmp:rw,noexec,nosuid,nodev,size=512m,mode=1777 + +CONTAINER_ENV := \ + --env HOME=/tmp/home \ + --env npm_config_cache=/tmp/npm-cache \ + --env npm_config_ignore_scripts=true \ + --env npm_config_audit=false \ + --env npm_config_fund=false + +.PHONY: help check-podman nominations self-stake-stats validator-info run typecheck test test-live package-lock + +help: + @printf '%s\n' \ + 'make nominations - nomination reports for configured validators' \ + 'make self-stake-stats - network-wide validator self-stake statistics' \ + 'make validator-info - validator stake, rewards, and reward projections' \ + 'make run ARGS="..." - run an explicit CLI command' \ + 'make typecheck - run the TypeScript compiler without emitting files' \ + 'make test - run unit and RPC integration tests' \ + 'make test-live - run full live RPC tests for every CLI mode' \ + 'make package-lock - regenerate package-lock.json in a container' + +check-podman: + @rootless="$$($(PODMAN) info --format '{{.Host.Security.Rootless}}')"; \ + if [[ "$$rootless" != "true" ]]; then \ + printf '%s\n' 'Error: rootless Podman is required.' >&2; \ + exit 1; \ + fi + +define run_in_container + @tar --create --file=- --directory="$(CURDIR)" $(SOURCE_EXCLUDES) . | \ + $(PODMAN) run $(CONTAINER_FLAGS) \ + --env RPC_URL --env CONFIG_PATH \ + $(CONTAINER_ENV) \ + $(IMAGE) sh -ceu ' \ + test "$$(id -u)" -ne 0; \ + test ! -e /src; \ + grep -Eq "^CapEff:[[:space:]]+0+$$" /proc/self/status; \ + grep -Eq "^NoNewPrivs:[[:space:]]+1$$" /proc/self/status; \ + mkdir -p /work/app "$$HOME"; \ + chmod 700 /work/app "$$HOME"; \ + tar --extract --file=- --directory=/work/app; \ + cd /work/app; \ + npm ci --ignore-scripts --no-audit --no-fund >&2; \ + if [ -d node_modules/tsc-prog ]; then \ + mv node_modules/tsc-prog node_modules/@polkadot-api/cli/node_modules/; \ + fi; \ + rpc_url="$$(node -e '\'' \ + const fs = require("node:fs"); \ + const path = process.env.CONFIG_PATH || "config.json"; \ + const config = JSON.parse(fs.readFileSync(path, "utf8")); \ + process.stdout.write(process.env.RPC_URL || config.rpcUrl || \ + "wss://rpc-assethub.novasama-tech.org"); \ + '\'')"; \ + ./node_modules/.bin/papi add ah -w "$$rpc_url" >&2; \ + $(BUILD_COMMAND) >&2; \ + exec "$$@" \ + ' sh $(1) +endef + +nominations: check-podman + $(call run_in_container,node dist/index.js --nominations) + +self-stake-stats: check-podman + $(call run_in_container,node dist/index.js --self-stake-stats) + +validator-info: check-podman + $(call run_in_container,node dist/index.js --validator-info) + +run: check-podman + $(call run_in_container,node dist/index.js $(ARGS)) + +typecheck: CONFIG_PATH := test/fixtures/rpc-config.json +typecheck: BUILD_COMMAND := : +typecheck: check-podman + $(call run_in_container,npm run typecheck) + +test: CONFIG_PATH := test/fixtures/rpc-config.json +test: check-podman + $(call run_in_container,npm test) + +test-live: CONFIG_PATH := test/fixtures/rpc-config.json +test-live: check-podman + $(call run_in_container,npm run test:live) + +package-lock: check-podman + @tar --create --file=- --directory="$(CURDIR)" $(SOURCE_EXCLUDES) . | \ + $(PODMAN) run $(CONTAINER_FLAGS) \ + $(CONTAINER_ENV) \ + $(IMAGE) sh -ceu ' \ + test "$$(id -u)" -ne 0; \ + test ! -e /src; \ + grep -Eq "^CapEff:[[:space:]]+0+$$" /proc/self/status; \ + grep -Eq "^NoNewPrivs:[[:space:]]+1$$" /proc/self/status; \ + mkdir -p /work/app "$$HOME"; \ + chmod 700 /work/app "$$HOME"; \ + tar --extract --file=- --directory=/work/app; \ + cd /work/app; \ + npm install --package-lock-only --ignore-scripts --no-audit --no-fund >&2; \ + cat package-lock.json \ + ' > package-lock.json.next + @mv package-lock.json.next package-lock.json diff --git a/polkadot-nominations/README.md b/polkadot-nominations/README.md index 1f4e8a8..e3183d9 100644 --- a/polkadot-nominations/README.md +++ b/polkadot-nominations/README.md @@ -1,169 +1,264 @@ # polkadot-nominations -Fetches and analyzes nominations for a specific validator on the Polkadot network (Asset Hub) via RPC. +A strictly typed TypeScript CLI that fetches nominations, self-stake +statistics, and validator information from Polkadot Asset Hub. + +## Runtime requirements + +- GNU Make +- Podman configured in rootless mode + +Running `node`, `npm`, or `npx` directly on the host is prohibited. The CLI also +checks that it is running in a container. All supported commands go through the +Makefile, which: + +1. verifies that Podman is running rootless; +2. starts an ephemeral, digest-pinned `node:22-bookworm-slim` container; +3. streams a filtered tar archive of the project into container tmpfs without + mounting any host path; +4. installs npm dependencies there; +5. creates `.papi/polkadot-api.json`, downloads metadata, and generates PAPI + descriptors there; +6. compiles the TypeScript sources into container-only `dist/`; +7. removes the container and all generated dependencies after the command. + +No source or config directory is exposed as a container mount. No +`node_modules`, `dist`, or generated `.papi` files are written to the host. + +### Container isolation + +The Makefile applies the same hardened profile to CLI runs, tests, and lock-file +generation: + +- the process runs as the invoking non-root UID in a private user namespace; +- all Linux capabilities are dropped and `no-new-privileges` is enabled; +- the image root filesystem is read-only, with size-limited `/work` and `/tmp` + tmpfs mounts as the only application-writable locations; +- PID, IPC, UTS, cgroup, and network namespaces are private, no ports are + published, and host aliases are omitted from `/etc/hosts`; +- CPU, memory, swap, and PID counts are limited; +- npm lifecycle scripts are disabled, including npm commands started by PAPI; +- Podman log persistence is disabled; command output remains attached to the + invoking terminal. + +Outbound networking remains enabled because dependency installation, PAPI +metadata generation, and live RPC queries require it. `make package-lock` is +the only target that intentionally changes a project file: it receives the new +lock-file over container stdout and atomically replaces `package-lock.json` on +the host. `make run ARGS="..."` accepts exactly one known CLI flag so its value +cannot become an arbitrary host-shell command. -For each nominator that targets the configured validator, the tool collects: -- Active stake (DOT) -- All nominated targets with their commission rates -- Commission percentile statistics per nomination and across all results - -It also reports the validator's own reward (commission cut + reward on its own -stake) for the last N completed eras, and whether each era has been claimed. - -## Prerequisites - -- Node.js >= 20 +## Configuration -## Setup +Create the local configuration if it does not exist: ```bash -npm install +cp config.json.example config.json ``` -This will install dependencies and run `npx papi` to generate chain type descriptors from the RPC endpoint. - -## Configuration - -Edit `config.json`: - ```json { - "validator": "1YourValidatorAddressHere________________________", - "percentiles": [0.5, 0.75, 0.9], - "minStakeDot": 0, - "rewardEras": 20 + "validators": [ + "1FirstValidatorAddress____________________________", + "1SecondValidatorAddress___________________________" + ], + "percentiles": [0.25, 0.5, 0.75, 0.9], + "minStakeDot": 1000, + "rewardEras": 20, + "rpcUrl": "wss://rpc-assethub.novasama-tech.org" } ``` | Field | Description | |---|---| -| `validator` | SS58 address of the validator to filter nominations for | -| `percentiles` | Percentile thresholds for commission analysis (0-1 range) | -| `minStakeDot` | Minimum nominator active stake in DOT. Set to `0` to disable the filter | -| `rewardEras` | Number of most recent completed eras to report validator rewards for (default `20`) | +| `validators` | Non-empty list of validator SS58 addresses | +| `percentiles` | Percentile thresholds in the inclusive `0..1` range | +| `minStakeDot` | Minimum nominator stake included by `--nominations` | +| `rewardEras` | Completed eras included by `--validator-info` | +| `rpcUrl` | Asset Hub WebSocket RPC; defaults to the URL shown above | -The RPC endpoint is configured in `.papi/polkadot-api.json` (generated by `npx papi add`). To override it at runtime: +The `RPC_URL` environment variable overrides `rpcUrl` for a single command: ```bash -RPC_URL=wss://other-node.example.com npm start +RPC_URL=wss://other-node.example.com make nominations ``` -## Usage +## Commands ```bash -npm start +make nominations +make self-stake-stats +make validator-info ``` -Output is JSON to stdout, logs go to stderr. To save results: +The script writes JSON to stdout and operational logs to stderr. Redirecting a +report therefore produces a clean JSON file: ```bash -npm start > nominations.json +make nominations > nominations.json +``` + +`make nominations` returns a map keyed by each address from `validators`: + +```json +{ + "1Validator...": { + "total_nominators": 212, + "unique_targets": 352, + "commission_p50": 3, + "stake_p50": 2236, + "nominations": [] + } +} ``` -## Output format +`make self-stake-stats` analyzes every registered validator, not only configured +addresses. It reports the percentages of active and all validators meeting the +10,000 DOT minimum. Reward-effective percentiles include every validator meeting +that minimum. Self-stake above 30,000 DOT is capped to 30,000 for percentile +calculation rather than excluded, because the network top-up no longer improves +the reward percentage above that point. The validator still remains part of the +competitive distribution and still earns on its actual own stake. ```json { - "validator": "1Abc......", - "total_nominators": 212, - "percentiles": { "p50": 3, "p75": 5, "p90": 5 }, - "rewards": { - "eras_requested": 20, - "active_era": 1852, - "is_validator": true, - "current_commission_pct": 0, - "latest_era_commission_pct": 10, - "reward_collapse_pending": true, - "total_reward": 412.5, - "unclaimed_reward": 55.3, - "eras": [ - { - "era": 1851, - "active": true, - "has_data": true, - "reward": 21.4567, - "commission_reward": 21.4567, - "own_stake_reward": 0, - "reward_planck": "214567000000", - "commission_pct": 10, - "own_stake": 0, - "total_stake": 1602477.39, - "claimed": false, - "claimed_pages": 0, - "total_pages": 1 - } - ] + "minimum_self_stake_dot": 10000, + "active_validators": { + "total": 297, + "meeting_minimum": 240, + "meeting_minimum_pct": 80.81 + }, + "all_validators": { + "total": 1300, + "meeting_minimum": 500, + "meeting_minimum_pct": 38.46 }, - "nominations": [ - { - "nominator": "1Xyz_...", - "stake": 1500, - "targets": [ - { "address": "1Aaa_...", "commission": 0 }, - { "address": "1Bbb..", "commission": 4 } - ], - "percentiles": { "p50": 1, "p75": 4, "p90": 5 }, - "submitted_in": 1738, - "suppressed": false + "reward_effective_self_stake": { + "minimum_dot": 10000, + "cap_dot": 30000, + "validator_count": 500, + "capped_validator_count": 200, + "self_stake_p25": 15000, + "self_stake_p50": 25000, + "self_stake_p75": 30000, + "self_stake_p90": 30000 + } +} +``` + +`make validator-info` reports current self-stake, actual historical personal +rewards, and what-if rewards for higher self-stake values. The scenario range +ends at the current on-chain `HardCapSelfStake` (`C`), not at a locally fixed +limit. Targets advance by 2,500 DOT through the current `OptimumSelfStake` +(currently 30,000 DOT), then by 10,000 DOT through `C`; exact optimum and `C` +points are always included even when a step does not land on them. + +```json +{ + "1Validator...": { + "self_stake": 10000, + "rewards": { + "eras_requested": 20, + "total_reward": 829.848, + "unclaimed_reward": 41.4924, + "eras": [ + { + "era": 2256, + "reward": 41.4924, + "commission_reward": 0.7063, + "own_stake_reward": 0, + "validator_incentive_reward": 40.7861 + } + ] + }, + "reward_projection": { + "basis": { + "eras_requested": 20, + "eras_used": 20, + "current_self_stake": 10000, + "actual_total_reward": 829.848, + "actual_average_reward_per_era": 41.4924 + }, + "curve": { + "minimum_self_stake": 10000, + "optimum_self_stake": 30000, + "hard_cap_self_stake": 100000, + "slope_factor": 0.5 + }, + "scenarios": [ + { + "self_stake": 12500, + "additional_self_stake": 2500, + "estimated_total_reward": 927.12, + "estimated_average_reward_per_era": 46.356, + "estimated_total_commission_reward": 14.126, + "estimated_total_own_stake_reward": 43.22, + "estimated_total_validator_incentive_reward": 869.774, + "increase_pct": 11.7217 + } + ] } - ] + } } ``` -### Rewards field +`basis.actual_total_reward` and every scenario cover only completed active eras +in the requested window that contain validator-incentive data. The comparison +is independent for each configured validator and uses that validator's current +ledger self-stake. A scenario changes only its self-stake: historical reward +points, commission, nominator stake, incentive budget, and all other validator +weights remain fixed. `increase_pct` is the estimated percentage change from +the matching `basis.actual_total_reward`; it is `null` when that baseline is +zero. These estimates are intended for comparison and do not predict election, +future era points, commission changes, or nominator movement. -The `rewards` object reports the validator's **own** payout — its commission cut -plus the reward on its self-bonded stake — for the last `rewardEras` completed -eras. Nominator payouts are excluded. +## Tests -Summary fields: +Run the strict TypeScript compiler without emitting files: -| Field | Description | -|---|---| -| `current_commission_pct` | The validator's **current** on-chain commission | -| `latest_era_commission_pct` | Commission snapshotted in the latest completed era | -| `reward_collapse_pending` | `true` when past rewards are commission-only, the current commission is now lower, and there is no own-stake cushion — i.e. rewards have **not** dropped yet but will collapse to ~0 once the new commission is snapshotted | -| `is_validator` | Whether the address is currently a registered validator | -| `total_reward` / `unclaimed_reward` | Sum of rewards (and of not-fully-claimed eras) over the window | +```bash +make typecheck +``` -Per era: +The project uses TypeScript 7 with `strict`, `noUncheckedIndexedAccess`, and +`exactOptionalPropertyTypes`. The checked source includes production code, +unit tests, RPC integration tests, and live tests. -| Field | Description | -|---|---| -| `era` | Era index | -| `active` | Whether the validator was in the active (elected) set this era | -| `has_data` | Whether this chain has reward data for the era (see migration note) | -| `reward` | Validator's personal reward in DOT (`0` if not active) | -| `commission_reward` / `own_stake_reward` | Split of `reward` into its commission and own-stake parts | -| `commission_pct` | Commission snapshotted for this era | -| `own_stake` / `total_stake` | Validator's own vs. total exposed stake this era (DOT) | -| `reward_planck` | `reward` in Planck, as an exact integer string | -| `claimed` | `true` only when **all** reward pages have been claimed | -| `claimed_pages` / `total_pages` | Claimed pages vs. total pages for the era | - -Because the per-era commission is snapshotted at era start, a commission change -(such as the 0% referendum) only appears in `current_commission_pct` until the -next era snapshots it. A validator whose income was entirely commission and who -has no own stake (`own_stake ≈ 0`) will see `reward` fall to ~0 from the first -era that snapshots the new commission — `reward_collapse_pending` flags this. - -The validator's own-stake reward is paid out with page `0`; its commission is -spread across all pages. A reward is therefore fully collected only once -`claimed_pages == total_pages`. - -> **Staking migration note:** staking moved to Asset Hub, so eras that predate -> the migration have no reward data on this chain. Those eras appear with -> `has_data: false`, `active: false`, and `reward: 0`. Requesting more -> `rewardEras` than exist since the migration is safe — the extra eras are -> simply reported with no data. - -## Changing the RPC endpoint - -To point to a different node: +Run unit tests and the lightweight RPC integration test together: + +```bash +make test +``` + +The integration test connects to the configured Asset Hub RPC, loads the +current active set, randomly selects one active validator, and verifies typed +validator preferences, live curve constants, and latest completed-era incentive +storage. It contains no production validator addresses. + +Run the separate full live suite: + +```bash +make test-live +``` + +The live suite loads the current active set over RPC, randomly samples three +validators, writes a temporary container-only config, and executes all CLI +modes. It validates nomination maps, global self-stake statistics, and validator +information against live data. It does not use or hardcode the validator list +from the local `config.json`. + +All validation targets install dependencies, generate PAPI files, compile the +TypeScript project, and execute Node.js only inside an ephemeral Podman +container. + +## Maintenance + +After changing dependency versions in `package.json`, regenerate the lock-file +inside Podman: ```bash -npx papi add ah -w wss://your-rpc-node.example.com -npx papi +make package-lock ``` -This updates `.papi/polkadot-api.json` and regenerates the type descriptors. +This target installs no dependencies on the host and replaces only +`package-lock.json`. diff --git a/polkadot-nominations/config.json.example b/polkadot-nominations/config.json.example index 44d3833..ca333c5 100644 --- a/polkadot-nominations/config.json.example +++ b/polkadot-nominations/config.json.example @@ -1,6 +1,9 @@ { - "validator": "", + "validators": [ + "1YourValidatorAddressHere________________________" + ], "percentiles": [0.25, 0.5, 0.75, 0.9], "minStakeDot": 1000, - "rewardEras": 20 + "rewardEras": 20, + "rpcUrl": "wss://rpc-assethub.novasama-tech.org" } diff --git a/polkadot-nominations/index.js b/polkadot-nominations/index.js deleted file mode 100644 index 0046b93..0000000 --- a/polkadot-nominations/index.js +++ /dev/null @@ -1,128 +0,0 @@ -import { validator, minStakeDot, rewardEras, rpcUrl } from "./src/config.js"; -import { - connect, - fetchActiveValidators, - fetchCommissions, - fetchNominators, - fetchStakes, -} from "./src/chain.js"; -import { fetchValidatorRewards } from "./src/rewards.js"; -import { calcPercentiles } from "./src/stats.js"; - -async function main() { - console.error(`Connecting to ${rpcUrl}...`); - console.error(`Validator: ${validator}`); - if (minStakeDot > 0) console.error(`Min stake filter: ${minStakeDot} DOT`); - - const { client, api } = connect(); - - console.error("Fetching chain data..."); - console.error(`Reward lookback: last ${rewardEras} eras`); - const [activeValidators, commissions, nominatorEntries, stakes, rewards] = - await Promise.all([ - fetchActiveValidators(api), - fetchCommissions(api), - fetchNominators(api), - fetchStakes(api), - fetchValidatorRewards(api), - ]); - const activeSet = new Set(activeValidators); - console.error( - `Loaded: ${commissions.size} validators (${activeSet.size} active), ${nominatorEntries.length} nominators, ${stakes.size} ledgers`, - ); - - const SELF_STAKE_THRESHOLD = 10_000; - let activeAbove = 0; - let allAbove = 0; - for (const addr of commissions.keys()) { - const selfStake = stakes.get(addr) ?? 0; - if (selfStake >= SELF_STAKE_THRESHOLD) { - allAbove++; - if (activeSet.has(addr)) activeAbove++; - } - } - const activeSelfStakePct = - activeSet.size > 0 - ? Math.round((activeAbove / activeSet.size) * 1e4) / 1e2 - : 0; - const allSelfStakePct = - commissions.size > 0 - ? Math.round((allAbove / commissions.size) * 1e4) / 1e2 - : 0; - - const nominations = []; - const uniqueTargets = new Map(); - - for (const entry of nominatorEntries) { - if (!entry.value.targets.includes(validator)) continue; - - const nominator = entry.keyArgs[0]; - const stake = stakes.get(nominator) ?? 0; - - if (minStakeDot > 0 && stake < minStakeDot) continue; - - const targets = []; - const targetCommissions = []; - - for (const addr of entry.value.targets) { - if (!commissions.has(addr)) continue; - const c = commissions.get(addr); - targets.push({ address: addr, commission: c }); - targetCommissions.push(c); - uniqueTargets.set(addr, c); - } - - nominations.push({ - nominator, - stake, - targets, - ...calcPercentiles(targetCommissions, { - descending: true, - prefix: "commission_", - }), - submitted_in: entry.value.submitted_in, - suppressed: entry.value.suppressed, - }); - } - nominatorEntries.length = 0; - - const nominatorStakes = nominations.map((n) => n.stake); - - const output = { - validator, - total_nominators: nominations.length, - unique_targets: uniqueTargets.size, - active_validators_self_stake_gte_10k_pct: activeSelfStakePct, - all_validators_self_stake_gte_10k_pct: allSelfStakePct, - ...calcPercentiles([...uniqueTargets.values()], { - descending: true, - prefix: "commission_", - }), - ...calcPercentiles(nominatorStakes, { prefix: "stake_" }), - rewards, - nominations, - }; - - const claimedEras = rewards.eras.filter((e) => e.active && e.claimed).length; - const activeEras = rewards.eras.filter((e) => e.active).length; - console.error( - `Rewards: ${rewards.total_reward} DOT over ${activeEras} active eras ` + - `(${claimedEras} claimed, ${rewards.unclaimed_reward} DOT unclaimed)`, - ); - console.error( - `Commission: current ${rewards.current_commission_pct}% vs ` + - `latest era snapshot ${rewards.latest_era_commission_pct}%` + - (rewards.reward_collapse_pending - ? " -> rewards NOT yet dropped, but will collapse to ~0 once the new commission is snapshotted (no own-stake cushion)" - : ""), - ); - console.error(`Found ${nominations.length} nominators for this validator`); - console.log(JSON.stringify(output, null, 2)); - - client.destroy(); -} - -main().catch((err) => { - console.error("Error:", err); - process.exit(1); -}); diff --git a/polkadot-nominations/index.ts b/polkadot-nominations/index.ts new file mode 100644 index 0000000..22a272b --- /dev/null +++ b/polkadot-nominations/index.ts @@ -0,0 +1,134 @@ +import { existsSync } from "node:fs"; +import { parseCliArgs, USAGE } from "./src/cli.js"; +import type { RunnableCommand } from "./src/cli.js"; + +function assertContainerRuntime(): void { + if (!existsSync("/run/.containerenv") && !existsSync("/.dockerenv")) { + throw new Error( + "Direct host execution is prohibited. Run this tool through make and Podman.", + ); + } +} + +async function runCommand(command: RunnableCommand): Promise { + const [config, chain, analysis, rewards] = await Promise.all([ + import("./src/config.js"), + import("./src/chain.js"), + import("./src/analysis.js"), + import("./src/rewards.js"), + ]); + + console.error(`Connecting to ${config.rpcUrl}...`); + const { client, api } = chain.connect(); + + try { + if (command === "nominations") { + console.error( + `Fetching nominations for ${config.validators.length} configured validator(s)...`, + ); + if (config.minStakeDot > 0) { + console.error(`Min nominator stake filter: ${config.minStakeDot} DOT`); + } + + const [commissions, nominatorEntries, stakes] = await Promise.all([ + chain.fetchCommissions(api), + chain.fetchNominators(api), + chain.fetchStakes(api), + ]); + console.error( + `Loaded: ${commissions.size} validators, ${nominatorEntries.length} nominators, ${stakes.size} ledgers`, + ); + + const output = analysis.buildNominationsReport({ + validators: config.validators, + commissions, + nominatorEntries, + stakes, + minStakeDot: config.minStakeDot, + percentiles: config.percentiles, + }); + for (const [validator, report] of Object.entries(output)) { + console.error( + `Found ${report.total_nominators} nominators for ${validator}`, + ); + } + return output; + } + + if (command === "self-stake-stats") { + console.error("Fetching network-wide validator self-stake data..."); + const [activeValidators, commissions, stakes] = await Promise.all([ + chain.fetchActiveValidators(api), + chain.fetchCommissions(api), + chain.fetchStakes(api), + ]); + console.error( + `Loaded: ${commissions.size} validators (${activeValidators.length} active), ${stakes.size} ledgers`, + ); + + return analysis.buildSelfStakeStats({ + validatorAddresses: commissions.keys(), + activeValidators, + stakes, + percentiles: config.percentiles, + }); + } + + console.error( + `Fetching general information for ${config.validators.length} configured validator(s)...`, + ); + console.error(`Reward lookback: last ${config.rewardEras} eras`); + const [stakeBalances, rewardHistories, rewardCurve] = await Promise.all([ + chain.fetchStakeBalances(api), + Promise.all( + config.validators.map(async (validator) => + [ + validator, + await rewards.fetchValidatorRewardHistory( + api, + validator, + config.rewardEras, + ), + ] as const, + ), + ), + rewards.fetchRewardCurveParameters(api), + ]); + + const detailsByValidator = new Map( + rewardHistories.map(([validator, history]) => [ + validator, + rewards.buildValidatorRewardReport( + history, + stakeBalances.planck.get(validator) ?? 0n, + rewardCurve, + ), + ]), + ); + + return analysis.buildValidatorInfo( + config.validators, + stakeBalances.dots, + detailsByValidator, + ); + } finally { + client.destroy(); + } +} + +async function main(): Promise { + assertContainerRuntime(); + const command = parseCliArgs(process.argv.slice(2)); + if (command === "help") { + console.log(USAGE); + return; + } + + const output = await runCommand(command); + console.log(JSON.stringify(output, null, 2)); +} + +main().catch((error: unknown) => { + console.error("Error:", error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/polkadot-nominations/package-lock.json b/polkadot-nominations/package-lock.json index 3521fe9..8a256fb 100644 --- a/polkadot-nominations/package-lock.json +++ b/polkadot-nominations/package-lock.json @@ -7,12 +7,16 @@ "": { "name": "polkadot-nominations", "version": "1.0.0", - "hasInstallScript": true, "license": "ISC", "dependencies": { "@polkadot-api/descriptors": "file:.papi/descriptors", - "polkadot-api": "^2.1.7", + "polkadot-api": "^2.2.2", "ws": "^8.19.0" + }, + "devDependencies": { + "@types/node": "^22.20.1", + "@types/ws": "^8.18.1", + "typescript": "^7.0.2" } }, ".papi/descriptors": { @@ -55,9 +59,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -72,9 +76,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -89,9 +93,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -106,9 +110,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -123,9 +127,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -140,9 +144,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -157,9 +161,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -174,9 +178,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -191,9 +195,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -208,9 +212,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -225,9 +229,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -242,9 +246,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -259,9 +263,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -276,9 +280,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -293,9 +297,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -310,9 +314,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -327,9 +331,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -344,9 +348,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -361,9 +365,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -378,9 +382,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -395,9 +399,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -412,9 +416,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -429,9 +433,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -446,9 +450,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -463,9 +467,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -480,9 +484,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -496,6 +500,22 @@ "node": ">=18" } }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@noble/hashes": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", @@ -509,26 +529,26 @@ } }, "node_modules/@polkadot-api/cli": { - "version": "0.21.6", - "resolved": "https://registry.npmjs.org/@polkadot-api/cli/-/cli-0.21.6.tgz", - "integrity": "sha512-1OQ2LvRSHkUcT4WRvLQFJ5PhLLN1Z1o6X1c7dguWW3eZ0bv7I9PWrbE5AyyXcy/jkceKCQ6HyISsfbBv8tIQmw==", + "version": "0.21.10", + "resolved": "https://registry.npmjs.org/@polkadot-api/cli/-/cli-0.21.10.tgz", + "integrity": "sha512-i2Z1Xv6BJOAhdzRKfTGao4WMo+Zv2UsHiKz9CDcaYL290ueYM6T9k32l043opBDQT1EdcUQH7I7hDiwP125neA==", "license": "MIT", "dependencies": { "@commander-js/extra-typings": "^15.0.0", "@polkadot-api/codegen": "0.22.5", "@polkadot-api/ink-contracts": "0.6.3", "@polkadot-api/json-rpc-provider": "0.2.0", - "@polkadot-api/known-chains": "0.11.6", + "@polkadot-api/known-chains": "0.12.1", "@polkadot-api/metadata-compatibility": "0.6.3", - "@polkadot-api/observable-client": "0.18.7", - "@polkadot-api/sm-provider": "0.3.6", - "@polkadot-api/smoldot": "0.4.5", + "@polkadot-api/observable-client": "0.18.8", + "@polkadot-api/sm-provider": "0.3.8", + "@polkadot-api/smoldot": "0.4.6", "@polkadot-api/substrate-bindings": "0.20.3", "@polkadot-api/substrate-client": "0.7.0", "@polkadot-api/utils": "0.4.0", "@polkadot-api/wasm-executor": "^0.2.3", - "@polkadot-api/ws-middleware": "0.3.5", - "@polkadot-api/ws-provider": "0.9.0", + "@polkadot-api/ws-middleware": "0.3.6", + "@polkadot-api/ws-provider": "0.9.1", "@types/node": "^25.9.4", "commander": "^15.0.0", "execa": "^9.6.1", @@ -547,6 +567,34 @@ "polkadot-api": "dist/main/src/main.js" } }, + "node_modules/@polkadot-api/cli/node_modules/@types/node": { + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@polkadot-api/cli/node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@polkadot-api/cli/node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, "node_modules/@polkadot-api/codegen": { "version": "0.22.5", "resolved": "https://registry.npmjs.org/@polkadot-api/codegen/-/codegen-0.22.5.tgz", @@ -582,15 +630,15 @@ "license": "MIT" }, "node_modules/@polkadot-api/json-rpc-provider-proxy": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@polkadot-api/json-rpc-provider-proxy/-/json-rpc-provider-proxy-0.4.0.tgz", - "integrity": "sha512-h+ay62wwj4y+PJ/JiZ8o54il9UYsgBxq1dxas8mN1Ybth1QxxYPxWvkhyswBNQuWBZIWJw5p3X9cGQku+LvaWQ==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@polkadot-api/json-rpc-provider-proxy/-/json-rpc-provider-proxy-0.4.1.tgz", + "integrity": "sha512-F1Hw01C60jn98KQ0vBbgcwAvEcMcKLsJ5kFzq600sgjc6Rnbn1VF/XjTdrjcIqvfpR9sXyGIrpImrov2Usbrsw==", "license": "MIT" }, "node_modules/@polkadot-api/known-chains": { - "version": "0.11.6", - "resolved": "https://registry.npmjs.org/@polkadot-api/known-chains/-/known-chains-0.11.6.tgz", - "integrity": "sha512-EEffAbSELaYgUjZN1oiPQuJLshI0kuepr+QcT3Z28CQFRfCfSvhLTI0YE4keJ43b8TnqK5VvE8g4fW/rZqTxoA==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@polkadot-api/known-chains/-/known-chains-0.12.1.tgz", + "integrity": "sha512-ZW9TYD2y5IhgNy28zi9sByMr0CaP2I9x8HcAI0pwZR2qDC0kysCG6brDlg1UoZKCjRO99SsKO/iU1o434JgMnQ==", "license": "MIT" }, "node_modules/@polkadot-api/logs-provider": { @@ -634,9 +682,9 @@ } }, "node_modules/@polkadot-api/observable-client": { - "version": "0.18.7", - "resolved": "https://registry.npmjs.org/@polkadot-api/observable-client/-/observable-client-0.18.7.tgz", - "integrity": "sha512-/eQi3D8jbXLg/L1hZX4eX0/+nO3kTxkOfYeWEtFhtOvFdfqiiy2DdXG3Zg5QzgHmfG+11p4vSlLiPCC2UvS5Qw==", + "version": "0.18.8", + "resolved": "https://registry.npmjs.org/@polkadot-api/observable-client/-/observable-client-0.18.8.tgz", + "integrity": "sha512-V9BTze/Y+egPMHUGEavIKEcRQ5bDvz3WxTfUvpgvC1ZdkIkvlifc2mNrj7OCehyzBSCcV0D/NDMXyXc5SuRplQ==", "license": "MIT", "dependencies": { "@polkadot-api/metadata-builders": "0.14.3", @@ -703,28 +751,43 @@ } }, "node_modules/@polkadot-api/sm-provider": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@polkadot-api/sm-provider/-/sm-provider-0.3.6.tgz", - "integrity": "sha512-jRGWSdCWN0SXfYlfyLBvVJ6Nw+C98NVO8yjXzrcvtnahHr9kycFvPyPoE6lETlZejqvr7mMAe1tNSCPAbRFlXA==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@polkadot-api/sm-provider/-/sm-provider-0.3.8.tgz", + "integrity": "sha512-jIvzBNsBsh6LIpCrUOa7miZdQPwLPns5IUQyz/8v84R3ON7URSIVbtywGSR8O83BZGyW/DmVkqa5lClLE2fkqw==", "license": "MIT", "dependencies": { "@polkadot-api/json-rpc-provider": "0.2.0", - "@polkadot-api/json-rpc-provider-proxy": "0.4.0" + "@polkadot-api/json-rpc-provider-proxy": "0.4.1" }, "peerDependencies": { "@polkadot-api/smoldot": ">=0.3" } }, "node_modules/@polkadot-api/smoldot": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/@polkadot-api/smoldot/-/smoldot-0.4.5.tgz", - "integrity": "sha512-CLQ4336BwFKCf0CbHmDldgmfMvLWV2SYBRQA98Wwu9pCNBujZo17c7doqnMeyVLi0b8ijexHD8czUaHfc9CiqQ==", + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/@polkadot-api/smoldot/-/smoldot-0.4.6.tgz", + "integrity": "sha512-gOXMJ10fXOub0zPP1cGAYeyf29BA2fB+qukrjXGcGN9b0Ya+lDgV7A7Q7y9JOrlGLeKbd1YdrYLAe/F43V7b9Q==", "license": "MIT", "dependencies": { - "@types/node": "^25.9.1", - "smoldot": "~3.2.0" + "@types/node": "^25.9.4", + "smoldot": "~3.3.1" } }, + "node_modules/@polkadot-api/smoldot/node_modules/@types/node": { + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@polkadot-api/smoldot/node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, "node_modules/@polkadot-api/substrate-bindings": { "version": "0.20.3", "resolved": "https://registry.npmjs.org/@polkadot-api/substrate-bindings/-/substrate-bindings-0.20.3.tgz", @@ -761,13 +824,13 @@ "license": "MIT" }, "node_modules/@polkadot-api/ws-middleware": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@polkadot-api/ws-middleware/-/ws-middleware-0.3.5.tgz", - "integrity": "sha512-UuAWlXJTXGELENzXRRyQL0J0lPI6Z0e6NI4ihdspC5q6mlDb79jeERoml6mOmDn+eyIVaWL7z+nNGUree7IT7Q==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@polkadot-api/ws-middleware/-/ws-middleware-0.3.6.tgz", + "integrity": "sha512-IMoJB572DdSYPshCQa2JmmehUEzX2Uwg5vKQafubbTMEFacXbifc6LTTVvi9Ue67rBuDy2VOWXBAm3BBpfKpDA==", "license": "MIT", "dependencies": { "@polkadot-api/json-rpc-provider": "0.2.0", - "@polkadot-api/json-rpc-provider-proxy": "0.4.0", + "@polkadot-api/json-rpc-provider-proxy": "0.4.1", "@polkadot-api/raw-client": "0.3.0", "@polkadot-api/substrate-bindings": "0.20.3", "@polkadot-api/utils": "0.4.0" @@ -777,13 +840,13 @@ } }, "node_modules/@polkadot-api/ws-provider": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@polkadot-api/ws-provider/-/ws-provider-0.9.0.tgz", - "integrity": "sha512-czTLgHEJPqx+6t5sb5OJpXDaSmbDTr8zYngBdUI47QYqcNB225zo/GdYakiNiGThtxVp1MLK7QsNXcT6XgqQNQ==", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@polkadot-api/ws-provider/-/ws-provider-0.9.1.tgz", + "integrity": "sha512-Ft2QJEjLZgTyKiCEbz3urSOxNfMdqNkg+QKqJhRbOFB7JtR1qMTvLE3TjM+5d2mNlEpOc3j2KDK3APNSj7uQ2Q==", "license": "MIT", "dependencies": { "@polkadot-api/json-rpc-provider": "0.2.0", - "@polkadot-api/json-rpc-provider-proxy": "0.4.0", + "@polkadot-api/json-rpc-provider-proxy": "0.4.1", "@polkadot-api/utils": "0.4.0" }, "peerDependencies": { @@ -791,9 +854,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", - "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", "cpu": [ "arm" ], @@ -804,9 +867,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", - "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", "cpu": [ "arm64" ], @@ -817,9 +880,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", - "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", "cpu": [ "arm64" ], @@ -830,9 +893,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", - "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", "cpu": [ "x64" ], @@ -843,9 +906,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", - "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", "cpu": [ "arm64" ], @@ -856,9 +919,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", - "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", "cpu": [ "x64" ], @@ -869,15 +932,12 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", - "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -885,15 +945,12 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", - "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -901,15 +958,12 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", - "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -917,15 +971,12 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", - "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -933,15 +984,12 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", - "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -949,15 +997,12 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", - "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -965,15 +1010,12 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", - "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -981,15 +1023,12 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", - "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -997,15 +1036,12 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", - "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1013,15 +1049,12 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", - "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1029,15 +1062,12 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", - "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1045,15 +1075,12 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", - "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1061,15 +1088,12 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", - "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1077,9 +1101,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", - "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", "cpu": [ "x64" ], @@ -1090,9 +1114,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", - "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", "cpu": [ "arm64" ], @@ -1103,9 +1127,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", - "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", "cpu": [ "arm64" ], @@ -1116,9 +1140,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", - "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", "cpu": [ "ia32" ], @@ -1129,9 +1153,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", - "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", "cpu": [ "x64" ], @@ -1142,9 +1166,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", - "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", "cpu": [ "x64" ], @@ -1197,12 +1221,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.9.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz", - "integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==", + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, "license": "MIT", "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "undici-types": "~6.21.0" } }, "node_modules/@types/normalize-package-data": { @@ -1211,6 +1236,336 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -1330,9 +1685,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "hasInstallScript": true, "license": "MIT", "peer": true, @@ -1343,32 +1698,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/execa": { @@ -1464,9 +1819,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.1.tgz", + "integrity": "sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==", "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -1594,9 +1949,9 @@ } }, "node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -1762,9 +2117,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", "engines": { "node": ">=12" @@ -1774,29 +2129,29 @@ } }, "node_modules/polkadot-api": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/polkadot-api/-/polkadot-api-2.1.7.tgz", - "integrity": "sha512-hSXzpbxtm4DkgBxC1m3ahqCiGTnOI75NmkWOJHlqgR3jmFYzPCMjuURpe8V1gKxzcDWMcffEmgwsM/4nm1Ox5Q==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/polkadot-api/-/polkadot-api-2.2.2.tgz", + "integrity": "sha512-OpDJ1pZSu6ulukTb8l2T3wyA5jH6uVlR9TEUcf4N4jw3rMS7wJ+AKKtFXNWXWnMBt0z4LnlOJ7OyoN7Osa5zXw==", "license": "MIT", "dependencies": { - "@polkadot-api/cli": "0.21.6", + "@polkadot-api/cli": "0.21.10", "@polkadot-api/ink-contracts": "0.6.3", "@polkadot-api/json-rpc-provider": "0.2.0", - "@polkadot-api/known-chains": "0.11.6", + "@polkadot-api/known-chains": "0.12.1", "@polkadot-api/logs-provider": "0.2.0", "@polkadot-api/metadata-builders": "0.14.3", "@polkadot-api/metadata-compatibility": "0.6.3", - "@polkadot-api/observable-client": "0.18.7", + "@polkadot-api/observable-client": "0.18.8", "@polkadot-api/pjs-signer": "0.7.3", "@polkadot-api/polkadot-signer": "0.1.6", "@polkadot-api/signer": "0.3.3", - "@polkadot-api/sm-provider": "0.3.6", - "@polkadot-api/smoldot": "0.4.5", + "@polkadot-api/sm-provider": "0.3.8", + "@polkadot-api/smoldot": "0.4.6", "@polkadot-api/substrate-bindings": "0.20.3", "@polkadot-api/substrate-client": "0.7.0", "@polkadot-api/utils": "0.4.0", - "@polkadot-api/ws-middleware": "0.3.5", - "@polkadot-api/ws-provider": "0.9.0", + "@polkadot-api/ws-middleware": "0.3.6", + "@polkadot-api/ws-provider": "0.9.1", "@rx-state/core": "^0.1.4" }, "bin": { @@ -1879,9 +2234,9 @@ } }, "node_modules/rollup": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", - "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", "license": "MIT", "dependencies": { "@types/estree": "1.0.9" @@ -1894,31 +2249,32 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.2", - "@rollup/rollup-android-arm64": "4.62.2", - "@rollup/rollup-darwin-arm64": "4.62.2", - "@rollup/rollup-darwin-x64": "4.62.2", - "@rollup/rollup-freebsd-arm64": "4.62.2", - "@rollup/rollup-freebsd-x64": "4.62.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", - "@rollup/rollup-linux-arm-musleabihf": "4.62.2", - "@rollup/rollup-linux-arm64-gnu": "4.62.2", - "@rollup/rollup-linux-arm64-musl": "4.62.2", - "@rollup/rollup-linux-loong64-gnu": "4.62.2", - "@rollup/rollup-linux-loong64-musl": "4.62.2", - "@rollup/rollup-linux-ppc64-gnu": "4.62.2", - "@rollup/rollup-linux-ppc64-musl": "4.62.2", - "@rollup/rollup-linux-riscv64-gnu": "4.62.2", - "@rollup/rollup-linux-riscv64-musl": "4.62.2", - "@rollup/rollup-linux-s390x-gnu": "4.62.2", - "@rollup/rollup-linux-x64-gnu": "4.62.2", - "@rollup/rollup-linux-x64-musl": "4.62.2", - "@rollup/rollup-openbsd-x64": "4.62.2", - "@rollup/rollup-openharmony-arm64": "4.62.2", - "@rollup/rollup-win32-arm64-msvc": "4.62.2", - "@rollup/rollup-win32-ia32-msvc": "4.62.2", - "@rollup/rollup-win32-x64-gnu": "4.62.2", - "@rollup/rollup-win32-x64-msvc": "4.62.2", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" } }, @@ -2002,9 +2358,9 @@ } }, "node_modules/smoldot": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/smoldot/-/smoldot-3.2.0.tgz", - "integrity": "sha512-FO332bGlKO1UE0Io0B30omzaHSLjmlN61h6F4MtbwZMdy4XPetphmwVhzv4VUXN7l7prIvRTGrgeqKepL8kNuw==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/smoldot/-/smoldot-3.3.2.tgz", + "integrity": "sha512-Zl4h/0gsw8cfTZzuJ7LV7mtR6QjxltwYjMY7MsVw0oXBXrLK8zyOS6DS9Vjsy57pX1vBMg6UVhHxjbH3W905zA==", "license": "GPL-3.0-or-later WITH Classpath-exception-2.0", "dependencies": { "ws": "^8.8.1" @@ -2070,9 +2426,9 @@ } }, "node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", "license": "MIT", "dependencies": { "get-east-asian-width": "^1.5.0", @@ -2143,9 +2499,9 @@ "license": "0BSD" }, "node_modules/type-fest": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", - "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" @@ -2158,22 +2514,44 @@ } }, "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" }, "node_modules/unicorn-magic": { @@ -2376,9 +2754,9 @@ } }, "node_modules/yoctocolors": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", - "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", "license": "MIT", "engines": { "node": ">=18" diff --git a/polkadot-nominations/package.json b/polkadot-nominations/package.json index c350c14..d5a23a9 100644 --- a/polkadot-nominations/package.json +++ b/polkadot-nominations/package.json @@ -2,10 +2,13 @@ "name": "polkadot-nominations", "version": "1.0.0", "type": "module", - "main": "index.js", + "main": "dist/index.js", "scripts": { - "start": "node index.js", - "postinstall": "npx papi" + "build": "tsc --project tsconfig.json", + "typecheck": "tsc --project tsconfig.json --noEmit", + "start": "node dist/index.js", + "test": "node --test --test-concurrency=1 dist/test/*.test.js dist/test/integration/*.test.js", + "test:live": "node --test --test-concurrency=1 dist/test/live/*.test.js" }, "keywords": [], "author": "", @@ -13,7 +16,12 @@ "description": "Fetch all nominations from Polkadot Asset Hub", "dependencies": { "@polkadot-api/descriptors": "file:.papi/descriptors", - "polkadot-api": "^2.1.7", + "polkadot-api": "^2.2.2", "ws": "^8.19.0" + }, + "devDependencies": { + "@types/node": "^22.20.1", + "@types/ws": "^8.18.1", + "typescript": "^7.0.2" } } diff --git a/polkadot-nominations/src/analysis.ts b/polkadot-nominations/src/analysis.ts new file mode 100644 index 0000000..359562d --- /dev/null +++ b/polkadot-nominations/src/analysis.ts @@ -0,0 +1,270 @@ +import { calcPercentiles } from "./stats.js"; + +export interface NominatorEntry { + readonly keyArgs: readonly [string, ...unknown[]]; + readonly value: { + readonly targets: readonly string[]; + readonly submitted_in: number; + readonly suppressed: boolean; + }; +} + +export interface NominationTarget { + readonly address: string; + readonly commission: number; +} + +export interface NominationReport { + readonly nominator: string; + readonly stake: number; + readonly targets: readonly NominationTarget[]; + readonly submitted_in: number; + readonly suppressed: boolean; + readonly [key: string]: unknown; +} + +export interface ValidatorNominationsReport { + readonly total_nominators: number; + readonly unique_targets: number; + readonly nominations: readonly NominationReport[]; + readonly [key: string]: unknown; +} + +export type NominationsOutput = Record; + +export interface BuildNominationsOptions { + readonly validators: readonly string[]; + readonly commissions: ReadonlyMap; + readonly nominatorEntries: readonly NominatorEntry[]; + readonly stakes: ReadonlyMap; + readonly minStakeDot: number; + readonly percentiles: readonly number[]; +} + +interface NominationsState { + readonly nominations: NominationReport[]; + readonly uniqueTargets: Map; +} + +function roundPercentage(numerator: number, denominator: number): number { + return denominator > 0 + ? Math.round((numerator / denominator) * 10_000) / 100 + : 0; +} + +export function buildNominationsReport({ + validators, + commissions, + nominatorEntries, + stakes, + minStakeDot, + percentiles, +}: BuildNominationsOptions): NominationsOutput { + const validatorSet = new Set(validators); + const reports = new Map( + validators.map((validator) => [ + validator, + { nominations: [], uniqueTargets: new Map() }, + ]), + ); + + for (const entry of nominatorEntries) { + const matchedValidators = entry.value.targets.filter((target) => + validatorSet.has(target), + ); + if (matchedValidators.length === 0) continue; + + const nominator = entry.keyArgs[0]; + const stake = stakes.get(nominator) ?? 0; + if (minStakeDot > 0 && stake < minStakeDot) continue; + + const targets: NominationTarget[] = []; + const targetCommissions: number[] = []; + for (const address of entry.value.targets) { + const commission = commissions.get(address); + if (commission === undefined) continue; + targets.push({ address, commission }); + targetCommissions.push(commission); + } + + const nomination: NominationReport = { + nominator, + stake, + targets, + ...calcPercentiles(targetCommissions, { + descending: true, + prefix: "commission_", + percentiles, + }), + submitted_in: entry.value.submitted_in, + suppressed: entry.value.suppressed, + }; + + for (const validator of matchedValidators) { + const report = reports.get(validator); + if (report === undefined) { + throw new Error(`Missing report state for validator ${validator}`); + } + report.nominations.push(nomination); + for (const target of targets) { + report.uniqueTargets.set(target.address, target.commission); + } + } + } + + return Object.fromEntries( + validators.map((validator) => { + const report = reports.get(validator); + if (report === undefined) { + throw new Error(`Missing report state for validator ${validator}`); + } + const { nominations, uniqueTargets } = report; + return [ + validator, + { + total_nominators: nominations.length, + unique_targets: uniqueTargets.size, + ...calcPercentiles([...uniqueTargets.values()], { + descending: true, + prefix: "commission_", + percentiles, + }), + ...calcPercentiles( + nominations.map((nomination) => nomination.stake), + { prefix: "stake_", percentiles }, + ), + nominations, + }, + ]; + }), + ); +} + +export interface ValidatorThresholdStats { + readonly total: number; + readonly meeting_minimum: number; + readonly meeting_minimum_pct: number; +} + +export interface RewardEffectiveSelfStake { + readonly minimum_dot: number; + readonly cap_dot: number; + readonly validator_count: number; + readonly capped_validator_count: number; + readonly [key: string]: number; +} + +export interface SelfStakeStats { + readonly minimum_self_stake_dot: number; + readonly active_validators: ValidatorThresholdStats; + readonly all_validators: ValidatorThresholdStats; + readonly reward_effective_self_stake: RewardEffectiveSelfStake; +} + +export interface BuildSelfStakeOptions { + readonly validatorAddresses: Iterable; + readonly activeValidators: Iterable; + readonly stakes: ReadonlyMap; + readonly percentiles: readonly number[]; + readonly minimumDot?: number; + readonly rewardCapDot?: number; +} + +export function buildSelfStakeStats({ + validatorAddresses, + activeValidators, + stakes, + percentiles, + minimumDot = 10_000, + rewardCapDot = 30_000, +}: BuildSelfStakeOptions): SelfStakeStats { + const activeSet = new Set(activeValidators); + const allSet = new Set(validatorAddresses); + const activeStakes: number[] = []; + const allStakes: number[] = []; + + for (const address of allSet) { + const selfStake = stakes.get(address) ?? 0; + allStakes.push(selfStake); + if (activeSet.has(address)) activeStakes.push(selfStake); + } + + const activeMeetingMinimum = activeStakes.filter( + (stake) => stake >= minimumDot, + ).length; + const allMeetingMinimum = allStakes.filter( + (stake) => stake >= minimumDot, + ).length; + const eligibleStakes = allStakes.filter((stake) => stake >= minimumDot); + const effectiveStakes = eligibleStakes.map((stake) => + Math.min(stake, rewardCapDot), + ); + const cappedValidatorCount = eligibleStakes.filter( + (stake) => stake > rewardCapDot, + ).length; + + return { + minimum_self_stake_dot: minimumDot, + active_validators: { + total: activeSet.size, + meeting_minimum: activeMeetingMinimum, + meeting_minimum_pct: roundPercentage( + activeMeetingMinimum, + activeSet.size, + ), + }, + all_validators: { + total: allSet.size, + meeting_minimum: allMeetingMinimum, + meeting_minimum_pct: roundPercentage(allMeetingMinimum, allSet.size), + }, + reward_effective_self_stake: { + minimum_dot: minimumDot, + cap_dot: rewardCapDot, + validator_count: effectiveStakes.length, + capped_validator_count: cappedValidatorCount, + ...calcPercentiles(effectiveStakes, { + prefix: "self_stake_", + percentiles, + }), + }, + }; +} + +export interface ValidatorInfoDetails { + readonly rewards: TRewards; + readonly reward_projection: TProjection; +} + +export interface ValidatorInfo { + readonly self_stake: number; + readonly rewards: TRewards; + readonly reward_projection: TProjection; +} + +export type ValidatorInfoOutput = Record< + string, + ValidatorInfo +>; + +export function buildValidatorInfo( + validators: readonly string[], + stakes: ReadonlyMap, + detailsByValidator: ReadonlyMap< + string, + ValidatorInfoDetails + >, +): ValidatorInfoOutput { + return Object.fromEntries( + validators.map((validator) => { + const details = detailsByValidator.get(validator); + if (details === undefined) { + throw new Error(`Missing validator information for ${validator}`); + } + return [ + validator, + { self_stake: stakes.get(validator) ?? 0, ...details }, + ]; + }), + ); +} diff --git a/polkadot-nominations/src/chain.js b/polkadot-nominations/src/chain.js deleted file mode 100644 index db532ff..0000000 --- a/polkadot-nominations/src/chain.js +++ /dev/null @@ -1,47 +0,0 @@ -import { createClient } from "polkadot-api"; -import { getWsProvider } from "polkadot-api/ws"; -import { ah } from "@polkadot-api/descriptors"; -import WebSocket from "ws"; -import { rpcUrl, DOT_PLANCK } from "./config.js"; - -const PERBILL = 1_000_000_000; - -export function connect() { - const provider = getWsProvider(rpcUrl, { websocketClass: WebSocket }); - const client = createClient(provider); - const api = client.getTypedApi(ah); - return { client, api }; -} - -export async function fetchCommissions(api) { - const entries = await api.query.Staking.Validators.getEntries(); - const map = new Map(); - for (const entry of entries) { - const pct = (entry.value.commission / PERBILL) * 100; - map.set(entry.keyArgs[0], Math.round(pct * 1e4) / 1e4); - } - return map; -} - -export async function fetchActiveValidators(api) { - const era = await api.query.Staking.ActiveEra.getValue(); - const entries = await api.query.Staking.ErasStakersOverview.getEntries( - era.index, - ); - return entries.map((e) => e.keyArgs[1]); -} - -export async function fetchNominators(api) { - return api.query.Staking.Nominators.getEntries(); -} - -export async function fetchStakes(api) { - const entries = await api.query.Staking.Ledger.getEntries(); - const map = new Map(); - for (const entry of entries) { - const stash = entry.value.stash; - const activeDot = Number(entry.value.active / DOT_PLANCK); - map.set(stash, activeDot); - } - return map; -} diff --git a/polkadot-nominations/src/chain.ts b/polkadot-nominations/src/chain.ts new file mode 100644 index 0000000..af26619 --- /dev/null +++ b/polkadot-nominations/src/chain.ts @@ -0,0 +1,73 @@ +import { createClient } from "polkadot-api"; +import { getWsProvider } from "polkadot-api/ws"; +import { ah } from "@polkadot-api/descriptors"; +import WebSocket from "ws"; +import { rpcUrl } from "./config.js"; +import { planckToDot } from "./constants.js"; + +const PERBILL = 1_000_000_000; + +export function connect() { + const provider = getWsProvider(rpcUrl, { + websocketClass: WebSocket as unknown as typeof globalThis.WebSocket, + }); + const client = createClient(provider); + const api = client.getTypedApi(ah); + return { client, api }; +} + +export type ChainApi = ReturnType["api"]; + +export async function fetchCommissions( + api: ChainApi, +): Promise> { + const entries = await api.query.Staking.Validators.getEntries(); + const commissions = new Map(); + for (const entry of entries) { + const percentage = (entry.value.commission / PERBILL) * 100; + commissions.set( + entry.keyArgs[0], + Math.round(percentage * 10_000) / 10_000, + ); + } + return commissions; +} + +export async function fetchActiveValidators(api: ChainApi): Promise { + const era = await api.query.Staking.ActiveEra.getValue(); + if (era === undefined) { + throw new Error("Staking.ActiveEra is unavailable"); + } + const entries = await api.query.Staking.ErasStakersOverview.getEntries( + era.index, + ); + return entries.map( + (entry: { readonly keyArgs: readonly [number, string] }) => entry.keyArgs[1], + ); +} + +export async function fetchNominators(api: ChainApi) { + return api.query.Staking.Nominators.getEntries(); +} + +export interface StakeBalances { + readonly dots: ReadonlyMap; + readonly planck: ReadonlyMap; +} + +export async function fetchStakeBalances( + api: ChainApi, +): Promise { + const entries = await api.query.Staking.Ledger.getEntries(); + const dots = new Map(); + const planck = new Map(); + for (const entry of entries) { + dots.set(entry.value.stash, planckToDot(entry.value.active)); + planck.set(entry.value.stash, entry.value.active); + } + return { dots, planck }; +} + +export async function fetchStakes(api: ChainApi): Promise> { + return new Map((await fetchStakeBalances(api)).dots); +} diff --git a/polkadot-nominations/src/cli.ts b/polkadot-nominations/src/cli.ts new file mode 100644 index 0000000..d5a7d59 --- /dev/null +++ b/polkadot-nominations/src/cli.ts @@ -0,0 +1,33 @@ +export type RunnableCommand = + | "nominations" + | "self-stake-stats" + | "validator-info"; +export type CliCommand = RunnableCommand | "help"; + +export const COMMANDS: ReadonlyMap = new Map([ + ["--nominations", "nominations"], + ["--self-stake-stats", "self-stake-stats"], + ["--validator-info", "validator-info"], +]); + +export const USAGE = `Usage: node dist/index.js + +Commands: + --nominations Nomination reports for validators from config.json + --self-stake-stats Network-wide validator self-stake statistics + --validator-info Validator stake, actual rewards, and reward projections + --help Show this help`; + +export function parseCliArgs(args: readonly string[]): CliCommand { + const argument = args[0]; + if (args.length === 1 && (argument === "--help" || argument === "-h")) { + return "help"; + } + + const command = argument === undefined ? undefined : COMMANDS.get(argument); + if (args.length !== 1 || command === undefined) { + throw new Error(`Exactly one command is required.\n\n${USAGE}`); + } + + return command; +} diff --git a/polkadot-nominations/src/config-schema.ts b/polkadot-nominations/src/config-schema.ts new file mode 100644 index 0000000..18cda83 --- /dev/null +++ b/polkadot-nominations/src/config-schema.ts @@ -0,0 +1,85 @@ +import { DEFAULT_RPC_URL } from "./constants.js"; + +const DEFAULT_PERCENTILES = [0.5, 0.75, 0.9] as const; + +export interface Config { + readonly validators: readonly string[]; + readonly percentiles: readonly number[]; + readonly minStakeDot: number; + readonly rewardEras: number; + readonly rpcUrl: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNonEmptyStringArray(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.every((item: unknown) => typeof item === "string" && item.trim() !== "") + ); +} + +function isPercentileArray(value: unknown): value is number[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.every( + (item: unknown) => + typeof item === "number" && Number.isFinite(item) && item >= 0 && item <= 1, + ) + ); +} + +export function parseConfig(value: unknown): Config { + if (!isRecord(value)) { + throw new Error("config.json must contain a JSON object"); + } + + if (!isNonEmptyStringArray(value.validators)) { + throw new Error('config.json must contain a non-empty "validators" array'); + } + const validators = [...value.validators]; + if (new Set(validators).size !== validators.length) { + throw new Error("validators must not contain duplicate addresses"); + } + + const percentilesValue = value.percentiles ?? DEFAULT_PERCENTILES; + if (!isPercentileArray(percentilesValue)) { + throw new Error("percentiles must be a non-empty array of numbers from 0 to 1"); + } + const percentiles = [...percentilesValue]; + + const minStakeDot = value.minStakeDot ?? 0; + if ( + typeof minStakeDot !== "number" || + !Number.isFinite(minStakeDot) || + minStakeDot < 0 + ) { + throw new Error("minStakeDot must be a non-negative number"); + } + + const rewardEras = value.rewardEras ?? 20; + if ( + typeof rewardEras !== "number" || + !Number.isInteger(rewardEras) || + rewardEras < 0 + ) { + throw new Error("rewardEras must be a non-negative integer"); + } + + const rpcUrl = value.rpcUrl ?? DEFAULT_RPC_URL; + if (typeof rpcUrl !== "string" || rpcUrl.trim() === "") { + throw new Error("rpcUrl must be a non-empty string"); + } + + return { + validators, + percentiles, + minStakeDot, + rewardEras, + rpcUrl, + }; +} diff --git a/polkadot-nominations/src/config.js b/polkadot-nominations/src/config.js deleted file mode 100644 index 6176bdf..0000000 --- a/polkadot-nominations/src/config.js +++ /dev/null @@ -1,15 +0,0 @@ -import { readFileSync } from "node:fs"; - -const config = JSON.parse(readFileSync("config.json", "utf-8")); -const papiConfig = JSON.parse(readFileSync(".papi/polkadot-api.json", "utf-8")); - -export const validator = config.validator; -export const percentiles = config.percentiles ?? [0.5, 0.75, 0.9]; -export const minStakeDot = config.minStakeDot ?? 0; -// Number of most recent completed eras to report validator rewards for. -export const rewardEras = config.rewardEras ?? 20; -export const rpcUrl = process.env.RPC_URL || papiConfig.entries.ah.wsUrl; - -// DOT has 10 decimals -export const DOT_DECIMALS = 10n; -export const DOT_PLANCK = 10n ** DOT_DECIMALS; diff --git a/polkadot-nominations/src/config.ts b/polkadot-nominations/src/config.ts new file mode 100644 index 0000000..733c141 --- /dev/null +++ b/polkadot-nominations/src/config.ts @@ -0,0 +1,10 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { parseConfig } from "./config-schema.js"; +export { DOT_DECIMALS, DOT_PLANCK } from "./constants.js"; + +const configPath = resolve(process.env.CONFIG_PATH || "config.json"); +const config = parseConfig(JSON.parse(readFileSync(configPath, "utf-8"))); + +export const { validators, percentiles, minStakeDot, rewardEras } = config; +export const rpcUrl = process.env.RPC_URL || config.rpcUrl; diff --git a/polkadot-nominations/src/constants.ts b/polkadot-nominations/src/constants.ts new file mode 100644 index 0000000..c917e3f --- /dev/null +++ b/polkadot-nominations/src/constants.ts @@ -0,0 +1,10 @@ +export const DOT_DECIMALS = 10n; +export const DOT_PLANCK = 10n ** DOT_DECIMALS; +export const DEFAULT_RPC_URL = "wss://rpc-assethub.novasama-tech.org"; + +export function planckToDot(planck: bigint): number { + const whole = planck / DOT_PLANCK; + const fraction = planck % DOT_PLANCK; + const dot = Number(whole) + Number(fraction) / Number(DOT_PLANCK); + return Math.round(dot * 10_000) / 10_000; +} diff --git a/polkadot-nominations/src/rewards.js b/polkadot-nominations/src/rewards.js deleted file mode 100644 index d194c2e..0000000 --- a/polkadot-nominations/src/rewards.js +++ /dev/null @@ -1,159 +0,0 @@ -import { validator, rewardEras, DOT_PLANCK } from "./config.js"; - -// Commission is stored on-chain as a Perbill (parts per billion). -const PERBILL = 1_000_000_000n; - -function toDot(planck) { - // Per-era, single-validator rewards stay far below 2^53 planck, so the - // Number conversion is lossless here. Round to 4 decimals like the rest. - return Math.round((Number(planck) / Number(DOT_PLANCK)) * 1e4) / 1e4; -} - -// Compute the reward that is paid to the validator's own stash for one era. -// -// This mirrors `pallet_staking::do_payout_stakers_by_page`: the validator -// personally receives its commission cut plus the staking reward on its own -// bonded stake. Nominator payouts are intentionally excluded. -// -// validator_total_payout = era_payout * validator_points / total_points -// commission_payout = commission * validator_total_payout -// leftover = validator_total_payout - commission_payout -// own_stake_reward = leftover * own / total -// personal_reward = commission_payout + own_stake_reward -async function computeEraReward(api, era) { - const [overview, eraPayout, points, prefs, claimedPages] = await Promise.all([ - api.query.Staking.ErasStakersOverview.getValue(era, validator), - api.query.Staking.ErasValidatorReward.getValue(era), - api.query.Staking.ErasRewardPoints.getValue(era), - api.query.Staking.ErasValidatorPrefs.getValue(era, validator), - api.query.Staking.ClaimedRewards.getValue(era, validator), - ]); - - // No era payout at all => the era has no reward data on this chain. This is - // the case for eras that predate the Asset Hub staking migration as well as - // for the not-yet-finalized current era. - const hasData = eraPayout != null; - - // No exposure overview => the validator was not in the active set this era - // (was not elected), so there is nothing to be paid out to it. - if (!hasData || !overview) { - return { - era, - active: false, - has_data: hasData, - reward: 0, - commission_reward: 0, - own_stake_reward: 0, - reward_planck: "0", - commission_pct: null, - own_stake: 0, - total_stake: 0, - claimed: false, - claimed_pages: 0, - total_pages: overview ? overview.page_count : 0, - }; - } - - const totalPages = overview.page_count; - const claimed = Array.isArray(claimedPages) ? claimedPages : []; - // The validator's own-stake reward is only paid out with page 0, while its - // commission is spread across every page, so it is fully paid only once all - // pages have been claimed. - const fullyClaimed = totalPages > 0 && claimed.length >= totalPages; - - const totalPoints = BigInt(points?.total ?? 0); - const vp = (points?.individual ?? []).find(([addr]) => addr === validator); - const validatorPoints = vp ? BigInt(vp[1]) : 0n; - - const commission = BigInt(prefs?.commission ?? 0); // Perbill (0..1e9) - - let commissionPayout = 0n; - let ownReward = 0n; - if (totalPoints > 0n && validatorPoints > 0n) { - const validatorTotalPayout = (eraPayout * validatorPoints) / totalPoints; - commissionPayout = (validatorTotalPayout * commission) / PERBILL; - const leftover = validatorTotalPayout - commissionPayout; - ownReward = - overview.total > 0n ? (leftover * overview.own) / overview.total : 0n; - } - const personalPlanck = commissionPayout + ownReward; - - return { - era, - active: true, - has_data: true, - reward: toDot(personalPlanck), - // Split so it is clear whether the reward comes from commission (which the - // 0% referendum removes) or from the validator's own stake. - commission_reward: toDot(commissionPayout), - own_stake_reward: toDot(ownReward), - reward_planck: personalPlanck.toString(), - commission_pct: Number(commission) / 1e7, // Perbill -> percent - own_stake: toDot(overview.own), - total_stake: toDot(overview.total), - claimed: fullyClaimed, - claimed_pages: claimed.length, - total_pages: totalPages, - }; -} - -// Report the validator's personal reward for the last `rewardEras` completed -// eras (the active era is still running and has no payout yet). -export async function fetchValidatorRewards(api) { - const [activeEra, curPrefs] = await Promise.all([ - api.query.Staking.ActiveEra.getValue(), - // Current on-chain commission. Per-era prefs are snapshotted at era start, - // so a recent commission change (e.g. the 0% referendum) is only visible - // here until the next era snapshots it. - api.query.Staking.Validators.getValue(validator), - ]); - if (!activeEra) { - return { eras_requested: rewardEras, eras: [] }; - } - - const eraIndices = []; - for ( - let e = activeEra.index - 1; - e >= 0 && eraIndices.length < rewardEras; - e-- - ) { - eraIndices.push(e); - } - - const eras = await Promise.all( - eraIndices.map((era) => computeEraReward(api, era)), - ); - - let totalReward = 0; - let unclaimedReward = 0; - for (const e of eras) { - totalReward += e.reward; - if (e.active && !e.claimed) unclaimedReward += e.reward; - } - - // The latest completed era still reflects the pre-change commission snapshot. - const latest = eras.find((e) => e.active); - const currentCommissionPct = curPrefs - ? Number(curPrefs.commission) / 1e7 - : null; - // Rewards will collapse once the new (lower) commission is snapshotted, if the - // validator has no own-stake cushion to earn from. - const rewardCollapsePending = - latest != null && - currentCommissionPct != null && - latest.reward > 0 && - latest.commission_pct > currentCommissionPct && - latest.own_stake_reward === 0; - - return { - eras_requested: rewardEras, - active_era: activeEra.index, - is_validator: curPrefs != null, - current_commission_pct: currentCommissionPct, - latest_era_commission_pct: latest ? latest.commission_pct : null, - reward_collapse_pending: rewardCollapsePending, - total_reward: Math.round(totalReward * 1e4) / 1e4, - unclaimed_reward: Math.round(unclaimedReward * 1e4) / 1e4, - eras, - }; -} diff --git a/polkadot-nominations/src/rewards.ts b/polkadot-nominations/src/rewards.ts new file mode 100644 index 0000000..66d18ad --- /dev/null +++ b/polkadot-nominations/src/rewards.ts @@ -0,0 +1,626 @@ +import type { ChainApi } from "./chain.js"; +import { DOT_PLANCK, planckToDot } from "./constants.js"; + +const PERBILL = 1_000_000_000n; +const BELOW_OPTIMUM_STEP = 2_500n * DOT_PLANCK; +const ABOVE_OPTIMUM_STEP = 10_000n * DOT_PLANCK; + +export interface EraReward { + readonly era: number; + readonly active: boolean; + readonly has_data: boolean; + readonly reward: number; + readonly commission_reward: number; + readonly own_stake_reward: number; + readonly validator_incentive_reward: number; + readonly reward_planck: string; + readonly commission_pct: number | null; + readonly own_stake: number; + readonly total_stake: number; + readonly claimed: boolean; + readonly claimed_pages: number; + readonly total_pages: number; +} + +export interface ValidatorRewards { + readonly eras_requested: number; + readonly active_era: number | null; + readonly is_validator: boolean; + readonly current_commission_pct: number | null; + readonly latest_era_commission_pct: number | null; + readonly reward_collapse_pending: boolean; + readonly total_reward: number; + readonly unclaimed_reward: number; + readonly eras: readonly EraReward[]; +} + +export interface RewardCurveParameters { + readonly minimumSelfStake: bigint; + readonly optimumSelfStake: bigint; + readonly hardCapSelfStake: bigint; + readonly slopeFactor: bigint; +} + +export interface RewardProjectionScenario { + readonly self_stake: number; + readonly additional_self_stake: number; + readonly estimated_total_reward: number; + readonly estimated_average_reward_per_era: number; + readonly estimated_total_commission_reward: number; + readonly estimated_total_own_stake_reward: number; + readonly estimated_total_validator_incentive_reward: number; + readonly increase_pct: number | null; +} + +export interface RewardProjection { + readonly basis: { + readonly eras_requested: number; + readonly eras_used: number; + readonly current_self_stake: number; + readonly actual_total_reward: number; + readonly actual_average_reward_per_era: number; + }; + readonly curve: { + readonly minimum_self_stake: number; + readonly optimum_self_stake: number; + readonly hard_cap_self_stake: number; + readonly slope_factor: number; + }; + readonly scenarios: readonly RewardProjectionScenario[]; +} + +export interface ValidatorRewardReport { + readonly rewards: ValidatorRewards; + readonly reward_projection: RewardProjection; +} + +interface ExposureOverview { + readonly own: bigint; + readonly total: bigint; + readonly page_count: number; +} + +interface EraRewardComputation { + readonly output: EraReward; + readonly rewardPlanck: bigint; + readonly eraPayout: bigint | undefined; + readonly overview: ExposureOverview | undefined; + readonly totalPoints: bigint; + readonly validatorPoints: bigint; + readonly commission: bigint; + readonly incentiveBudget: bigint; + readonly incentiveWeight: bigint; + readonly totalIncentiveWeight: bigint; +} + +export interface ValidatorRewardHistory { + readonly rewards: ValidatorRewards; + readonly computations: readonly EraRewardComputation[]; +} + +interface RewardParts { + readonly commission: bigint; + readonly ownStake: bigint; + readonly incentive: bigint; +} + +function perbillFromRational(numerator: bigint, denominator: bigint): bigint { + if (numerator <= 0n || denominator <= 0n) return 0n; + const parts = (numerator * PERBILL) / denominator; + return parts > PERBILL ? PERBILL : parts; +} + +function perbillMulFloor(parts: bigint, value: bigint): bigint { + return (value * parts) / PERBILL; +} + +function ratioMulFloor( + value: bigint, + numerator: bigint, + denominator: bigint, +): bigint { + return perbillMulFloor( + perbillFromRational(numerator, denominator), + value, + ); +} + +function integerSquareRoot(value: bigint): bigint { + if (value < 0n) throw new Error("Cannot calculate the square root of a negative value"); + if (value < 2n) return value; + + let estimate = value; + let next = (estimate + 1n) / 2n; + while (next < estimate) { + estimate = next; + next = (estimate + value / estimate) / 2n; + } + return estimate; +} + +export function calculateValidatorIncentiveWeight( + selfStake: bigint, + parameters: RewardCurveParameters, +): bigint { + const { optimumSelfStake, hardCapSelfStake, slopeFactor } = parameters; + if (selfStake <= 0n || (optimumSelfStake === 0n && hardCapSelfStake === 0n)) { + return 0n; + } + if (optimumSelfStake > hardCapSelfStake) { + throw new Error("Optimum self-stake cannot exceed the hard cap"); + } + if (slopeFactor < 0n || slopeFactor > PERBILL) { + throw new Error("Self-stake slope factor must be a Perbill value"); + } + + if (selfStake <= optimumSelfStake) { + return integerSquareRoot(selfStake); + } + + const effectiveStake = selfStake <= hardCapSelfStake + ? selfStake + : hardCapSelfStake; + const slopeSquared = perbillMulFloor(slopeFactor, slopeFactor); + const excess = effectiveStake - optimumSelfStake; + const dampenedExcess = perbillMulFloor(slopeSquared, excess); + return integerSquareRoot(optimumSelfStake + dampenedExcess); +} + +function addTarget(targets: Set, value: bigint, cap: bigint): void { + if (value > 0n && value <= cap) targets.add(value); +} + +export function buildSelfStakeScenarioTargets( + currentSelfStake: bigint, + parameters: RewardCurveParameters, +): bigint[] { + const { minimumSelfStake, optimumSelfStake, hardCapSelfStake } = parameters; + if (hardCapSelfStake <= 0n) return []; + if (optimumSelfStake > hardCapSelfStake) { + throw new Error("Optimum self-stake cannot exceed the hard cap"); + } + + const targets = new Set(); + if (minimumSelfStake <= optimumSelfStake) { + for ( + let target = minimumSelfStake; + target < optimumSelfStake; + target += BELOW_OPTIMUM_STEP + ) { + addTarget(targets, target, hardCapSelfStake); + } + } + addTarget(targets, optimumSelfStake, hardCapSelfStake); + + for ( + let target = optimumSelfStake + ABOVE_OPTIMUM_STEP; + target < hardCapSelfStake; + target += ABOVE_OPTIMUM_STEP + ) { + addTarget(targets, target, hardCapSelfStake); + } + addTarget(targets, hardCapSelfStake, hardCapSelfStake); + + return [...targets] + .filter((target) => target > currentSelfStake) + .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)); +} + +function calculateStakerRewardParts( + validatorTotalPayout: bigint, + commission: bigint, + ownStake: bigint, + totalExposure: bigint, +): Pick { + const commissionPayout = perbillMulFloor(commission, validatorTotalPayout); + const leftover = validatorTotalPayout - commissionPayout; + const ownStakePayout = ratioMulFloor(leftover, ownStake, totalExposure); + return { commission: commissionPayout, ownStake: ownStakePayout }; +} + +function calculateIncentivePayout( + budget: bigint, + validatorWeight: bigint, + totalWeight: bigint, +): bigint { + return ratioMulFloor(budget, validatorWeight, totalWeight); +} + +async function fetchEraRewardComputation( + api: ChainApi, + validator: string, + era: number, +): Promise { + const [ + overview, + eraPayout, + points, + preferences, + claimedPages, + incentiveBudget, + incentiveWeight, + totalIncentiveWeight, + ] = await Promise.all([ + api.query.Staking.ErasStakersOverview.getValue(era, validator), + api.query.Staking.ErasValidatorReward.getValue(era), + api.query.Staking.ErasRewardPoints.getValue(era), + api.query.Staking.ErasValidatorPrefs.getValue(era, validator), + api.query.Staking.ClaimedRewards.getValue(era, validator), + api.query.Staking.ErasValidatorIncentiveBudget.getValue(era), + api.query.Staking.ErasValidatorIncentiveWeight.getValue(era, validator), + api.query.Staking.ErasSumValidatorIncentiveWeight.getValue(era), + ]); + + const totalPoints = BigInt(points?.total ?? 0); + const validatorPointEntry = (points?.individual ?? []).find( + ([address]: readonly [string, number]) => address === validator, + ); + const validatorPoints = BigInt(validatorPointEntry?.[1] ?? 0); + const commission = BigInt(preferences?.commission ?? 0); + const budget = incentiveBudget ?? 0n; + const weight = incentiveWeight ?? 0n; + const sumWeight = totalIncentiveWeight ?? 0n; + + if (eraPayout === undefined || overview === undefined) { + return { + output: { + era, + active: false, + has_data: eraPayout !== undefined, + reward: 0, + commission_reward: 0, + own_stake_reward: 0, + validator_incentive_reward: 0, + reward_planck: "0", + commission_pct: null, + own_stake: 0, + total_stake: 0, + claimed: false, + claimed_pages: 0, + total_pages: overview?.page_count ?? 0, + }, + rewardPlanck: 0n, + eraPayout, + overview, + totalPoints, + validatorPoints, + commission, + incentiveBudget: budget, + incentiveWeight: weight, + totalIncentiveWeight: sumWeight, + }; + } + + const claimed: readonly number[] = claimedPages ?? []; + const totalPages = overview.page_count; + const fullyClaimed = totalPages > 0 && claimed.length >= totalPages; + let parts: RewardParts = { commission: 0n, ownStake: 0n, incentive: 0n }; + + if (totalPoints > 0n && validatorPoints > 0n) { + const validatorTotalPayout = ratioMulFloor( + eraPayout, + validatorPoints, + totalPoints, + ); + const stakerParts = calculateStakerRewardParts( + validatorTotalPayout, + commission, + overview.own, + overview.total, + ); + parts = { + ...stakerParts, + incentive: calculateIncentivePayout(budget, weight, sumWeight), + }; + } + + const rewardPlanck = parts.commission + parts.ownStake + parts.incentive; + return { + output: { + era, + active: true, + has_data: true, + reward: planckToDot(rewardPlanck), + commission_reward: planckToDot(parts.commission), + own_stake_reward: planckToDot(parts.ownStake), + validator_incentive_reward: planckToDot(parts.incentive), + reward_planck: rewardPlanck.toString(), + commission_pct: Number(commission) / 10_000_000, + own_stake: planckToDot(overview.own), + total_stake: planckToDot(overview.total), + claimed: fullyClaimed, + claimed_pages: claimed.length, + total_pages: totalPages, + }, + rewardPlanck, + eraPayout, + overview, + totalPoints, + validatorPoints, + commission, + incentiveBudget: budget, + incentiveWeight: weight, + totalIncentiveWeight: sumWeight, + }; +} + +export async function computeEraReward( + api: ChainApi, + validator: string, + era: number, +): Promise { + return (await fetchEraRewardComputation(api, validator, era)).output; +} + +function buildValidatorRewards( + rewardEras: number, + activeEraIndex: number | null, + isValidator: boolean, + currentCommissionPct: number | null, + computations: readonly EraRewardComputation[], +): ValidatorRewards { + let totalReward = 0n; + let unclaimedReward = 0n; + for (const computation of computations) { + totalReward += computation.rewardPlanck; + if (computation.output.active && !computation.output.claimed) { + unclaimedReward += computation.rewardPlanck; + } + } + + const eras = computations.map(({ output }) => output); + const latest = eras.find((era) => era.active); + const rewardCollapsePending = + latest !== undefined && + latest.commission_pct !== null && + currentCommissionPct !== null && + latest.commission_reward > 0 && + latest.commission_pct > currentCommissionPct && + latest.own_stake_reward === 0 && + latest.validator_incentive_reward === 0; + + return { + eras_requested: rewardEras, + active_era: activeEraIndex, + is_validator: isValidator, + current_commission_pct: currentCommissionPct, + latest_era_commission_pct: latest?.commission_pct ?? null, + reward_collapse_pending: rewardCollapsePending, + total_reward: planckToDot(totalReward), + unclaimed_reward: planckToDot(unclaimedReward), + eras, + }; +} + +export async function fetchValidatorRewardHistory( + api: ChainApi, + validator: string, + rewardEras: number, +): Promise { + const [activeEra, currentPreferences] = await Promise.all([ + api.query.Staking.ActiveEra.getValue(), + api.query.Staking.Validators.getValue(validator), + ]); + const currentCommissionPct = + currentPreferences === undefined + ? null + : Number(currentPreferences.commission) / 10_000_000; + + if (activeEra === undefined) { + return { + rewards: buildValidatorRewards( + rewardEras, + null, + currentPreferences !== undefined, + currentCommissionPct, + [], + ), + computations: [], + }; + } + + const eraIndices: number[] = []; + for ( + let era = activeEra.index - 1; + era >= 0 && eraIndices.length < rewardEras; + era-- + ) { + eraIndices.push(era); + } + const computations = await Promise.all( + eraIndices.map((era) => fetchEraRewardComputation(api, validator, era)), + ); + + return { + rewards: buildValidatorRewards( + rewardEras, + activeEra.index, + currentPreferences !== undefined, + currentCommissionPct, + computations, + ), + computations, + }; +} + +export async function fetchValidatorRewards( + api: ChainApi, + validator: string, + rewardEras: number, +): Promise { + return (await fetchValidatorRewardHistory(api, validator, rewardEras)).rewards; +} + +export async function fetchRewardCurveParameters( + api: ChainApi, +): Promise { + const [minimumSelfStake, optimumSelfStake, hardCapSelfStake, slopeFactor] = + await Promise.all([ + api.query.Staking.MinValidatorBond.getValue(), + api.query.Staking.OptimumSelfStake.getValue(), + api.query.Staking.HardCapSelfStake.getValue(), + api.query.Staking.SelfStakeSlopeFactor.getValue(), + ]); + + return { + minimumSelfStake: minimumSelfStake ?? 0n, + optimumSelfStake: optimumSelfStake ?? 0n, + hardCapSelfStake: hardCapSelfStake ?? 0n, + slopeFactor: BigInt(slopeFactor ?? 0), + }; +} + +function calculateScenarioParts( + computation: EraRewardComputation, + targetSelfStake: bigint, + parameters: RewardCurveParameters, +): RewardParts { + const { eraPayout, overview, totalPoints, validatorPoints } = computation; + if ( + eraPayout === undefined || + overview === undefined || + totalPoints <= 0n || + validatorPoints <= 0n + ) { + return { commission: 0n, ownStake: 0n, incentive: 0n }; + } + + const validatorTotalPayout = ratioMulFloor( + eraPayout, + validatorPoints, + totalPoints, + ); + const nominatorStake = overview.total > overview.own + ? overview.total - overview.own + : 0n; + const stakerParts = calculateStakerRewardParts( + validatorTotalPayout, + computation.commission, + targetSelfStake, + nominatorStake + targetSelfStake, + ); + + if (computation.incentiveWeight > computation.totalIncentiveWeight) { + throw new Error( + `Validator incentive weight exceeds the total in era ${computation.output.era}`, + ); + } + const targetWeight = calculateValidatorIncentiveWeight( + targetSelfStake, + parameters, + ); + const adjustedTotalWeight = + computation.totalIncentiveWeight - + computation.incentiveWeight + + targetWeight; + + return { + ...stakerParts, + incentive: calculateIncentivePayout( + computation.incentiveBudget, + targetWeight, + adjustedTotalWeight, + ), + }; +} + +function averageReward(total: bigint, eraCount: number): number { + return eraCount > 0 ? planckToDot(total / BigInt(eraCount)) : 0; +} + +function calculateIncreasePercentage( + currentReward: bigint, + estimatedReward: bigint, +): number | null { + if (currentReward <= 0n) return null; + const percentage = + ((Number(estimatedReward) / Number(currentReward)) - 1) * 100; + return Math.round(percentage * 10_000) / 10_000; +} + +export function buildRewardProjection( + history: ValidatorRewardHistory, + currentSelfStake: bigint, + parameters: RewardCurveParameters, +): RewardProjection { + const basisComputations = history.computations.filter( + (computation) => + computation.output.active && + computation.incentiveBudget > 0n && + computation.totalIncentiveWeight > 0n, + ); + const actualTotalReward = basisComputations.reduce( + (total, computation) => total + computation.rewardPlanck, + 0n, + ); + const targets = buildSelfStakeScenarioTargets(currentSelfStake, parameters); + + const scenarios = targets.map((targetSelfStake) => { + let commission = 0n; + let ownStake = 0n; + let incentive = 0n; + for (const computation of basisComputations) { + const parts = calculateScenarioParts( + computation, + targetSelfStake, + parameters, + ); + commission += parts.commission; + ownStake += parts.ownStake; + incentive += parts.incentive; + } + const estimatedTotalReward = commission + ownStake + incentive; + + return { + self_stake: planckToDot(targetSelfStake), + additional_self_stake: planckToDot(targetSelfStake - currentSelfStake), + estimated_total_reward: planckToDot(estimatedTotalReward), + estimated_average_reward_per_era: averageReward( + estimatedTotalReward, + basisComputations.length, + ), + estimated_total_commission_reward: planckToDot(commission), + estimated_total_own_stake_reward: planckToDot(ownStake), + estimated_total_validator_incentive_reward: planckToDot(incentive), + increase_pct: calculateIncreasePercentage( + actualTotalReward, + estimatedTotalReward, + ), + }; + }); + + return { + basis: { + eras_requested: history.rewards.eras_requested, + eras_used: basisComputations.length, + current_self_stake: planckToDot(currentSelfStake), + actual_total_reward: planckToDot(actualTotalReward), + actual_average_reward_per_era: averageReward( + actualTotalReward, + basisComputations.length, + ), + }, + curve: { + minimum_self_stake: planckToDot(parameters.minimumSelfStake), + optimum_self_stake: planckToDot(parameters.optimumSelfStake), + hard_cap_self_stake: planckToDot(parameters.hardCapSelfStake), + slope_factor: Number(parameters.slopeFactor) / Number(PERBILL), + }, + scenarios, + }; +} + +export function buildValidatorRewardReport( + history: ValidatorRewardHistory, + currentSelfStake: bigint, + parameters: RewardCurveParameters, +): ValidatorRewardReport { + return { + rewards: history.rewards, + reward_projection: buildRewardProjection( + history, + currentSelfStake, + parameters, + ), + }; +} diff --git a/polkadot-nominations/src/stats.js b/polkadot-nominations/src/stats.js deleted file mode 100644 index 428f7b5..0000000 --- a/polkadot-nominations/src/stats.js +++ /dev/null @@ -1,23 +0,0 @@ -import { percentiles as configPercentiles } from "./config.js"; - -export function calcPercentiles( - values, - { descending = false, prefix = "" } = {}, -) { - if (values.length === 0) return {}; - const sorted = descending - ? [...values].sort((a, b) => b - a) - : [...values].sort((a, b) => a - b); - const result = {}; - for (const p of configPercentiles) { - const idx = p * (sorted.length - 1); - const lo = Math.floor(idx); - const hi = Math.ceil(idx); - const val = - lo === hi - ? sorted[lo] - : sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo); - result[`${prefix}p${p * 100}`] = Math.round(val * 1e4) / 1e4; - } - return result; -} diff --git a/polkadot-nominations/src/stats.ts b/polkadot-nominations/src/stats.ts new file mode 100644 index 0000000..f3fffe2 --- /dev/null +++ b/polkadot-nominations/src/stats.ts @@ -0,0 +1,39 @@ +export interface PercentileOptions { + readonly descending?: boolean; + readonly prefix?: string; + readonly percentiles?: readonly number[]; +} + +export function calcPercentiles( + values: readonly number[], + { + descending = false, + prefix = "", + percentiles = [0.5, 0.75, 0.9], + }: PercentileOptions = {}, +): Record { + if (values.length === 0) return {}; + + const sorted = [...values].sort((left, right) => + descending ? right - left : left - right, + ); + const result: Record = {}; + for (const percentile of percentiles) { + const index = percentile * (sorted.length - 1); + const lowerIndex = Math.floor(index); + const upperIndex = Math.ceil(index); + const lower = sorted[lowerIndex]; + const upper = sorted[upperIndex]; + if (lower === undefined || upper === undefined) { + throw new RangeError("Percentile index is outside the sorted sample"); + } + + const value = + lowerIndex === upperIndex + ? lower + : lower + (upper - lower) * (index - lowerIndex); + result[`${prefix}p${percentile * 100}`] = + Math.round(value * 10_000) / 10_000; + } + return result; +} diff --git a/polkadot-nominations/test/analysis.test.ts b/polkadot-nominations/test/analysis.test.ts new file mode 100644 index 0000000..cba1bbc --- /dev/null +++ b/polkadot-nominations/test/analysis.test.ts @@ -0,0 +1,206 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildNominationsReport, + buildSelfStakeStats, + buildValidatorInfo, +} from "../src/analysis.js"; +import type { NominatorEntry } from "../src/analysis.js"; + +function nomination( + nominator: string, + targets: readonly string[], + submittedIn = 100, +): NominatorEntry { + return { + keyArgs: [nominator], + value: { targets, submitted_in: submittedIn, suppressed: false }, + }; +} + +test("buildNominationsReport groups results by configured validator", () => { + const output = buildNominationsReport({ + validators: ["validator-a", "validator-b"], + commissions: new Map([ + ["validator-a", 1], + ["validator-b", 5], + ["other", 10], + ]), + nominatorEntries: [ + nomination("nominator-a", ["validator-a", "other"]), + nomination("below-minimum", ["validator-a", "validator-b"]), + nomination("nominator-b", ["validator-b"]), + ], + stakes: new Map([ + ["nominator-a", 2_000], + ["below-minimum", 999], + ["nominator-b", 1_500], + ]), + minStakeDot: 1_000, + percentiles: [0.5], + }); + + assert.deepEqual(Object.keys(output), ["validator-a", "validator-b"]); + const validatorA = output["validator-a"]; + const validatorB = output["validator-b"]; + assert.ok(validatorA); + assert.ok(validatorB); + assert.equal(validatorA.total_nominators, 1); + assert.equal(validatorA.unique_targets, 2); + assert.equal(validatorA.commission_p50, 5.5); + assert.equal(validatorA.stake_p50, 2_000); + assert.deepEqual(validatorA.nominations[0]?.targets, [ + { address: "validator-a", commission: 1 }, + { address: "other", commission: 10 }, + ]); + assert.equal(validatorB.total_nominators, 1); + assert.equal(validatorB.nominations[0]?.nominator, "nominator-b"); +}); + +test("buildNominationsReport keeps an empty object for unmatched validators", () => { + const output = buildNominationsReport({ + validators: ["validator-a"], + commissions: new Map(), + nominatorEntries: [], + stakes: new Map(), + minStakeDot: 0, + percentiles: [0.5], + }); + + assert.deepEqual(output, { + "validator-a": { + total_nominators: 0, + unique_targets: 0, + nominations: [], + }, + }); +}); + +test("buildNominationsReport adds a shared nominator to every matched validator", () => { + const output = buildNominationsReport({ + validators: ["validator-a", "validator-b"], + commissions: new Map([ + ["validator-a", 1], + ["validator-b", 2], + ]), + nominatorEntries: [ + nomination("shared-nominator", ["validator-a", "validator-b"]), + ], + stakes: new Map([["shared-nominator", 5_000]]), + minStakeDot: 0, + percentiles: [0.5], + }); + + const validatorA = output["validator-a"]; + const validatorB = output["validator-b"]; + assert.ok(validatorA); + assert.ok(validatorB); + assert.equal(validatorA.total_nominators, 1); + assert.equal(validatorB.total_nominators, 1); + assert.equal( + validatorA.nominations[0]?.nominator, + "shared-nominator", + ); + assert.equal( + validatorB.nominations[0]?.nominator, + "shared-nominator", + ); +}); + +test("buildSelfStakeStats caps stakes above 30k without excluding validators", () => { + const output = buildSelfStakeStats({ + validatorAddresses: ["a", "b", "c", "d", "e"], + activeValidators: ["b", "e"], + stakes: new Map([ + ["a", 0], + ["b", 10_000], + ["c", 20_000], + ["d", 30_000], + ["e", 40_000], + ]), + percentiles: [0.25, 0.5, 0.75], + }); + + assert.deepEqual(output.active_validators, { + total: 2, + meeting_minimum: 2, + meeting_minimum_pct: 100, + }); + assert.deepEqual(output.all_validators, { + total: 5, + meeting_minimum: 4, + meeting_minimum_pct: 80, + }); + assert.deepEqual(output.reward_effective_self_stake, { + minimum_dot: 10_000, + cap_dot: 30_000, + validator_count: 4, + capped_validator_count: 1, + self_stake_p25: 17_500, + self_stake_p50: 25_000, + self_stake_p75: 30_000, + }); +}); + +test("buildSelfStakeStats handles an empty validator set", () => { + assert.deepEqual( + buildSelfStakeStats({ + validatorAddresses: [], + activeValidators: [], + stakes: new Map(), + percentiles: [0.5], + }), + { + minimum_self_stake_dot: 10_000, + active_validators: { + total: 0, + meeting_minimum: 0, + meeting_minimum_pct: 0, + }, + all_validators: { + total: 0, + meeting_minimum: 0, + meeting_minimum_pct: 0, + }, + reward_effective_self_stake: { + minimum_dot: 10_000, + cap_dot: 30_000, + validator_count: 0, + capped_validator_count: 0, + }, + }, + ); +}); + +test("buildValidatorInfo uses validator addresses as output keys", () => { + const rewards = { eras_requested: 20, eras: [] }; + const rewardProjection = { scenarios: [] }; + assert.deepEqual( + buildValidatorInfo( + ["validator-a", "validator-b"], + new Map([["validator-a", 12_345]]), + new Map([ + [ + "validator-a", + { rewards, reward_projection: rewardProjection }, + ], + [ + "validator-b", + { rewards, reward_projection: rewardProjection }, + ], + ]), + ), + { + "validator-a": { + self_stake: 12_345, + rewards, + reward_projection: rewardProjection, + }, + "validator-b": { + self_stake: 0, + rewards, + reward_projection: rewardProjection, + }, + }, + ); +}); diff --git a/polkadot-nominations/test/chain.test.ts b/polkadot-nominations/test/chain.test.ts new file mode 100644 index 0000000..bd61187 --- /dev/null +++ b/polkadot-nominations/test/chain.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + fetchActiveValidators, + fetchCommissions, + fetchNominators, + fetchStakeBalances, + fetchStakes, +} from "../src/chain.js"; +import type { ChainApi } from "../src/chain.js"; +import { DOT_PLANCK } from "../src/constants.js"; + +test("chain fetchers convert typed storage results", async () => { + const nominators = [{ keyArgs: ["nominator-a"], value: { targets: [] } }]; + const api = { + query: { + Staking: { + ActiveEra: { getValue: async () => ({ index: 42 }) }, + ErasStakersOverview: { + getEntries: async (era: number) => { + assert.equal(era, 42); + return [ + { keyArgs: [42, "validator-a"] }, + { keyArgs: [42, "validator-b"] }, + ]; + }, + }, + Validators: { + getEntries: async () => [ + { + keyArgs: ["validator-a"], + value: { commission: 50_000_000 }, + }, + ], + }, + Nominators: { getEntries: async () => nominators }, + Ledger: { + getEntries: async () => [ + { + value: { + stash: "validator-a", + active: 12n * DOT_PLANCK + DOT_PLANCK / 2n, + }, + }, + ], + }, + }, + }, + } as unknown as ChainApi; + + assert.deepEqual(await fetchActiveValidators(api), [ + "validator-a", + "validator-b", + ]); + assert.deepEqual(await fetchCommissions(api), new Map([["validator-a", 5]])); + assert.equal(await fetchNominators(api), nominators); + assert.deepEqual(await fetchStakeBalances(api), { + dots: new Map([["validator-a", 12.5]]), + planck: new Map([ + ["validator-a", 12n * DOT_PLANCK + DOT_PLANCK / 2n], + ]), + }); + assert.deepEqual(await fetchStakes(api), new Map([["validator-a", 12.5]])); +}); diff --git a/polkadot-nominations/test/cli.test.ts b/polkadot-nominations/test/cli.test.ts new file mode 100644 index 0000000..492b9c5 --- /dev/null +++ b/polkadot-nominations/test/cli.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseCliArgs } from "../src/cli.js"; + +test("parseCliArgs recognizes every command", () => { + assert.equal(parseCliArgs(["--nominations"]), "nominations"); + assert.equal(parseCliArgs(["--self-stake-stats"]), "self-stake-stats"); + assert.equal(parseCliArgs(["--validator-info"]), "validator-info"); + assert.equal(parseCliArgs(["--help"]), "help"); + assert.equal(parseCliArgs(["-h"]), "help"); +}); + +test("parseCliArgs rejects missing, unknown, and multiple commands", () => { + assert.throws(() => parseCliArgs([]), /Exactly one command is required/); + assert.throws(() => parseCliArgs(["--unknown"]), /Exactly one command/); + assert.throws( + () => parseCliArgs(["--nominations", "--validator-info"]), + /Exactly one command/, + ); +}); diff --git a/polkadot-nominations/test/config-schema.test.ts b/polkadot-nominations/test/config-schema.test.ts new file mode 100644 index 0000000..2cc7b6c --- /dev/null +++ b/polkadot-nominations/test/config-schema.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseConfig } from "../src/config-schema.js"; + +test("parseConfig applies defaults to a validator list", () => { + assert.deepEqual(parseConfig({ validators: ["one", "two"] }), { + validators: ["one", "two"], + percentiles: [0.5, 0.75, 0.9], + minStakeDot: 0, + rewardEras: 20, + rpcUrl: "wss://rpc-assethub.novasama-tech.org", + }); +}); + +test("parseConfig validates lists and numeric settings", () => { + assert.throws(() => parseConfig({ validator: "one" }), /validators/); + assert.throws(() => parseConfig({ validators: [] }), /validators/); + assert.throws( + () => parseConfig({ validators: ["one", "one"] }), + /duplicate/, + ); + assert.throws( + () => parseConfig({ validators: ["one"], percentiles: [1.1] }), + /percentiles/, + ); + assert.throws( + () => parseConfig({ validators: ["one"], minStakeDot: -1 }), + /minStakeDot/, + ); + assert.throws( + () => parseConfig({ validators: ["one"], rewardEras: 1.5 }), + /rewardEras/, + ); + assert.throws( + () => parseConfig({ validators: ["one"], rpcUrl: "" }), + /rpcUrl/, + ); +}); diff --git a/polkadot-nominations/test/fixtures/rpc-config.json b/polkadot-nominations/test/fixtures/rpc-config.json new file mode 100644 index 0000000..c4da0ce --- /dev/null +++ b/polkadot-nominations/test/fixtures/rpc-config.json @@ -0,0 +1,7 @@ +{ + "validators": ["selected-dynamically-by-rpc-tests"], + "percentiles": [0.25, 0.5, 0.75, 0.9], + "minStakeDot": 0, + "rewardEras": 3, + "rpcUrl": "wss://rpc-assethub.novasama-tech.org" +} diff --git a/polkadot-nominations/test/integration/rpc.test.ts b/polkadot-nominations/test/integration/rpc.test.ts new file mode 100644 index 0000000..651c1a2 --- /dev/null +++ b/polkadot-nominations/test/integration/rpc.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { randomInt } from "node:crypto"; +import test from "node:test"; +import { connect, fetchActiveValidators } from "../../src/chain.js"; + +test( + "typed RPC returns the current active set and validator preferences", + { timeout: 120_000 }, + async (t) => { + const { client, api } = connect(); + + try { + const activeValidators = await fetchActiveValidators(api); + assert.ok(activeValidators.length > 0); + assert.equal(new Set(activeValidators).size, activeValidators.length); + + const validator = activeValidators[randomInt(activeValidators.length)]; + assert.ok(validator); + const preferences = + await api.query.Staking.Validators.getValue(validator); + + assert.ok(preferences, "an active validator must have validator preferences"); + assert.equal(typeof preferences.commission, "number"); + assert.ok(preferences.commission >= 0); + assert.ok(preferences.commission <= 1_000_000_000); + + const [ + activeEra, + minimumSelfStake, + optimumSelfStake, + hardCapSelfStake, + slopeFactor, + ] = await Promise.all([ + api.query.Staking.ActiveEra.getValue(), + api.query.Staking.MinValidatorBond.getValue(), + api.query.Staking.OptimumSelfStake.getValue(), + api.query.Staking.HardCapSelfStake.getValue(), + api.query.Staking.SelfStakeSlopeFactor.getValue(), + ]); + assert.ok(activeEra); + assert.ok(minimumSelfStake > 0n); + assert.ok(optimumSelfStake >= minimumSelfStake); + assert.ok(hardCapSelfStake >= optimumSelfStake); + assert.ok(slopeFactor >= 0); + assert.ok(slopeFactor <= 1_000_000_000); + + const completedEra = activeEra.index - 1; + const [incentiveBudget, totalIncentiveWeight] = await Promise.all([ + api.query.Staking.ErasValidatorIncentiveBudget.getValue(completedEra), + api.query.Staking.ErasSumValidatorIncentiveWeight.getValue(completedEra), + ]); + assert.ok(incentiveBudget > 0n); + assert.ok(totalIncentiveWeight > 0n); + t.diagnostic(`sampled active validator: ${validator}`); + } finally { + client.destroy(); + } + }, +); diff --git a/polkadot-nominations/test/live/cli-live.test.ts b/polkadot-nominations/test/live/cli-live.test.ts new file mode 100644 index 0000000..aea92be --- /dev/null +++ b/polkadot-nominations/test/live/cli-live.test.ts @@ -0,0 +1,286 @@ +import assert from "node:assert/strict"; +import { randomInt } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; +import test from "node:test"; +import { connect, fetchActiveValidators } from "../../src/chain.js"; +import { rpcUrl } from "../../src/config.js"; +import type { + NominationsOutput, + SelfStakeStats, + ValidatorInfoOutput, +} from "../../src/analysis.js"; +import type { + RewardProjection, + ValidatorRewards, +} from "../../src/rewards.js"; + +const PROJECT_ROOT = dirname( + dirname(dirname(dirname(fileURLToPath(import.meta.url)))), +); +const SAMPLE_SIZE = 3; + +function sampleWithoutReplacement( + values: readonly string[], + count: number, +): string[] { + const pool = [...values]; + const result: string[] = []; + while (result.length < count) { + const index = randomInt(pool.length); + const selected = pool[index]; + if (selected === undefined) { + throw new Error("Cannot sample more validators than are available"); + } + result.push(selected); + pool.splice(index, 1); + } + return result; +} + +function runCli(flag: string, configPath: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ["dist/index.js", flag], { + cwd: PROJECT_ROOT, + env: { ...process.env, CONFIG_PATH: configPath }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (code) => { + if (code !== 0) { + reject(new Error(`${flag} exited with ${code}:\n${stderr}`)); + return; + } + + try { + resolve(JSON.parse(stdout) as T); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + reject(new Error(`${flag} returned invalid JSON: ${message}`)); + } + }); + }); +} + +function assertAddressMap( + report: Readonly>, + validators: readonly string[], +): void { + assert.deepEqual(Object.keys(report).sort(), [...validators].sort()); +} + +function requireMetric( + metrics: Readonly>, + name: string, +): number { + const value = metrics[name]; + if (value === undefined) { + assert.fail(`${name} must be present`); + } + return value; +} + +test( + "all CLI modes work against random validators from the current active set", + { timeout: 300_000 }, + async (t) => { + const { client, api } = connect(); + let activeValidators: string[]; + try { + activeValidators = await fetchActiveValidators(api); + } finally { + client.destroy(); + } + + assert.ok(activeValidators.length >= SAMPLE_SIZE); + const validators = sampleWithoutReplacement(activeValidators, SAMPLE_SIZE); + t.diagnostic(`random active validators: ${validators.join(", ")}`); + + const configDirectory = await mkdtemp( + join(tmpdir(), "polkadot-nominations-live-"), + ); + const configPath = join(configDirectory, "config.json"); + await writeFile( + configPath, + JSON.stringify( + { + validators, + percentiles: [0.25, 0.5, 0.75, 0.9], + minStakeDot: 0, + rewardEras: 3, + rpcUrl, + }, + null, + 2, + ), + ); + + try { + await t.test("--nominations", async () => { + const report = await runCli( + "--nominations", + configPath, + ); + assertAddressMap(report, validators); + + for (const validator of validators) { + const validatorReport = report[validator]; + assert.ok(validatorReport); + assert.equal( + validatorReport.total_nominators, + validatorReport.nominations.length, + ); + const uniqueTargets = new Set( + validatorReport.nominations.flatMap((nomination) => + nomination.targets.map((target) => target.address), + ), + ); + assert.equal(validatorReport.unique_targets, uniqueTargets.size); + for (const nomination of validatorReport.nominations) { + assert.ok(nomination.stake >= 0); + assert.ok( + nomination.targets.some((target) => target.address === validator), + ); + } + } + }); + + await t.test("--self-stake-stats", async () => { + const report = await runCli( + "--self-stake-stats", + configPath, + ); + assert.equal(report.minimum_self_stake_dot, 10_000); + assert.ok(report.active_validators.total > 0); + assert.ok(report.all_validators.total >= report.active_validators.total); + assert.ok( + report.active_validators.meeting_minimum <= + report.active_validators.total, + ); + assert.ok( + report.all_validators.meeting_minimum <= report.all_validators.total, + ); + const effectiveStake = report.reward_effective_self_stake; + assert.equal(effectiveStake.minimum_dot, 10_000); + assert.equal(effectiveStake.cap_dot, 30_000); + assert.equal( + effectiveStake.validator_count, + report.all_validators.meeting_minimum, + ); + assert.ok(effectiveStake.validator_count > 0); + assert.ok(effectiveStake.capped_validator_count >= 0); + assert.ok( + effectiveStake.capped_validator_count <= + effectiveStake.validator_count, + ); + const p25 = requireMetric(effectiveStake, "self_stake_p25"); + const p50 = requireMetric(effectiveStake, "self_stake_p50"); + const p75 = requireMetric(effectiveStake, "self_stake_p75"); + const p90 = requireMetric(effectiveStake, "self_stake_p90"); + assert.ok(p25 >= 10_000); + assert.ok(p90 <= 30_000); + assert.ok(p25 <= p50); + assert.ok(p50 <= p75); + assert.ok(p75 <= p90); + }); + + await t.test("--validator-info", async () => { + const report = await runCli< + ValidatorInfoOutput + >("--validator-info", configPath); + assertAddressMap(report, validators); + + for (const validator of validators) { + const validatorReport = report[validator]; + assert.ok(validatorReport); + assert.ok(validatorReport.self_stake >= 0); + assert.equal(validatorReport.rewards.eras_requested, 3); + assert.equal(validatorReport.rewards.is_validator, true); + assert.equal(validatorReport.rewards.eras.length, 3); + assert.ok(validatorReport.rewards.total_reward >= 0); + assert.ok(validatorReport.rewards.unclaimed_reward >= 0); + assert.ok( + validatorReport.rewards.unclaimed_reward <= + validatorReport.rewards.total_reward, + ); + for (const era of validatorReport.rewards.eras) { + assert.ok(era.validator_incentive_reward >= 0); + assert.ok( + Math.abs( + era.reward - + era.commission_reward - + era.own_stake_reward - + era.validator_incentive_reward, + ) <= 0.0003, + ); + } + + const projection = validatorReport.reward_projection; + assert.equal(projection.basis.eras_requested, 3); + assert.ok(projection.basis.eras_used >= 0); + assert.ok(projection.basis.eras_used <= 3); + assert.equal( + projection.basis.current_self_stake, + validatorReport.self_stake, + ); + assert.ok( + projection.curve.optimum_self_stake >= + projection.curve.minimum_self_stake, + ); + assert.ok( + projection.curve.hard_cap_self_stake >= + projection.curve.optimum_self_stake, + ); + + let previousStake = projection.basis.current_self_stake; + for (const scenario of projection.scenarios) { + assert.ok(scenario.self_stake > previousStake); + assert.ok( + scenario.self_stake <= projection.curve.hard_cap_self_stake, + ); + assert.ok(scenario.additional_self_stake > 0); + assert.ok(scenario.estimated_total_reward >= 0); + assert.ok( + Math.abs( + scenario.estimated_total_reward - + scenario.estimated_total_commission_reward - + scenario.estimated_total_own_stake_reward - + scenario.estimated_total_validator_incentive_reward, + ) <= 0.0003, + ); + assert.equal( + Object.hasOwn(scenario, "relative_to_current_pct"), + false, + ); + previousStake = scenario.self_stake; + } + if ( + projection.basis.current_self_stake < + projection.curve.hard_cap_self_stake + ) { + assert.equal( + projection.scenarios.at(-1)?.self_stake, + projection.curve.hard_cap_self_stake, + ); + } + } + }); + } finally { + await rm(configDirectory, { recursive: true }); + } + }, +); diff --git a/polkadot-nominations/test/rewards.test.ts b/polkadot-nominations/test/rewards.test.ts new file mode 100644 index 0000000..08c99e2 --- /dev/null +++ b/polkadot-nominations/test/rewards.test.ts @@ -0,0 +1,291 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { ChainApi } from "../src/chain.js"; +import { DOT_PLANCK } from "../src/constants.js"; +import { + buildRewardProjection, + buildSelfStakeScenarioTargets, + calculateValidatorIncentiveWeight, + computeEraReward, + fetchValidatorRewardHistory, + fetchValidatorRewards, +} from "../src/rewards.js"; +import type { RewardCurveParameters } from "../src/rewards.js"; + +const CURVE: RewardCurveParameters = { + minimumSelfStake: 10_000n * DOT_PLANCK, + optimumSelfStake: 30_000n * DOT_PLANCK, + hardCapSelfStake: 100_000n * DOT_PLANCK, + slopeFactor: 500_000_000n, +}; + +test("calculateValidatorIncentiveWeight follows the runtime curve", () => { + assert.equal( + calculateValidatorIncentiveWeight(10_000n * DOT_PLANCK, CURVE), + 10_000_000n, + ); + assert.equal( + calculateValidatorIncentiveWeight(30_000n * DOT_PLANCK, CURVE), + 17_320_508n, + ); + assert.equal( + calculateValidatorIncentiveWeight(50_000n * DOT_PLANCK, CURVE), + 18_708_286n, + ); + assert.equal( + calculateValidatorIncentiveWeight(100_000n * DOT_PLANCK, CURVE), + 21_794_494n, + ); + assert.equal( + calculateValidatorIncentiveWeight(150_000n * DOT_PLANCK, CURVE), + 21_794_494n, + ); +}); + +test("buildSelfStakeScenarioTargets uses the live hard cap as the final point", () => { + const parameters = { + ...CURVE, + hardCapSelfStake: 95_000n * DOT_PLANCK, + }; + const targets = buildSelfStakeScenarioTargets( + 10_100n * DOT_PLANCK, + parameters, + ).map((value) => Number(value / DOT_PLANCK)); + + assert.deepEqual(targets, [ + 12_500, + 15_000, + 17_500, + 20_000, + 22_500, + 25_000, + 27_500, + 30_000, + 40_000, + 50_000, + 60_000, + 70_000, + 80_000, + 90_000, + 95_000, + ]); +}); + +test("computeEraReward includes commission, own stake, and validator incentive", async () => { + const validator = "validator-a"; + const api = { + query: { + Staking: { + ErasStakersOverview: { + getValue: async (era: number, address: string) => { + assert.equal(era, 42); + assert.equal(address, validator); + return { + own: 100n * DOT_PLANCK, + total: 1_000n * DOT_PLANCK, + page_count: 2, + }; + }, + }, + ErasValidatorReward: { + getValue: async () => 1_000n * DOT_PLANCK, + }, + ErasRewardPoints: { + getValue: async () => ({ + total: 100, + individual: [[validator, 20]], + }), + }, + ErasValidatorPrefs: { + getValue: async () => ({ commission: 100_000_000 }), + }, + ClaimedRewards: { getValue: async () => [0, 1] }, + ErasValidatorIncentiveBudget: { + getValue: async () => 500n * DOT_PLANCK, + }, + ErasValidatorIncentiveWeight: { getValue: async () => 10n }, + ErasSumValidatorIncentiveWeight: { getValue: async () => 100n }, + }, + }, + } as unknown as ChainApi; + + const result = await computeEraReward(api, validator, 42); + + assert.equal(result.reward, 88); + assert.equal(result.commission_reward, 20); + assert.equal(result.own_stake_reward, 18); + assert.equal(result.validator_incentive_reward, 50); + assert.equal(result.reward_planck, (88n * DOT_PLANCK).toString()); + assert.equal(result.claimed, true); +}); + +test("computeEraReward reports an inactive era without exposure", async () => { + const api = { + query: { + Staking: { + ErasStakersOverview: { getValue: async () => undefined }, + ErasValidatorReward: { getValue: async () => 1n }, + ErasRewardPoints: { getValue: async () => ({}) }, + ErasValidatorPrefs: { getValue: async () => undefined }, + ClaimedRewards: { getValue: async () => [] }, + ErasValidatorIncentiveBudget: { getValue: async () => 1n }, + ErasValidatorIncentiveWeight: { getValue: async () => undefined }, + ErasSumValidatorIncentiveWeight: { getValue: async () => 1n }, + }, + }, + } as unknown as ChainApi; + + const result = await computeEraReward(api, "validator-a", 42); + assert.equal(result.active, false); + assert.equal(result.has_data, true); + assert.equal(result.reward, 0); + assert.equal(result.validator_incentive_reward, 0); +}); + +test("fetchValidatorRewards aggregates claimed eras and detects a pending collapse", async () => { + const validator = "validator-a"; + const api = { + query: { + Staking: { + ActiveEra: { getValue: async () => ({ index: 3 }) }, + Validators: { + getValue: async (address: string) => { + assert.equal(address, validator); + return { commission: 0 }; + }, + }, + ErasStakersOverview: { + getValue: async () => ({ + own: 0n, + total: 100n * DOT_PLANCK, + page_count: 1, + }), + }, + ErasValidatorReward: { + getValue: async () => 100n * DOT_PLANCK, + }, + ErasRewardPoints: { + getValue: async () => ({ + total: 10, + individual: [[validator, 10]], + }), + }, + ErasValidatorPrefs: { + getValue: async () => ({ commission: 100_000_000 }), + }, + ClaimedRewards: { + getValue: async (era: number) => (era === 1 ? [0] : []), + }, + ErasValidatorIncentiveBudget: { + getValue: async () => 50n * DOT_PLANCK, + }, + ErasValidatorIncentiveWeight: { getValue: async () => undefined }, + ErasSumValidatorIncentiveWeight: { getValue: async () => 100n }, + }, + }, + } as unknown as ChainApi; + + const result = await fetchValidatorRewards(api, validator, 2); + + assert.equal(result.total_reward, 20); + assert.equal(result.unclaimed_reward, 10); + assert.equal(result.current_commission_pct, 0); + assert.equal(result.latest_era_commission_pct, 10); + assert.equal(result.reward_collapse_pending, true); + assert.deepEqual( + result.eras.map(({ era, claimed }) => ({ era, claimed })), + [ + { era: 2, claimed: false }, + { era: 1, claimed: true }, + ], + ); +}); + +test("buildRewardProjection compares every target with the validator actual reward", async () => { + const validator = "validator-a"; + const currentSelfStake = 10_000n * DOT_PLANCK; + const currentWeight = calculateValidatorIncentiveWeight( + currentSelfStake, + CURVE, + ); + const api = { + query: { + Staking: { + ActiveEra: { getValue: async () => ({ index: 43 }) }, + Validators: { getValue: async () => ({ commission: 0 }) }, + ErasStakersOverview: { + getValue: async () => ({ + own: currentSelfStake, + total: 100_000n * DOT_PLANCK, + page_count: 1, + }), + }, + ErasValidatorReward: { + getValue: async () => 1_000n * DOT_PLANCK, + }, + ErasRewardPoints: { + getValue: async () => ({ + total: 100, + individual: [[validator, 100]], + }), + }, + ErasValidatorPrefs: { getValue: async () => ({ commission: 0 }) }, + ClaimedRewards: { getValue: async () => [] }, + ErasValidatorIncentiveBudget: { + getValue: async () => 1_000n * DOT_PLANCK, + }, + ErasValidatorIncentiveWeight: { + getValue: async () => currentWeight, + }, + ErasSumValidatorIncentiveWeight: { + getValue: async () => 1_000_000_000n, + }, + }, + }, + } as unknown as ChainApi; + + const history = await fetchValidatorRewardHistory(api, validator, 1); + const projection = buildRewardProjection(history, currentSelfStake, CURVE); + + assert.equal(projection.basis.eras_used, 1); + assert.equal(projection.basis.actual_total_reward, 110); + assert.equal(projection.scenarios[0]?.self_stake, 12_500); + assert.equal(projection.scenarios.at(-1)?.self_stake, 100_000); + + const atOptimum = projection.scenarios.find( + ({ self_stake }) => self_stake === 30_000, + ); + assert.ok(atOptimum); + assert.equal(atOptimum.estimated_total_own_stake_reward, 250); + assert.ok(atOptimum.estimated_total_validator_incentive_reward > 17); + assert.ok(atOptimum.estimated_total_reward > 267); + assert.ok(atOptimum.increase_pct !== null); + assert.ok(atOptimum.increase_pct > 142); + assert.equal( + Object.hasOwn(atOptimum, "relative_to_current_pct"), + false, + ); +}); + +test("fetchValidatorRewards handles a missing active era", async () => { + const api = { + query: { + Staking: { + ActiveEra: { getValue: async () => undefined }, + Validators: { getValue: async () => undefined }, + }, + }, + } as unknown as ChainApi; + + assert.deepEqual(await fetchValidatorRewards(api, "validator-a", 20), { + eras_requested: 20, + active_era: null, + is_validator: false, + current_commission_pct: null, + latest_era_commission_pct: null, + reward_collapse_pending: false, + total_reward: 0, + unclaimed_reward: 0, + eras: [], + }); +}); diff --git a/polkadot-nominations/test/stats.test.ts b/polkadot-nominations/test/stats.test.ts new file mode 100644 index 0000000..d632a8d --- /dev/null +++ b/polkadot-nominations/test/stats.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { calcPercentiles } from "../src/stats.js"; + +test("calcPercentiles interpolates in ascending and descending order", () => { + assert.deepEqual( + calcPercentiles([0, 10, 20], { percentiles: [0.25, 0.5, 0.75] }), + { p25: 5, p50: 10, p75: 15 }, + ); + assert.deepEqual( + calcPercentiles([1, 5, 10], { + descending: true, + prefix: "commission_", + percentiles: [0.25, 0.5], + }), + { commission_p25: 7.5, commission_p50: 5 }, + ); +}); + +test("calcPercentiles returns no fields for an empty sample", () => { + assert.deepEqual(calcPercentiles([], { percentiles: [0.5] }), {}); +}); diff --git a/polkadot-nominations/tsconfig.json b/polkadot-nominations/tsconfig.json new file mode 100644 index 0000000..af79221 --- /dev/null +++ b/polkadot-nominations/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": ".", + "outDir": "dist", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noEmitOnError": true, + "verbatimModuleSyntax": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["index.ts", "src/**/*.ts", "test/**/*.ts"], + "exclude": ["dist", "node_modules", ".papi"] +}