From 4f922c55f9b0adc89cdaf066a0757cdaaa0c819e Mon Sep 17 00:00:00 2001 From: soaresa <10797037+soaresa@users.noreply.github.com> Date: Thu, 7 Nov 2024 21:26:55 -0300 Subject: [PATCH] Update Validators Page: Added Columns, Vouches Tab, and Stats Enhancements (#58) --- api/.env.example | 9 + api/nest-cli.json | 5 + api/package-lock.json | 166 +++---- api/package.json | 2 + api/src/config/config.interface.ts | 3 + api/src/config/config.ts | 3 + api/src/ol/accounts/accounts.service.ts | 7 +- .../community-wallet.model.ts | 5 + .../community-wallets.service.ts | 28 +- .../ol/community-wallets/community-wallets.ts | 112 ----- api/src/ol/constants.ts | 2 + api/src/ol/models/validator.model.ts | 224 +++++++++ api/src/ol/validators/validators.processor.ts | 38 +- api/src/ol/validators/validators.resolver.ts | 31 +- api/src/ol/validators/validators.service.ts | 429 +++++++++++++++++- api/src/schema.gql | 32 ++ api/src/stats/public-wallets.ts | 24 - api/src/stats/stats.service.ts | 29 +- api/src/stats/types.ts | 4 + .../core/routes/Validators/Validators.tsx | 26 +- .../Validators/components/ValidatorRow.tsx | 58 ++- .../components/ValidatorRowSkeleton.tsx | 3 + .../Validators/components/ValidatorsStats.tsx | 48 ++ .../Validators/components/ValidatorsTable.tsx | 117 +++-- .../Validators/components/VouchesLegend.tsx | 72 +++ .../Validators/components/VouchesRow.tsx | 123 +++++ .../components/VouchesRowSkeleton.tsx | 29 ++ .../Validators/components/VouchesTable.tsx | 190 ++++++++ .../modules/interface/Validator.interface.ts | 23 + 29 files changed, 1502 insertions(+), 340 deletions(-) delete mode 100644 api/src/ol/community-wallets/community-wallets.ts delete mode 100644 api/src/stats/public-wallets.ts create mode 100644 web-app/src/modules/core/routes/Validators/components/VouchesLegend.tsx create mode 100644 web-app/src/modules/core/routes/Validators/components/VouchesRow.tsx create mode 100644 web-app/src/modules/core/routes/Validators/components/VouchesRowSkeleton.tsx create mode 100644 web-app/src/modules/core/routes/Validators/components/VouchesTable.tsx diff --git a/api/.env.example b/api/.env.example index 41e1b99..14cac8e 100644 --- a/api/.env.example +++ b/api/.env.example @@ -25,3 +25,12 @@ NATS_SERVERS="127.0.0.1:4222" DATA_API_HOST="https://data.0l.fyi" DATABASE_URL="postgresql://olfyi:olfyi@127.0.0.1:5432/olfyi?schema=public" + +# Path to the json file containing validator handles +VALIDATOR_HANDLES_URL=https://raw.githubusercontent.com/user/repo/branch/path/to/validator-handle.json + +# Path to the json file containing community wallets info +COMMUNITY_WALLETS_URL="https://raw.githubusercontent.com/soaresa/test_data/refs/heads/main/community-wallets.json" + +# Path to the json file containing well known addresses info +KNOWN_ADDRESSES_URL="https://raw.githubusercontent.com/soaresa/test_data/refs/heads/main/known-addresses.json" \ No newline at end of file diff --git a/api/nest-cli.json b/api/nest-cli.json index 6142873..bb88bfd 100644 --- a/api/nest-cli.json +++ b/api/nest-cli.json @@ -10,6 +10,11 @@ "include": "**/*.sql", "outDir": "dist/src/", "watchAssets": true + }, + { + "include": "assets/**/*", + "outDir": "dist/", + "watchAssets": true } ] } diff --git a/api/package-lock.json b/api/package-lock.json index 1702fe9..d5c92c7 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -35,11 +35,13 @@ "d3-array": "^3.2.4", "decimal.js": "^10.4.3", "firebase-admin": "^12.1.1", + "fs": "^0.0.1-security", "graphql": "^16.8.2", "graphql-ws": "^5.16.0", "lodash": "^4.17.21", "maxmind": "^4.3.20", "nats": "^2.26.0", + "path": "^0.12.7", "qs": "^6.12.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" @@ -474,11 +476,11 @@ } }, "node_modules/@aptos-labs/aptos-client": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@aptos-labs/aptos-client/-/aptos-client-0.1.0.tgz", - "integrity": "sha512-q3s6pPq8H2buGp+tPuIRInWsYOuhSEwuNJPwd2YnsiID3YSLihn2ug39ktDJAcSOprUcp7Nid8WK7hKqnUmSdA==", + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@aptos-labs/aptos-client/-/aptos-client-0.1.1.tgz", + "integrity": "sha512-kJsoy4fAPTOhzVr7Vwq8s/AUg6BQiJDa7WOqRzev4zsuIS3+JCuIZ6vUd7UBsjnxtmguJJulMRs9qWCzVBt2XA==", "dependencies": { - "axios": "1.6.2", + "axios": "1.7.4", "got": "^11.8.6" }, "engines": { @@ -486,11 +488,11 @@ } }, "node_modules/@aptos-labs/aptos-client/node_modules/axios": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", - "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", + "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", "dependencies": { - "follow-redirects": "^1.15.0", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -3120,9 +3122,9 @@ } }, "node_modules/@nestjs/cli": { - "version": "10.4.2", - "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.2.tgz", - "integrity": "sha512-fQexIfLHfp6GUgX+CO4fOg+AEwV5ox/LHotQhyZi9wXUQDyIqS0NTTbumr//62EcX35qV4nU0359nYnuEdzG+A==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.5.tgz", + "integrity": "sha512-FP7Rh13u8aJbHe+zZ7hM0CC4785g9Pw4lz4r2TTgRtf0zTxSWMkJaPEwyjX8SK9oWK2GsYxl+fKpwVZNbmnj9A==", "dev": true, "dependencies": { "@angular-devkit/core": "17.3.8", @@ -3142,7 +3144,7 @@ "tsconfig-paths": "4.2.0", "tsconfig-paths-webpack-plugin": "4.1.0", "typescript": "5.3.3", - "webpack": "5.92.1", + "webpack": "5.94.0", "webpack-node-externals": "3.0.0" }, "bin": { @@ -3164,28 +3166,6 @@ } } }, - "node_modules/@nestjs/cli/node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@nestjs/cli/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, "node_modules/@nestjs/cli/node_modules/typescript": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", @@ -3199,53 +3179,6 @@ "node": ">=14.17" } }, - "node_modules/@nestjs/cli/node_modules/webpack": { - "version": "5.92.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.92.1.tgz", - "integrity": "sha512-JECQ7IwJb+7fgUFBlrJzbyu3GEuNBcdqr1LD7IbSzwkSmIevTm8PF+wej3Oxuz/JFBUZ6O1o43zsPkwm1C4TmA==", - "dev": true, - "dependencies": { - "@types/eslint-scope": "^3.7.3", - "@types/estree": "^1.0.5", - "@webassemblyjs/ast": "^1.12.1", - "@webassemblyjs/wasm-edit": "^1.12.1", - "@webassemblyjs/wasm-parser": "^1.12.1", - "acorn": "^8.7.1", - "acorn-import-attributes": "^1.9.5", - "browserslist": "^4.21.10", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.0", - "es-module-lexer": "^1.2.1", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^3.2.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.10", - "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, "node_modules/@nestjs/common": { "version": "10.3.10", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.3.10.tgz", @@ -4590,21 +4523,13 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.0.tgz", "integrity": "sha512-gi6WQJ7cHRgZxtkQEoyHMppPjq9Kxo5Tjn2prSKDSmZrCz8TZ3jSRCeTJm+WoM+oB0WG37bRqLzaaU3q7JypGg==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", @@ -5587,9 +5512,9 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/axios": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", - "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -7799,6 +7724,11 @@ "node": ">= 0.6" } }, + "node_modules/fs": { + "version": "0.0.1-security", + "resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz", + "integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==" + }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -9974,9 +9904,9 @@ } }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -10495,6 +10425,15 @@ "node": ">= 0.8" } }, + "node_modules/path": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", + "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==", + "dependencies": { + "process": "^0.11.1", + "util": "^0.10.3" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -10750,6 +10689,14 @@ "fsevents": "2.3.3" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -12452,11 +12399,24 @@ "punycode": "^2.1.0" } }, + "node_modules/util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "dependencies": { + "inherits": "2.0.3" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "node_modules/util/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -12568,13 +12528,11 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "node_modules/webpack": { - "version": "5.93.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.93.0.tgz", - "integrity": "sha512-Y0m5oEY1LRuwly578VqluorkXbvXKh7U3rLoQCEO04M97ScRr44afGVkI0FQFsXzysk5OgFAxjZAb9rsGQVihA==", + "version": "5.94.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", + "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dev": true, - "peer": true, "dependencies": { - "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", "@webassemblyjs/wasm-edit": "^1.12.1", @@ -12583,7 +12541,7 @@ "acorn-import-attributes": "^1.9.5", "browserslist": "^4.21.10", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.0", + "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", @@ -12638,7 +12596,6 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -12652,7 +12609,6 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, - "peer": true, "engines": { "node": ">=4.0" } diff --git a/api/package.json b/api/package.json index fb5d64a..9f83be6 100644 --- a/api/package.json +++ b/api/package.json @@ -53,11 +53,13 @@ "d3-array": "^3.2.4", "decimal.js": "^10.4.3", "firebase-admin": "^12.1.1", + "fs": "^0.0.1-security", "graphql": "^16.8.2", "graphql-ws": "^5.16.0", "lodash": "^4.17.21", "maxmind": "^4.3.20", "nats": "^2.26.0", + "path": "^0.12.7", "qs": "^6.12.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" diff --git a/api/src/config/config.interface.ts b/api/src/config/config.interface.ts index 2ad8d89..7bf4b11 100644 --- a/api/src/config/config.interface.ts +++ b/api/src/config/config.interface.ts @@ -19,6 +19,9 @@ export interface InfoConfig { export interface OlConfig { provider: string; dataApiHost: string; + validatorHandlesUrl?: string | undefined; + communityWalletsUrl?: string | undefined; + knwonAddressesUrl?: string | undefined; } export interface S3Config { diff --git a/api/src/config/config.ts b/api/src/config/config.ts index c2cceed..3426427 100644 --- a/api/src/config/config.ts +++ b/api/src/config/config.ts @@ -16,6 +16,9 @@ export default (): Config => { ol: { provider: 'https://rpc.0l.fyi', dataApiHost: ENV.DATA_API_HOST!, + validatorHandlesUrl: ENV.VALIDATOR_HANDLES_URL || undefined, + communityWalletsUrl: ENV.COMMUNITY_WALLETS_URL || undefined, + knwonAddressesUrl: ENV.KNOWN_ADDRESSES_URL || undefined, }, s3: { diff --git a/api/src/ol/accounts/accounts.service.ts b/api/src/ol/accounts/accounts.service.ts index 9cc6d34..44bc913 100644 --- a/api/src/ol/accounts/accounts.service.ts +++ b/api/src/ol/accounts/accounts.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { ClickhouseService } from '../../clickhouse/clickhouse.service.js'; import { OlService } from '../ol.service.js'; -import { communityWallets } from '../community-wallets/community-wallets.js'; +import { CommunityWalletsService } from '../community-wallets/community-wallets.service.js'; import { CumulativeShare, TopAccount } from './accounts.model.js'; @Injectable() @@ -9,6 +9,7 @@ export class AccountsService { constructor( private readonly clickhouseService: ClickhouseService, private readonly olService: OlService, + private readonly communityWalletsService: CommunityWalletsService, ) {} public async getTopBalanceAccounts(limit: number): Promise { @@ -45,9 +46,11 @@ export class AccountsService { publicName: string; }> = await resultSet.json(); + const cwsInfo = await this.communityWalletsService.loadCommunityWallets(); + let cumulativeBalanceAmount = 0; const accountsWithCumulative = rows.map((account) => { - const name = communityWallets.get(account.address)?.name; + const name = cwsInfo.get(account.address)?.name; account.publicName = name ?? ''; cumulativeBalanceAmount += account.balance; const cumulativeShare = new CumulativeShare({ diff --git a/api/src/ol/community-wallets/community-wallet.model.ts b/api/src/ol/community-wallets/community-wallet.model.ts index a8c7b0b..0813b9a 100644 --- a/api/src/ol/community-wallets/community-wallet.model.ts +++ b/api/src/ol/community-wallets/community-wallet.model.ts @@ -1,5 +1,10 @@ import { Field, ObjectType } from '@nestjs/graphql'; +export interface CommunityWalletInfo { + name?: string; + description?: string; +} + interface CommunityWalletInput { rank: number; address: string; diff --git a/api/src/ol/community-wallets/community-wallets.service.ts b/api/src/ol/community-wallets/community-wallets.service.ts index daf6fbd..d21ee8e 100644 --- a/api/src/ol/community-wallets/community-wallets.service.ts +++ b/api/src/ol/community-wallets/community-wallets.service.ts @@ -6,12 +6,12 @@ import { redisClient } from '../../redis/redis.service.js'; import { OlService } from '../ol.service.js'; import { CommunityWallet, + CommunityWalletInfo, CommunityWalletStats, - CommunityWalletPayments, CommunityWalletDetails, + CommunityWalletPayments, } from './community-wallet.model.js'; import { parseAddress, parseHexString } from '../../utils.js'; -import { communityWallets } from './community-wallets.js'; import { ICommunityWalletsService } from './interfaces.js'; import { COMMUNITY_WALLETS_CACHE_KEY, @@ -19,6 +19,8 @@ import { COMMUNITY_WALLETS_PAYMENTS_CACHE_KEY, COMMUNITY_WALLETS_DETAILS_CACHE_KEY, } from '../constants.js'; +import { OlConfig } from '../../config/config.interface.js'; +import axios from 'axios'; function formatCoin(value: number): number { return Math.floor(value / 1e6); @@ -27,12 +29,14 @@ function formatCoin(value: number): number { @Injectable() export class CommunityWalletsService implements ICommunityWalletsService { private readonly cacheEnabled: boolean; + private readonly communityWalletsUrl?: string; public constructor( private readonly olService: OlService, config: ConfigService, ) { this.cacheEnabled = config.get('cacheEnabled')!; + this.communityWalletsUrl = config.get('ol')?.communityWalletsUrl; } private async getFromCache(key: string): Promise { @@ -62,6 +66,7 @@ export class CommunityWalletsService implements ICommunityWalletsService { } private async queryCommunityWallets(): Promise { + const communityWallets = await this.loadCommunityWallets(); const donorVoiceRegistry = (await this.olService.aptosClient.getAccountResource( '0x1', '0x1::donor_voice::Registry', @@ -135,6 +140,25 @@ export class CommunityWalletsService implements ICommunityWalletsService { }); } + async loadCommunityWallets(): Promise> { + const cwsMap = new Map(); + if (!this.communityWalletsUrl) { + return cwsMap; + } + try { + const response = await axios.get(this.communityWalletsUrl); + const data = response.data; + Object.keys(data.communityWallets).forEach((address) => { + let addressStr = address.replace(/^0x/, '').toUpperCase(); + cwsMap.set(addressStr, data.communityWallets[address] as CommunityWalletInfo); + }); + } catch (error) { + console.error('Error loading validator handles from URL:', error); + return new Map(); + } + return cwsMap; + } + private async queryPayments(wallets: CommunityWallet[]) { let totalPaid = 0; let totalVetoed = 0; diff --git a/api/src/ol/community-wallets/community-wallets.ts b/api/src/ol/community-wallets/community-wallets.ts deleted file mode 100644 index 2762c17..0000000 --- a/api/src/ol/community-wallets/community-wallets.ts +++ /dev/null @@ -1,112 +0,0 @@ -interface CommunityWalletInfo { - name?: string; - description?: string; -} - -export const communityWallets = new Map([ - [ - 'BC25F79FEF8A981BE4636AC1A2D6F587', - { - name: 'Application Studio', - description: - 'Newlab started this program to back teams that have clearly-defined plans to leverage 0L for applications with real-world, measurable impact and utility.', - }, - ], - [ - '2B0E8325DEA5BE93D856CFDE2D0CBA12', - { - name: 'Tip Jar', - description: - 'A personal tip jar for the lead 0L developer, who has contributed thousands of hours of work to the project.', - }, - ], - [ - 'BCA50D10041FA111D1B44181A264A599', - { - name: 'A Good List', - description: - 'Supports a collection of non-profit and humanitarian organizations. Contributors can vote on the weighting.', - }, - ], - [ - 'B31BD7796BC113013A2BF6C3953305FD', - { - name: 'Danish Red Cross Humanitarian Fund', - description: - 'Under the auspices of Red Cross humanitarian principles, the Fund aims to improve outcomes for communities affected by humanitarian crises.', - }, - ], - [ - '3A6C51A0B786D644590E8A21591FA8E2', - { - name: 'Ongoing Full-Time Workers Program', - description: - 'The iqlusion FTW Program aims to collect ongoing donations, and redistribute those donations to any engineers working full-time on the 0L platform on a monthly basis (collectively the FTW).', - }, - ], - [ - '19E966BFA4B32CE9B7E23721B37B96D2', - { - name: 'Social Infrastructure Program', - description: - 'This program is designed to provide capital to fund a wide range of benefits to 0L members in alignment with the mission, vision and values of the community.', - }, - ], - [ - '2057BCFB0189B7FD0ABA7244BA271661', - { - name: 'Moonshot Program', - description: - 'Inspired by the well-known Xprize awards, we think large and meaningful rewards are necessary to materialize frontier technologies which are ambitious, speculative, and non-obvious.', - }, - ], - [ - 'F605FE7F787551EEA808EE9ACDB98897', - { - name: 'Human Rewards Program', - description: - 'A program to allow anyone that can verify they are human to receive some coins for some human work.', - }, - ], - [ - 'BB6926434D1497A559E4F0487F79434F', - { - name: 'Deep Technology Innovation Program', - description: - 'BlockScience has established this Program to create a pathway for funding to the academic fields that technology and other crypto networks rely upon.', - }, - ], - [ - '1367B68C86CB27FA7215D9F75A26EB8F', - { - name: 'University of Toronto MSRG', - description: - 'This program seeks to attract funding to help support basic research & discovery targeted at distributed ledger and blockchain technology as well as distributed systems in the long term.', - }, - ], - [ - 'C906F67F626683B77145D1F20C1A753B', - { - name: 'The Iqlusion Engineering Fund', - description: - 'This program will establish and compensate community members engaged in engineering work whether original work, maintenance, or review of the code.', - }, - ], - [ - 'C19C06A592911ED31C4100E9FB63AD7B', - { - name: 'RxC Research and Experimentation', - description: - 'The RadicalxChange Foundation has established this Research and Experimentation fund to advance new incentive structures.', - }, - ], - [ - '87DC2E497AC6EDAB21511333A421E5A5', - { - name: 'Working Group Key Roles', - }, - ], - ['2640CD6D652AC94DC5F0963DCC00BCC7', { name: 'Engineering Fund, tool-scrubbers-guild' }], - ['FBE8DA53C92CEEEB40D8967EC033A0FB', { name: 'Community development' }], - ['851A3BAF866951B36A3FE0DA92BA38FC', { name: 'Hustle CW' }], -]); diff --git a/api/src/ol/constants.ts b/api/src/ol/constants.ts index ed5a1f2..2314ac3 100644 --- a/api/src/ol/constants.ts +++ b/api/src/ol/constants.ts @@ -1,5 +1,7 @@ export const V0_TIMESTAMP = 1712696400; export const VALIDATORS_CACHE_KEY = '__0L_VALIDATORS__'; +export const VALIDATORS_VOUCHES_CACHE_KEY = '__0L_VALIDATORS_VOUCHES__'; +export const VALIDATORS_VFN_STATUS_CACHE_KEY = '__0L_VALIDATORS_VFN_STATUS__'; export const TOP_BALANCE_ACCOUNTS_CACHE_KEY = '__0L_TOP_BALANCE_ACCOUNTS__'; export const COMMUNITY_WALLETS_CACHE_KEY = '__0L_COMMUNITY_WALLETS__'; export const COMMUNITY_WALLETS_STATS_CACHE_KEY = '__0L_COMMUNITY_WALLETS_STATS__'; diff --git a/api/src/ol/models/validator.model.ts b/api/src/ol/models/validator.model.ts index 596ce67..2322b34 100644 --- a/api/src/ol/models/validator.model.ts +++ b/api/src/ol/models/validator.model.ts @@ -96,9 +96,11 @@ interface ValidatorInput { inSet: boolean; index: BN; address: string; + handle: string | null; balance?: number; unlocked?: number; votingPower: BN; + vfnStatus?: String | null; grade?: GqlValidatorGrade | null; vouches: Vouches; currentBid: GqlValidatorCurrentBid; @@ -113,9 +115,11 @@ export class Validator { this.inSet = input.inSet; this.index = input.index; this.address = input.address; + this.handle = input.handle; this.balance = input.balance; this.unlocked = input.unlocked; this.votingPower = input.votingPower; + this.vfnStatus = input.vfnStatus; this.grade = input.grade; this.vouches = input.vouches; this.currentBid = input.currentBid; @@ -133,6 +137,9 @@ export class Validator { @Field(() => String) public address: string; + @Field(() => String, { nullable: true }) + public handle: string | null; + @Field(() => String, { nullable: true }) public city?: string | null; @@ -142,6 +149,9 @@ export class Validator { @Field(() => BN) public votingPower: BN; + @Field(() => String, { nullable: true }) + public vfnStatus?: String | null; + @Field(() => GqlValidatorGrade, { nullable: true }) public grade?: GqlValidatorGrade | null; @@ -160,3 +170,217 @@ export class Validator { @Field(() => [String], { nullable: true }) public auditQualification?: [string] | null; } + +interface VouchInput { + address: string; + epoch: number; +} + +@ObjectType() +export class Vouch { + public constructor(input: VouchInput) { + this.address = input.address; + this.epoch = input.epoch; + } + + @Field(() => String) + address: string; + + @Field(() => Number) + epoch: number; +} + +interface VouchDetailsInput { + address: string; + handle?: string | null; + family?: string | null; + compliant: boolean; + epoch: number; + epochsToExpire: number; + inSet: boolean; +} + +@ObjectType() +export class VouchDetails { + public constructor(input: VouchDetailsInput) { + this.address = input.address; + this.handle = input.handle; + this.family = input.family; + this.compliant = input.compliant; + this.epoch = input.epoch; + this.epochsToExpire = input.epochsToExpire; + this.inSet = input.inSet; + } + + @Field(() => String) + address: string; + + @Field(() => String, { nullable: true }) + handle?: string | null; + + @Field(() => String, { nullable: true }) + family?: string | null; + + @Field(() => Boolean) + compliant: boolean; + + @Field(() => Number) + epoch: number; + + @Field(() => Number) + epochsToExpire: number; + + @Field(() => Boolean) + inSet: boolean; +} + +interface VfnStatusInput { + address: string; + status: string; +} + +@ObjectType() +export class VfnStatus { + public constructor(input: VfnStatusInput) { + this.address = input.address; + this.status = input.status; + } + + @Field(() => String) + address: string; + + @Field(() => String) + status: string; +} + +interface ValidatorVouchesInput { + address: string; + handle: string | null; + family: string | null; + inSet: boolean; + validVouches: number; + compliant: boolean; + receivedVouches: VouchDetails[]; + givenVouches: VouchDetails[]; +} + +@ObjectType() +export class ValidatorVouches { + public constructor(input: ValidatorVouchesInput) { + this.address = input.address; + this.handle = input.handle; + this.family = input.family; + this.inSet = input.inSet; + this.validVouches = input.validVouches; + this.compliant = input.compliant; + this.receivedVouches = input.receivedVouches; + this.givenVouches = input.givenVouches; + } + + @Field(() => String) + address: string; + + @Field(() => String, { nullable: true }) + handle?: string | null; + + @Field(() => String, { nullable: true }) + family?: string | null; + + @Field(() => Boolean) + inSet: boolean; + + @Field(() => Number) + validVouches: number; + + @Field(() => Boolean) + compliant: boolean; + + @Field(() => [VouchDetails]) + receivedVouches: VouchDetails[]; + + @Field(() => [VouchDetails]) + givenVouches: VouchDetails[]; +} + +interface ValidVouchesInput { + valid: number; + compliant: boolean; +} + +@ObjectType() +export class ValidVouches { + public constructor(input: ValidVouchesInput) { + this.valid = input.valid; + this.compliant = input.compliant; + } + + @Field(() => Number) + valid: number; + + @Field(() => Boolean) + compliant: boolean; +} + +interface ThermostatMeasureInput { + nextEpoch: number; + amount: number; + percentage: number; // 0-100 + didIncrease: boolean; +} + +@ObjectType() +export class ThermostatMeasure { + public constructor(input: ThermostatMeasureInput) { + this.nextEpoch = input.nextEpoch; + this.amount = input.amount; + this.percentage = input.percentage; + this.didIncrease = input.didIncrease; + } + + @Field(() => Number) + nextEpoch: number; + + @Field(() => Number) + amount: number; + + @Field(() => Number) + percentage: number; + + @Field(() => Boolean) + didIncrease: boolean; +} + +interface ValidatorUtilsInput { + vouchPrice: number; + entryFee: number; + clearingBid: number; + netReward: number; + //thermostatMeasure: ThermostatMeasure; +} + +@ObjectType() +export class ValidatorUtils { + public constructor(input: ValidatorUtilsInput) { + this.vouchPrice = input.vouchPrice; + this.entryFee = input.entryFee; + this.clearingBid = input.clearingBid; + this.netReward = input.netReward; + //this.thermostatMeasure = input.thermostatMeasure; + } + + @Field(() => Number) + vouchPrice: number; + + @Field(() => Number) + entryFee: number; + + @Field(() => Number) + clearingBid: number; + + @Field(() => Number) + netReward: number; + + /* + @Field(() => ThermostatMeasure) + thermostatMeasure: ThermostatMeasure;*/ +} diff --git a/api/src/ol/validators/validators.processor.ts b/api/src/ol/validators/validators.processor.ts index 513edfb..d45788e 100644 --- a/api/src/ol/validators/validators.processor.ts +++ b/api/src/ol/validators/validators.processor.ts @@ -4,7 +4,11 @@ import { InjectQueue } from '@nestjs/bullmq'; import { Queue, Job } from 'bullmq'; import { redisClient } from '../../redis/redis.service.js'; import { ValidatorsService } from './validators.service.js'; -import { VALIDATORS_CACHE_KEY } from '../constants.js'; +import { + VALIDATORS_CACHE_KEY, + VALIDATORS_VOUCHES_CACHE_KEY, + VALIDATORS_VFN_STATUS_CACHE_KEY, +} from '../constants.js'; @Processor('validators') export class ValidatorsProcessor extends WorkerHost { @@ -17,6 +21,20 @@ export class ValidatorsProcessor extends WorkerHost { } public async onModuleInit() { + await this.validatorsQueue.add('updateVfnStatusCache', undefined, { + repeat: { + every: 5 * 60 * 1000, // 5 minutes + }, + }); + this.updateVfnStatusCache(); + + await this.validatorsQueue.add('updateValidatorsVouchesCache', undefined, { + repeat: { + every: 60 * 1000, // 60 seconds + }, + }); + this.updateValidatorsVouchesCache(); + await this.validatorsQueue.add('updateValidatorsCache', undefined, { repeat: { every: 30 * 1000, // 30 seconds @@ -30,14 +48,30 @@ export class ValidatorsProcessor extends WorkerHost { case 'updateValidatorsCache': await this.updateValidatorsCache(); break; + case 'updateValidatorsVouchesCache': + await this.updateValidatorsVouchesCache(); + break; + case 'updateVfnStatusCache': + await this.updateVfnStatusCache(); + break; default: throw new Error(`Invalid job name ${job.name}`); } } + private async updateVfnStatusCache() { + const vfnStatus = await this.validatorsService.queryValidatorsVfnStatus(); + await redisClient.set(VALIDATORS_VFN_STATUS_CACHE_KEY, JSON.stringify(vfnStatus)); + } + private async updateValidatorsCache() { - const validators = await this.validatorsService.getValidators(); + const validators = await this.validatorsService.queryValidators(); await redisClient.set(VALIDATORS_CACHE_KEY, JSON.stringify(validators)); } + + private async updateValidatorsVouchesCache() { + const validatorsVouches = await this.validatorsService.queryValidatorsVouches(); + await redisClient.set(VALIDATORS_VOUCHES_CACHE_KEY, JSON.stringify(validatorsVouches)); + } } diff --git a/api/src/ol/validators/validators.resolver.ts b/api/src/ol/validators/validators.resolver.ts index 1bd9982..3880baf 100644 --- a/api/src/ol/validators/validators.resolver.ts +++ b/api/src/ol/validators/validators.resolver.ts @@ -2,31 +2,24 @@ import { ConfigService } from '@nestjs/config'; import { Query, Resolver } from '@nestjs/graphql'; import { ValidatorsService } from './validators.service.js'; -import { Validator } from '../models/validator.model.js'; -import { redisClient } from '../../redis/redis.service.js'; -import { VALIDATORS_CACHE_KEY } from '../constants.js'; +import { Validator, ValidatorVouches, ValidatorUtils } from '../models/validator.model.js'; @Resolver(() => Validator) export class ValidatorsResolver { - private cacheEnabled: boolean; - - public constructor( - private readonly validatorsService: ValidatorsService, - config: ConfigService, - ) { - this.cacheEnabled = config.get('cacheEnabled')!; - } + public constructor(private readonly validatorsService: ValidatorsService) {} @Query(() => [Validator]) async getValidators(): Promise { - if (this.cacheEnabled) { - const cachedValidators = await redisClient.get(VALIDATORS_CACHE_KEY); - if (cachedValidators) { - return JSON.parse(cachedValidators); - } - } + return this.validatorsService.getValidators(); + } + + @Query(() => [ValidatorVouches]) + async getValidatorsVouches(): Promise { + return this.validatorsService.getValidatorsVouches(); + } - const validators = await this.validatorsService.getValidators(); - return validators; + @Query(() => ValidatorUtils) + async getValidatorUtils(): Promise { + return this.validatorsService.getValidatorUtils(); } } diff --git a/api/src/ol/validators/validators.service.ts b/api/src/ol/validators/validators.service.ts index cbbbf99..febdfce 100644 --- a/api/src/ol/validators/validators.service.ts +++ b/api/src/ol/validators/validators.service.ts @@ -1,20 +1,90 @@ import { Injectable } from '@nestjs/common'; import Bluebird from 'bluebird'; +import axios from 'axios'; import BN from 'bn.js'; - +import { ConfigService } from '@nestjs/config'; import { OlService } from '../ol.service.js'; import { PrismaService } from '../../prisma/prisma.service.js'; -import { Validator, Vouches, Voucher } from '../models/validator.model.js'; +import { + Validator, + Vouches, + Voucher, + ValidatorVouches, + Vouch, + VouchDetails, + ValidVouches, + ValidatorUtils, + //ThermostatMeasure, + VfnStatus, +} from '../models/validator.model.js'; import { parseAddress } from '../../utils.js'; +import { redisClient } from '../../redis/redis.service.js'; +import { + VALIDATORS_CACHE_KEY, + VALIDATORS_VFN_STATUS_CACHE_KEY, + VALIDATORS_VOUCHES_CACHE_KEY, +} from '../constants.js'; + +// Regex to match the fullnode address pattern +const fullnodeRegex = + /^\/(ip4|dns)\/([\d\.]+|[\w\.-]+)\/tcp\/\d+\/noise-ik\/0x[a-fA-F0-9]+\/handshake\/\d+$/; @Injectable() export class ValidatorsService { + private readonly cacheEnabled: boolean; + private readonly validatorHandlesUrl: string | undefined; public constructor( private readonly olService: OlService, private readonly prisma: PrismaService, - ) {} + config: ConfigService, + ) { + this.cacheEnabled = config.get('cacheEnabled')!; + this.validatorHandlesUrl = config.get('ol')?.validatorHandlesUrl; + } + + private async getFromCache(key: string): Promise { + const cachedData = await redisClient.get(key); + if (cachedData) { + return JSON.parse(cachedData) as T; + } + return null; + } + + private async setCache(key: string, data: T): Promise { + await redisClient.set(key, JSON.stringify(data)); + } public async getValidators(): Promise { + if (this.cacheEnabled) { + const cachedValidators = await this.getFromCache(VALIDATORS_CACHE_KEY); + if (cachedValidators) { + return cachedValidators; + } + } + + const validators = await this.queryValidators(); + await this.setCache('validators', validators); + + return validators; + } + + public async getValidatorsVouches(): Promise { + if (this.cacheEnabled) { + const cachedVouches = await this.getFromCache( + VALIDATORS_VOUCHES_CACHE_KEY, + ); + if (cachedVouches) { + return cachedVouches; + } + } + + const vouches = await this.queryValidatorsVouches(); + await this.setCache(VALIDATORS_VOUCHES_CACHE_KEY, vouches); + + return vouches; + } + + public async queryValidators(): Promise { const validatorSet = await this.olService.getValidatorSet(); const nodes = await this.prisma.node.findMany({ select: { @@ -33,6 +103,7 @@ export class ValidatorsService { const city = node && node['city'] ? node['city'] : null; const country = node && node['country'] ? node['country'] : null; const grade = await this.olService.getValidatorGrade(validator.addr); + const vfnStatus = await this.getVfnStatus(validator.addr.toString('hex').toUpperCase()); return { address: validator.addr, @@ -44,7 +115,8 @@ export class ValidatorsService { city, country, grade, - auditQualification: null, + vfnStatus, + auditQualification: await this.getAuditQualification(validator.addr), }; }, ); @@ -62,6 +134,7 @@ export class ValidatorsService { inSet: false, index: new BN(-1), auditQualification: await this.getAuditQualification(address), + vfnStatus: null, grade: null, city: null, country: null, @@ -69,21 +142,25 @@ export class ValidatorsService { }), ); + let handles = await this.loadValidatorHandles(); let allValidators = [...currentValidators, ...eligibleValidators]; return await Promise.all( allValidators.map(async (validator) => { + const vouches = await this.getVouches(validator.address); const balance = await this.olService.getAccountBalance(validator.address); + const currentBid = await this.olService.getCurrentBid(validator.address); const slowWallet = await this.olService.getSlowWallet(validator.address); const unlocked = Number(slowWallet?.unlocked); + const addr = validator.address.toString('hex').toLocaleUpperCase(); + const handle = handles.get(addr) || null; - let vouches = await this.getVouches(validator.address); - - const currentBid = await this.olService.getCurrentBid(validator.address); return new Validator({ inSet: validator.inSet, index: validator.index, - address: validator.address.toString('hex').toLocaleUpperCase(), + address: addr, + handle: handle, votingPower: validator.votingPower, + vfnStatus: validator.vfnStatus, balance: Number(balance), unlocked: unlocked, grade: validator.grade, @@ -141,4 +218,340 @@ export class ValidatorsService { }), }); } + + public async getValidVouchesInSet(address: Buffer): Promise { + const validVouchesRes = await this.olService.aptosClient.view({ + function: '0x1::proof_of_fee::get_valid_vouchers_in_set', + type_arguments: [], + arguments: [`0x${address.toString('hex')}`], + }); + + return new ValidVouches({ + valid: Number(validVouchesRes[1]), + compliant: validVouchesRes[0] as boolean, + }); + } + + public async getGivenVouches(address: Buffer): Promise { + const givenVouchesRes = await this.olService.aptosClient.getAccountResource( + `0x${address.toString('hex')}`, + '0x1::vouch::GivenVouches', + ); + + const givenVouches = givenVouchesRes.data as { + epoch_vouched: string[]; + outgoing_vouches: string[]; + }; + + return givenVouches.outgoing_vouches.map((address, index) => { + return new Vouch({ + address: parseAddress(address).toString('hex').toLocaleUpperCase(), + epoch: Number(givenVouches.epoch_vouched[index]), + }); + }); + } + + public async getReceivedVouches(address: Buffer): Promise { + const receivedVouchesRes = await this.olService.aptosClient.getAccountResource( + `0x${address.toString('hex')}`, + '0x1::vouch::ReceivedVouches', + ); + + const receivedVouches = receivedVouchesRes.data as { + epoch_vouched: string[]; + incoming_vouches: string[]; + }; + + return receivedVouches.incoming_vouches.map((address, index) => { + return new Vouch({ + address: parseAddress(address).toString('hex').toLocaleUpperCase(), + epoch: Number(receivedVouches.epoch_vouched[index]), + }); + }); + } + + public async getAncestry(address: string): Promise { + try { + const ancestryRes = await this.olService.aptosClient.getAccountResource( + `0x${address.toLocaleUpperCase()}`, + '0x1::ancestry::Ancestry', + ); + + const ancestry = ancestryRes.data as { + tree: string[]; + }; + + return ancestry.tree; + } catch (error) { + return []; + } + } + + public async queryValidatorsVouches(): Promise { + const eligible = await this.olService.getEligibleValidators(); + const active = await this.olService.getValidatorSet(); + const handles = await this.loadValidatorHandles(); + const currentEpoch = await this.olService.aptosClient + .getLedgerInfo() + .then((info) => Number(info.epoch)); + + const auditVals = new Map(); + await Promise.all( + eligible.map(async (address) => { + const audit: string[] = await this.getAuditQualification(address); + auditVals.set(address.toString('hex').toUpperCase(), audit.length === 0 ? true : false); + }), + ); + + const validVouches = new Map(); + await Promise.all( + eligible.map(async (address) => { + validVouches.set( + address.toString('hex').toUpperCase(), + await this.getValidVouchesInSet(address), + ); + }), + ); + + const families = new Map(); + await Promise.all( + eligible.map(async (address) => { + const ancestry = await this.getAncestry(address.toString('hex').toLocaleUpperCase()); + families.set(address.toString('hex').toLocaleUpperCase(), ancestry[0]); + }), + ); + + const getVouchDetails = (vouch: Vouch): VouchDetails => { + const vouchAddress = vouch.address.toLocaleUpperCase(); + const handle = handles.get(vouchAddress) || null; + const family = families.get(vouchAddress) || null; + return new VouchDetails({ + address: vouchAddress, + epoch: vouch.epoch, + handle: handle, + compliant: auditVals.get(vouchAddress) || false, + epochsToExpire: vouch.epoch + 45 - currentEpoch, + inSet: active.activeValidators.some( + (validator) => validator.addr.toString('hex').toUpperCase() === vouchAddress, + ), + family: family, + }); + }; + + const getSortedVouchesDetails = async (vouches: Vouch[]): Promise => { + // Map vouches to VouchDetails objects + const vouchDetailsList = vouches.map((vouch) => getVouchDetails(vouch)); + + // Sort the VouchDetails list based on the provided criteria + return vouchDetailsList.sort((a, b) => { + // 1. Sort by inSet (first those that are in the set) + if (a.inSet !== b.inSet) return a.inSet ? -1 : 1; + + // 2. Sort by compliant (first those that are compliant) + if (a.compliant !== b.compliant) return a.compliant ? -1 : 1; + + // 3. Sort by family + if (a.family !== b.family) return a.family?.localeCompare(b.family || '') ? -1 : 1; + + // 4. Sort by epochsToExpire (highest number of epochs to expire first) + if (a.epochsToExpire !== b.epochsToExpire) return b.epochsToExpire - a.epochsToExpire; + + // 5. If tied, sort alphabetically by handle + if (a.handle && b.handle) { + return a.handle.localeCompare(b.handle); + } + + return 0; + }); + }; + + return await Promise.all( + eligible.map(async (address) => { + // get received vouches + const received = await this.getReceivedVouches(address); + const receivedDetails = await getSortedVouchesDetails(received); + + // get given vouches + const given = await this.getGivenVouches(address); + let givenDetails = await getSortedVouchesDetails(given); + + const addr = address.toString('hex').toLocaleUpperCase(); + + // get validator handle + const handle = handles.get(addr) || null; + + // get validator family + const family = families.get(addr) || null; + + return new ValidatorVouches({ + address: addr, + family: family, + handle: handle, + inSet: active.activeValidators.some( + (validator) => validator.addr.toString('hex').toLocaleUpperCase() === addr, + ), + validVouches: validVouches.get(addr)?.valid || 0, + compliant: validVouches.get(addr)?.compliant || false, + receivedVouches: receivedDetails, + givenVouches: givenDetails, + }); + }), + ); + } + + public async getValidatorUtils(): Promise { + // Get Vouch Price + const priceRes = await this.olService.aptosClient.getAccountResource( + '0x1', + '0x1::vouch::VouchPrice', + ); + const vouchPriceRes = priceRes.data as { + amount: string; + }; + + // Get current reward + const rewardRes = await this.olService.aptosClient.view({ + function: '0x1::proof_of_fee::get_consensus_reward', + type_arguments: [], + arguments: [], + }); + const nominalReward = Number(rewardRes[0]); + const entryFee = Number(rewardRes[1]); + const clearingBid = Number(rewardRes[2]); + + // Check Thermostat + /*const measureRes = await this.olService.aptosClient.view({ + function: '0x1::proof_of_fee::query_reward_adjustment', + type_arguments: [], + arguments: [], + }); + const didIncrement = measureRes[1] as boolean; + const amount = measureRes[2]; + + // Get current epoch + const epochRes = await this.olService.aptosClient.getLedgerInfo(); + const currentEpoch = Number(epochRes.epoch); + + // Create ThermostatMeasure object + const thermostatMeasure = new ThermostatMeasure({ + nextEpoch: currentEpoch + 1, + amount: Number(nominalReward) + (didIncrement ? +1 : -1) * Number(amount), + percentage: Math.round((Number(amount) / Number(nominalReward)) * 100), + didIncrease: didIncrement, + });*/ + + return new ValidatorUtils({ + vouchPrice: Number(vouchPriceRes.amount), + entryFee: entryFee, + clearingBid: clearingBid, + netReward: nominalReward - entryFee, + /*, thermostatMeasure */ + }); + } + + // TODO cache this + async loadValidatorHandles(): Promise> { + if (!this.validatorHandlesUrl) { + return new Map(); + } + try { + const response = await axios.get(this.validatorHandlesUrl); + const data = response.data; + + const validatorMap = new Map(); + Object.keys(data.validators).forEach((address) => { + let addressStr = address.replace(/^0x/, '').toUpperCase(); + validatorMap.set(addressStr, data.validators[address]); + }); + + return validatorMap; + } catch (error) { + console.error('Error loading validator handles from URL:', error); + return new Map(); + } + } + + public async queryValidatorsVfnStatus(): Promise { + let ret: VfnStatus[] = []; + const active = await this.olService.getValidatorSet(); + for (const validator of active.activeValidators) { + let fullnode = validator.config.fullnodeAddresses; + if (fullnode) { + const vfnStatus = await this.queryVfnStatus(fullnode); + ret.push( + new VfnStatus({ + address: validator.addr.toString('hex').toUpperCase(), + status: vfnStatus, + }), + ); + } + } + + return ret; + } + + async getVfnStatus(address: string): Promise { + const cacheData = await this.getFromCache(VALIDATORS_VFN_STATUS_CACHE_KEY); + if (cacheData) { + const vfnStatus = cacheData.find((item) => item.address === address); + return vfnStatus ? vfnStatus.status : null; + } + + return null; + } + + async queryVfnStatus(fullnodeAddress: String): Promise { + // Variable to store the status + let status: 'invalidAddress' | 'accessible' | 'notAccessible'; + + // Check the VFN address + const match = fullnodeAddress && fullnodeAddress.match(fullnodeRegex); + + if (match) { + // Extract the IP or DNS + const valIp = match[2]; + + // Check if the address is accessible + status = await checkAddressAccessibility(valIp, 6182) + .then((res) => { + return res ? 'accessible' : 'notAccessible'; + }) + .catch(() => { + return 'notAccessible'; + }); + } else { + status = 'invalidAddress'; + } + + return status.toString(); + } +} + +import * as net from 'net'; +import { OlConfig } from '../../config/config.interface.js'; + +function checkAddressAccessibility(ip: string, port: number): Promise { + return new Promise((resolve) => { + const socket = new net.Socket(); + + // Timeout in case the server is not accessible + socket.setTimeout(1000); + + // Try to connect to the IP and port + socket.connect(port, ip, () => { + //console.log(`Connected to ${ip}:${port}`); + socket.end(); + resolve(true); + }); + + socket.on('error', () => { + //console.log(`Failed to connect to ${ip}:${port}`); + resolve(false); + }); + + socket.on('timeout', () => { + //console.log(`Connection to ${ip}:${port} timed out`); + resolve(false); + }); + }); } diff --git a/api/src/schema.gql b/api/src/schema.gql index 49ee76a..b680af7 100644 --- a/api/src/schema.gql +++ b/api/src/schema.gql @@ -68,9 +68,11 @@ type Validator { inSet: Boolean! index: BigInt! address: String! + handle: String city: String country: String votingPower: BigInt! + vfnStatus: String grade: ValidatorGrade vouches: Vouches currentBid: ValidatorCurrentBid @@ -79,6 +81,34 @@ type Validator { auditQualification: [String!] } +type VouchDetails { + address: String! + handle: String + family: String + compliant: Boolean! + epoch: Float! + epochsToExpire: Float! + inSet: Boolean! +} + +type ValidatorVouches { + address: String! + handle: String + family: String + inSet: Boolean! + validVouches: Float! + compliant: Boolean! + receivedVouches: [VouchDetails!]! + givenVouches: [VouchDetails!]! +} + +type ValidatorUtils { + vouchPrice: Float! + entryFee: Float! + clearingBid: Float! + netReward: Float! +} + type Account { address: Bytes! balance: Decimal @@ -254,6 +284,8 @@ type Query { account(address: Bytes!): Account getTopAccounts(limit: Float! = 100): [TopAccount!]! getValidators: [Validator!]! + getValidatorsVouches: [ValidatorVouches!]! + getValidatorUtils: ValidatorUtils! getCommunityWallets: [CommunityWallet!]! getCommunityWalletsStats: CommunityWalletStats! getCommunityWalletsPayments: [CommunityWalletPayments!]! diff --git a/api/src/stats/public-wallets.ts b/api/src/stats/public-wallets.ts deleted file mode 100644 index 8fca853..0000000 --- a/api/src/stats/public-wallets.ts +++ /dev/null @@ -1,24 +0,0 @@ -interface PublicWalletInfo { - name: string; -} - -export const publicWallets = new Map([ - [ - 'F57D3968D0BFD5B3120FDA88F34310C70BD72033F77422F4407FBBEF7C24557A', - { - name: '0lswap OTC', - }, - ], - [ - '7153A13691E832EC5C5E2F0503FB7D228FBB7C87DD0C285C29D3F1D9F320CD5C', - { - name: 'Osmosis Bridge', - }, - ], - [ - '8D57A33412C4625289E35F2843E1D36EA19FA6BDE7816B1E3607C694926F01AE', - { - name: 'Base Bridge', - }, - ], -]); diff --git a/api/src/stats/stats.service.ts b/api/src/stats/stats.service.ts index c003882..2187a5d 100644 --- a/api/src/stats/stats.service.ts +++ b/api/src/stats/stats.service.ts @@ -17,6 +17,7 @@ import { BinRange, BalanceItem, SupplyStats, + WellKnownAddress, } from './types.js'; import { ClickhouseService } from '../clickhouse/clickhouse.service.js'; import { OlService } from '../ol/ol.service.js'; @@ -24,12 +25,13 @@ import { ICommunityWalletsService } from '../ol/community-wallets/interfaces.js' import { Types } from '../types.js'; import _ from 'lodash'; import { TopLiquidAccount } from './stats.model.js'; -import { publicWallets } from './public-wallets.js'; +import { OlConfig } from '../config/config.interface.js'; @Injectable() export class StatsService { private readonly dataApiHost: string; private readonly cacheEnabled: boolean; + private readonly knownAddressesUrl?: string | undefined; public constructor( private readonly clickhouseService: ClickhouseService, @@ -42,6 +44,7 @@ export class StatsService { ) { this.dataApiHost = config.get('dataApiHost')!; this.cacheEnabled = config.get('cacheEnabled')!; + this.knownAddressesUrl = config.get('ol')?.knwonAddressesUrl; } private async setCache(key: string, data: T): Promise { @@ -1286,6 +1289,9 @@ export class StatsService { return address.slice(-15).toUpperCase(); } + // Get the list of well known addresses + const knownAddresses = await this.loadKnownAddresses(); + // Get the list of community wallets const communityWallets = await this.communityWalletsService.getCommunityWallets(); const communityAddresses = new Set(communityWallets.map((wallet) => wallet.address)); @@ -1367,7 +1373,7 @@ export class StatsService { new TopLiquidAccount({ rank: index + 1, address: item.address, - name: publicWallets.get(item.address)?.name, + name: knownAddresses.get(item.address)?.name, unlocked: item.unlockedBalance, balance: item.unlockedBalance, liquidShare: item.percentOfCirculating, @@ -1380,6 +1386,25 @@ export class StatsService { } } + async loadKnownAddresses(): Promise> { + const wka = new Map(); + if (!this.knownAddressesUrl) { + return wka; + } + try { + const response = await axios.get(this.knownAddressesUrl); + const data = response.data; + Object.keys(data.wellKnownAddresses).forEach((address) => { + let addressStr = address.replace(/^0x/, '').toUpperCase(); + wka.set(addressStr, data.wellKnownAddresses[address] as WellKnownAddress); + }); + } catch (error) { + console.error('Error loading validator handles from URL:', error); + return new Map(); + } + return wka; + } + public async updateCache(): Promise { const stats = await this.getStats(); this.setCache(STATS_CACHE_KEY, JSON.stringify(stats)); diff --git a/api/src/stats/types.ts b/api/src/stats/types.ts index f20154b..73d569d 100644 --- a/api/src/stats/types.ts +++ b/api/src/stats/types.ts @@ -49,3 +49,7 @@ export interface Stats { currentClearingBid: number; lockedCoins: [number, number][]; } + +export interface WellKnownAddress { + name: string; +} diff --git a/web-app/src/modules/core/routes/Validators/Validators.tsx b/web-app/src/modules/core/routes/Validators/Validators.tsx index 30b445b..6b90def 100644 --- a/web-app/src/modules/core/routes/Validators/Validators.tsx +++ b/web-app/src/modules/core/routes/Validators/Validators.tsx @@ -1,8 +1,10 @@ -import { FC } from 'react'; +import { FC, useState } from 'react'; import { gql, useQuery } from '@apollo/client'; import Page from '../../../ui/Page'; import ValidatorsTable from './components/ValidatorsTable'; import ValidatorsStats from './components/ValidatorsStats'; +import VouchesTable from './components/VouchesTable'; +import ToggleButton from '../../../ui/ToggleButton'; const GET_VALIDATORS = gql` query Validators { @@ -10,7 +12,9 @@ const GET_VALIDATORS = gql` inSet index address + handle votingPower + vfnStatus balance unlocked grade { @@ -39,6 +43,8 @@ const GET_VALIDATORS = gql` `; const Validators: FC = () => { + const [activeValue, setActiveValue] = useState('active'); + const { data, error } = useQuery(GET_VALIDATORS, { pollInterval: 30000, // Poll every 30 seconds }); @@ -52,6 +58,12 @@ const Validators: FC = () => { ); } + const toggleOptions = [ + { label: 'Active', value: 'active' }, + { label: 'Inactive', value: 'inactive' }, + { label: 'Vouches', value: 'vouches' }, + ]; + return (

@@ -59,7 +71,17 @@ const Validators: FC = () => {

- +
+ + {activeValue === 'vouches' ? : null} + {activeValue === 'active' || activeValue === 'inactive' ? ( + + ) : null} +
); diff --git a/web-app/src/modules/core/routes/Validators/components/ValidatorRow.tsx b/web-app/src/modules/core/routes/Validators/components/ValidatorRow.tsx index 73a6d11..483efc2 100644 --- a/web-app/src/modules/core/routes/Validators/components/ValidatorRow.tsx +++ b/web-app/src/modules/core/routes/Validators/components/ValidatorRow.tsx @@ -3,10 +3,23 @@ import clsx from 'clsx'; import AccountAddress from '../../../../ui/AccountAddress'; import Money from '../../../../ui/Money'; import { IValidator } from '../../../../interface/Validator.interface'; -import { CheckIcon, XMarkIcon } from '@heroicons/react/20/solid'; -import ProgressBar from './ProgressBar'; +import { + CheckIcon, + XMarkIcon, + CheckCircleIcon, + ExclamationCircleIcon, + XCircleIcon, +} from '@heroicons/react/20/solid'; +// import ProgressBar from './ProgressBar'; import Vouches from './Vouches'; +// Define icons for each status +const statusIcons = { + accessible: , + notAccessible: , + invalidAddress: , +}; + interface ValidatorRowProps { validator: IValidator; } @@ -17,7 +30,8 @@ const ValidatorRow: FC = ({ validator }) => { - {validator.inSet ? ( + {validator.handle} + {validator.inSet && ( {validator.grade.compliant ? ( @@ -29,30 +43,38 @@ const ValidatorRow: FC = ({ validator }) => { {' '} / {validator.grade.proposedBlocks.toLocaleString()} - ) : ( - - {validator.auditQualification?.toLocaleString()} - )} - + {validator.auditQualification?.toLocaleString() ? ( + + ) : ( + + )} + {validator.auditQualification?.toLocaleString()} - - {`${validator.currentBid && formatPercentage(validator.currentBid.currentBid)} (${ - validator.currentBid && validator.currentBid.expirationEpoch.toLocaleString() - })`} + + - {Number(validator.balance)} + {validator.currentBid + ? `${formatPercentage(validator.currentBid.currentBid)} (${ + validator.currentBid.expirationEpoch > 10000 + ? '> 10,000' + : validator.currentBid.expirationEpoch.toLocaleString() + })` + : ''} {validator.inSet && ( - - + + {statusIcons[validator.vfnStatus]} )} + + {Number(validator.unlocked)} + + + {Number(validator.balance)} + {validator.inSet && ( {validator.city ? `${validator.city}, ${validator.country}` : 'Unknown'} diff --git a/web-app/src/modules/core/routes/Validators/components/ValidatorRowSkeleton.tsx b/web-app/src/modules/core/routes/Validators/components/ValidatorRowSkeleton.tsx index 5ca31f3..1533729 100644 --- a/web-app/src/modules/core/routes/Validators/components/ValidatorRowSkeleton.tsx +++ b/web-app/src/modules/core/routes/Validators/components/ValidatorRowSkeleton.tsx @@ -14,6 +14,9 @@ const ValidatorRowSkeleton: FC = () => {
+ +
+
diff --git a/web-app/src/modules/core/routes/Validators/components/ValidatorsStats.tsx b/web-app/src/modules/core/routes/Validators/components/ValidatorsStats.tsx index aac3781..6352e21 100644 --- a/web-app/src/modules/core/routes/Validators/components/ValidatorsStats.tsx +++ b/web-app/src/modules/core/routes/Validators/components/ValidatorsStats.tsx @@ -2,16 +2,38 @@ import { FC } from 'react'; import Money from '../../../../ui/Money'; import StatsCard from '../../../../ui/StatsCard'; import { IValidator } from '../../../../interface/Validator.interface'; +import { gql, useQuery } from '@apollo/client'; + +const GET_VALIDATOR_UTILS = gql` + query ValidatorUtils { + getValidatorUtils { + vouchPrice + entryFee + clearingBid + netReward + } + } +`; interface ValidatorsStatsProps { validators?: IValidator[]; } const ValidatorsStats: FC = ({ validators }) => { + const { data, error } = useQuery(GET_VALIDATOR_UTILS, { + pollInterval: 30000, // Poll every 30 seconds + }); + + if (error) { + console.log('error', error); + return

{`Error: ${error.message}`}

; + } + const validatorSet = validators && validators.filter((it) => it.inSet); const eligible = validators && validatorSet && validators.length - validatorSet.length; const totalLibra = validators && validators.reduce((acc, it) => acc + Number(it.balance), 0); const liquidLibra = validators && validators.reduce((acc, it) => acc + Number(it.unlocked), 0); + const utils = data ? data.getValidatorUtils : null; return (
@@ -23,8 +45,34 @@ const ValidatorsStats: FC = ({ validators }) => { {liquidLibra && {liquidLibra}} + + {utils && {Math.ceil(utils.vouchPrice / 1_000_000)}} + + + {utils && {Math.ceil(utils.entryFee / 1_000_000)}} + + ({utils && formatPercentage(utils.clearingBid)}) + + + + {utils && {Math.ceil(utils.netReward / 1_000_000)}} +
); }; +// format percentage 1 decimal +function formatPercentage(value: number) { + console.log('value', value); + return `${(value / 10).toFixed(1)}%`; +} + +// print percentage 0-100 +/*function formatPercentage(value: number, didChange: boolean, didIncrease: boolean) { + if (didChange) { + return didIncrease ? `+${value}%` : `-${value}%`; + } + return `${value}%`; +}*/ + export default ValidatorsStats; diff --git a/web-app/src/modules/core/routes/Validators/components/ValidatorsTable.tsx b/web-app/src/modules/core/routes/Validators/components/ValidatorsTable.tsx index f02d3f0..f4195a0 100644 --- a/web-app/src/modules/core/routes/Validators/components/ValidatorsTable.tsx +++ b/web-app/src/modules/core/routes/Validators/components/ValidatorsTable.tsx @@ -1,27 +1,22 @@ import { FC, useState } from 'react'; import { IValidator } from '../../../../interface/Validator.interface'; -import ToggleButton from '../../../../ui/ToggleButton'; import SortableTh from './SortableTh'; import ValidatorRow from './ValidatorRow'; import ValidatorRowSkeleton from './ValidatorRowSkeleton'; +import { CheckCircleIcon, ExclamationCircleIcon, XCircleIcon } from '@heroicons/react/20/solid'; interface ValidatorsTableProps { validators?: IValidator[]; + activeValue: string; } type SortOrder = 'asc' | 'desc'; -const ValidatorsTable: FC = ({ validators }) => { +const ValidatorsTable: FC = ({ validators, activeValue }) => { const [sortColumn, setSortColumn] = useState('index'); const [sortOrder, setSortOrder] = useState('desc'); const [previousSortColumn, setPreviousSortColumn] = useState('vouches'); const [isActive] = useState(true); - const [activeValue, setActiveValue] = useState('active'); - - const toggleOptions = [ - { label: 'Active', value: 'active' }, - { label: 'Inactive', value: 'inactive' }, - ]; const handleSort = (column: string) => { if (sortColumn === column) { @@ -75,6 +70,10 @@ const ValidatorsTable: FC = ({ validators }) => { value1 = a.address; value2 = b.address; break; + case 'handle': + value1 = a.handle; + value2 = b.handle; + break; case 'index': value1 = Number(a.index); value2 = Number(b.index); @@ -93,6 +92,10 @@ const ValidatorsTable: FC = ({ validators }) => { value1 = a.auditQualification ? a.auditQualification.length : 0; value2 = b.auditQualification ? b.auditQualification.length : 0; break; + case 'vfnStatus': + value1 = a.vfnStatus; + value2 = b.vfnStatus; + break; case 'vouches': value1 = a.vouches.valid; value2 = b.vouches.valid; @@ -153,19 +156,25 @@ const ValidatorsTable: FC = ({ validators }) => { const columns = [ { key: 'address', label: 'Address', className: '' }, + { key: 'handle', label: 'Handle', className: 'text-center' }, ...(activeValue === 'active' ? [{ key: 'grade', label: 'Grade', className: 'text-center' }] - : [{ key: 'audit', label: 'Audit', className: 'text-center' }]), + : []), + { key: 'audit', label: 'Audit', className: 'text-center' }, { key: 'vouches', label: 'Vouches', className: 'text-center' }, { key: 'currentBid', label: 'Bid (Exp. Epoch)', className: 'text-right' }, + ...(activeValue === 'active' + ? [{ key: 'vfnStatus', label: 'VFN', className: 'text-center' }] + : []), + { key: 'unlocked', label: 'Unlocked', className: 'text-right' }, { key: 'balance', label: 'Balance', className: 'text-right' }, ...(activeValue === 'active' ? [ - { + /*{ key: 'cumulativeShare', label: 'Cumulative Share (%)', className: 'text-left whitespace-nowrap', - }, + },*/ { key: 'location', label: 'Location', className: 'text-left' }, ] : []), @@ -173,40 +182,38 @@ const ValidatorsTable: FC = ({ validators }) => { ]; return ( -
- -
-
-
- - - - {columns.map((col) => ( - - {col.label} - +
+
+
+
+ + + {columns.map((col) => ( + + {col.label} + + ))} + + + + {cumulativeValidators + ? cumulativeValidators.map((validator) => ( + + )) + : Array.from({ length: 10 }).map((_, index) => ( + ))} - - - - {cumulativeValidators - ? cumulativeValidators.map((validator) => ( - - )) - : Array.from({ length: 10 }).map((_, index) => ( - - ))} - -
- {activeValue === 'inactive' && } -
+ + + +
@@ -238,4 +245,26 @@ const AuditLegend: FC = () => { ); }; +const VfnLegend: FC = () => { + return ( +
+

VFN Legend

+
    +
  • + + Accessible +
  • +
  • + + Not Accessible +
  • +
  • + + Invalid Configuration +
  • +
+
+ ); +}; + export default ValidatorsTable; diff --git a/web-app/src/modules/core/routes/Validators/components/VouchesLegend.tsx b/web-app/src/modules/core/routes/Validators/components/VouchesLegend.tsx new file mode 100644 index 0000000..bc4909e --- /dev/null +++ b/web-app/src/modules/core/routes/Validators/components/VouchesLegend.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { + GlobeAltIcon, + CheckIcon, + XMarkIcon, + ExclamationTriangleIcon, + ClockIcon, +} from '@heroicons/react/20/solid'; +import clsx from 'clsx'; + +const VouchLegend: React.FC = () => { + const legendItems = [ + { + Icon: GlobeAltIcon, + color: 'text-blue-500', // Color for "In Set" + title: 'In Set', + description: 'The validator is part of the current active set.', + }, + { + Icon: CheckIcon, + color: 'text-green-500', // Color for "Compliant" + title: 'Compliant', + description: 'The validator is compliant with audit qualifications.', + }, + { + Icon: XMarkIcon, + color: 'text-red-500', // Color for "Non-Compliant" + title: 'Non-Compliant', + description: 'The validator is not compliant with audit qualifications.', + }, + { + Icon: ExclamationTriangleIcon, + color: 'text-yellow-500', // Color for "Expiring Soon" + title: 'Expiring Soon', + description: 'Vouch will expire in less than 7 epochs.', + }, + { + Icon: ClockIcon, + color: 'text-red-500', // Color for "Expired" + title: 'Expired', + description: 'The vouch has expired.', + }, + { + Icon: () => ( + + ), + color: '', // No icon, so no color + title: 'Family Color', + description: 'Represents the vouch family grouping.', + }, + ]; + + return ( +
+

Vouch Legend

+
    + {legendItems.map((item, index) => ( +
  • + + {item.title}: + {item.description} +
  • + ))} +
+
+ ); +}; + +export default VouchLegend; diff --git a/web-app/src/modules/core/routes/Validators/components/VouchesRow.tsx b/web-app/src/modules/core/routes/Validators/components/VouchesRow.tsx new file mode 100644 index 0000000..6a54617 --- /dev/null +++ b/web-app/src/modules/core/routes/Validators/components/VouchesRow.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { + CheckIcon, + XMarkIcon, + ExclamationTriangleIcon, + ClockIcon, + GlobeAltIcon, +} from '@heroicons/react/20/solid'; +import clsx from 'clsx'; +import AccountAddress from '../../../../ui/AccountAddress'; +import { ValidatorVouches, VouchDetails } from '../../../../interface/Validator.interface'; + +type VouchesRowProps = { + validator: ValidatorVouches; + showExpired: boolean; +}; + +function formatAddress(address: string): string { + return address.slice(0, 4) + '...' + address.slice(-4); +} + +const VouchesRow: React.FC = ({ validator, showExpired }) => { + return ( + + + + + + {validator.handle || formatAddress(validator.address)}{' '} + {' '} + + + {validator.inSet ? ( + + ) : ( + + )} + + + {validator.compliant ? ( + + ) : ( + + )} + {validator.validVouches} + + + {validator.receivedVouches + .filter((vouch: VouchDetails) => showExpired || vouch.epochsToExpire > 0) + .map((vouch: VouchDetails, index: number) => ( + + ))} + + + {validator.givenVouches + .filter((vouch: VouchDetails) => showExpired || vouch.epochsToExpire > 0) + .map((vouch: VouchDetails, index: number) => ( + + ))} + + + ); +}; + +const FamilyIcon: React.FC<{ family: string }> = ({ family }) => { + const bgColor = family && family.length > 8 ? `#${family.slice(2, 8)}` : `#f87171`; + return ( + + ); +}; + +const VouchChip: React.FC<{ vouch: VouchDetails; index: number }> = ({ vouch, index }) => { + // Generate background color for the chip based on the family (hexadecimal) + return ( + + {vouch.inSet && } + {vouch.compliant ? ( + + ) : ( + + )} + + {vouch.epochsToExpire <= 7 && vouch.epochsToExpire > 0 && ( + + )} + + {vouch.epochsToExpire <= 0 && } + + {/* handle or address 1234...5678*/} + {vouch.handle || formatAddress(vouch.address)} + + {' '} + ({vouch.epochsToExpire}) + + + + + ); +}; + +export default VouchesRow; diff --git a/web-app/src/modules/core/routes/Validators/components/VouchesRowSkeleton.tsx b/web-app/src/modules/core/routes/Validators/components/VouchesRowSkeleton.tsx new file mode 100644 index 0000000..8447813 --- /dev/null +++ b/web-app/src/modules/core/routes/Validators/components/VouchesRowSkeleton.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import clsx from 'clsx'; + +const VouchesRowSkeleton: React.FC = () => { + return ( + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + ); +}; + +export default VouchesRowSkeleton; diff --git a/web-app/src/modules/core/routes/Validators/components/VouchesTable.tsx b/web-app/src/modules/core/routes/Validators/components/VouchesTable.tsx new file mode 100644 index 0000000..ff288cf --- /dev/null +++ b/web-app/src/modules/core/routes/Validators/components/VouchesTable.tsx @@ -0,0 +1,190 @@ +import React, { useState } from 'react'; +import { gql, useQuery } from '@apollo/client'; +import SortableTh from './SortableTh'; +import VouchesRow from './VouchesRow'; +import { ValidatorVouches } from '../../../../interface/Validator.interface'; +import VouchesRowSkeleton from './VouchesRowSkeleton'; +import VouchesLegend from './VouchesLegend'; + +const GET_VALIDATORS = gql` + query GetValidatorsVouches { + getValidatorsVouches { + address + handle + family + inSet + validVouches + compliant + receivedVouches { + handle + address + compliant + epochsToExpire + inSet + family + } + givenVouches { + handle + address + compliant + epochsToExpire + inSet + family + } + } + } +`; + +const VouchesTable: React.FC = () => { + const [showExpired, setShowExpired] = useState(false); + const [sortColumn, setSortColumn] = useState('compliant'); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); + + const { data, error } = useQuery(GET_VALIDATORS, { + pollInterval: 30000, // Poll every 30 seconds + }); + + if (error) { + console.log('error', error); + return

{`Error: ${error.message}`}

; + } + + const handleCheckboxChange = () => { + setShowExpired(!showExpired); + }; + + const handleSort = (column: string) => { + if (sortColumn === column) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); + } else { + setSortColumn(column); + setSortOrder('asc'); + } + }; + + const getSortedValidators = (validators: ValidatorVouches[]) => { + const sortedValidators = [...validators].sort((a, b) => { + const [aValue, bValue] = getValue(a, b, sortColumn); + + if (aValue === bValue) { + return a.address.localeCompare(b.address); + } + + return aValue < bValue ? -1 : 1; + }); + + if (sortOrder === 'asc') { + sortedValidators.reverse(); + } + + return sortedValidators; + }; + + const getValue = (a: ValidatorVouches, b: ValidatorVouches, column: string): [any, any] => { + let value1: any; + let value2: any; + + switch (column) { + case 'address': + value1 = a.address; + value2 = b.address; + break; + case 'handle': + value1 = a.handle; + value2 = b.handle; + break; + case 'inSet': + value1 = a.inSet; + value2 = b.inSet; + break; + case 'compliant': + value1 = a.validVouches; + value2 = b.validVouches; + break; + case 'receivedVouches': + value1 = a.receivedVouches.length; + value2 = b.receivedVouches.length; + break; + case 'givenVouches': + value1 = a.givenVouches.length; + value2 = b.givenVouches.length; + break; + default: + value1 = a.address; + value2 = b.address; + } + + return [value1, value2]; + }; + + const filteredValidators = data + ? data.getValidatorsVouches.filter((validator: ValidatorVouches) => { + if (!showExpired) { + return validator.receivedVouches.some((vouch) => vouch.epochsToExpire > 0); + } + return true; + }) + : []; + + const sortedValidators = getSortedValidators(filteredValidators); + + const columns = [ + { key: 'address', label: 'Address', sortable: true }, + { key: 'handle', label: 'Handle', sortable: true }, + { key: 'inSet', label: 'In Set', sortable: true }, + { key: 'compliant', label: 'Compliant', sortable: true }, + { key: 'receivedVouches', label: 'Received Vouches', sortable: true }, + { key: 'givenVouches', label: 'Given Vouches', sortable: true }, + ]; + + return ( + <> + + + + + + {columns.map((col) => ( + + {col.label} + + ))} + + + + {data + ? sortedValidators.map((validator: ValidatorVouches, index: number) => ( + + )) + : [...Array(5)].map((_each, index) => ( + + ))} + +
+ +

+ The data in this table is updated every 60 seconds. +

+ + ); +}; + +export default VouchesTable; diff --git a/web-app/src/modules/interface/Validator.interface.ts b/web-app/src/modules/interface/Validator.interface.ts index 488954c..efea68f 100644 --- a/web-app/src/modules/interface/Validator.interface.ts +++ b/web-app/src/modules/interface/Validator.interface.ts @@ -10,9 +10,12 @@ interface Vouches { export interface IValidator { address: string; + family: string; + handle?: string; inSet: boolean; index: number; votingPower: number; + vfnStatus: string; balance?: number; unlocked?: number; vouches: Vouches; @@ -33,3 +36,23 @@ export interface IValidator { country: string | null; auditQualification: [string] | null; } + +export type VouchDetails = { + handle: string; + address: string; + compliant: boolean; + epochsToExpire: number; + inSet: boolean; + family: string; +}; + +export type ValidatorVouches = { + address: string; + handle: string; + inSet: boolean; + family: string; + compliant: boolean; + validVouches: number; + receivedVouches: VouchDetails[]; + givenVouches: VouchDetails[]; +};