From e28944e91d6452bdd730031467a9257bc822bfdb Mon Sep 17 00:00:00 2001 From: Bradford <1217269+dadatuputi@users.noreply.github.com> Date: Fri, 21 Jul 2023 01:01:05 +0100 Subject: [PATCH] 2.0 Release (#14) Features: Subscribe to daily claim payouts for validator/nominator wallets Monitor commission change events Closes #3 --- .env.template | 55 ++- .vscode/launch.json | 10 +- Dockerfile | 2 +- README.md | 195 +------- docker-compose.dev.yml | 22 + ...ly-compose.yml => docker-compose.mongo.yml | 17 +- docker-compose.yml | 24 +- mongo/config/.gitignore | 4 - mongo/db/.gitignore | 0 package-lock.json | 36 +- package.json | 11 +- src/chain/claim.ts | 417 ++++++++++++++++++ src/chain/index.ts | 200 +++++++++ src/chain/types.ts | 86 ++++ src/cmix/index.ts | 143 +++--- src/cmix/types.ts | 24 + src/custom-derives/staking/index.ts | 2 +- src/db/index.ts | 325 +++++++++++--- src/db/types.ts | 35 +- src/discord/commands/claim.ts | 247 +++++++++++ src/discord/commands/donate.ts | 42 ++ src/discord/commands/list_monitored_nodes.ts | 222 ---------- src/discord/commands/monitor.ts | 175 ++++++++ src/discord/commands/monitor_node.ts | 107 ----- src/discord/commands/unmonitor_node.ts | 78 ---- .../discord-utils.ts} | 53 ++- src/discord/events/interactionCreate.ts | 10 +- src/discord/events/ready.ts | 54 ++- src/discord/index.ts | 21 +- src/discord/messager.ts | 50 +++ src/env-guard/claim.ts | 12 + src/env-guard/discord.ts | 9 + src/env-guard/donate.ts | 7 + src/env-guard/index.ts | 7 + src/env-guard/monitor.ts | 10 + src/events/types.ts | 13 + src/index.ts | 45 +- src/messager.ts | 23 - src/utils.ts | 118 ++++- 39 files changed, 2019 insertions(+), 892 deletions(-) create mode 100755 docker-compose.dev.yml rename mongo-only-compose.yml => docker-compose.mongo.yml (58%) delete mode 100644 mongo/config/.gitignore mode change 100644 => 100755 mongo/db/.gitignore create mode 100644 src/chain/claim.ts create mode 100755 src/chain/index.ts create mode 100644 src/chain/types.ts create mode 100755 src/discord/commands/claim.ts create mode 100644 src/discord/commands/donate.ts delete mode 100644 src/discord/commands/list_monitored_nodes.ts create mode 100755 src/discord/commands/monitor.ts delete mode 100644 src/discord/commands/monitor_node.ts delete mode 100644 src/discord/commands/unmonitor_node.ts rename src/{bot-utils.ts => discord/discord-utils.ts} (74%) create mode 100644 src/discord/messager.ts create mode 100644 src/env-guard/claim.ts create mode 100644 src/env-guard/discord.ts create mode 100644 src/env-guard/donate.ts create mode 100644 src/env-guard/index.ts create mode 100644 src/env-guard/monitor.ts create mode 100644 src/events/types.ts delete mode 100644 src/messager.ts diff --git a/.env.template b/.env.template index 911d99d..8bb7727 100644 --- a/.env.template +++ b/.env.template @@ -4,24 +4,63 @@ DISCORD_TOKEN= APP_ID= # dev server ID for testing DEV_GUILD_ID= +# discord bot appearance +## optional: sets username at login +BOT_USERNAME=xx monitor bot +## optional: sets what the bot is 'listening to' at login +BOT_STATUS=user commands +## optional: sets the avatar at login +BOT_AVATAR=res/xx_logo_color.png +## optional: sends action failures (e.g. claims) to provided channel id, or sends to user if set to 'dm' +ADMIN_NOTIFY_CHANNEL= -BOT_USERNAME=xx monitor bot # this is updated on bot login -BOT_STATUS=user commands # this is updated on bot login -BOT_AVATAR=res/xx_logo_color.png # this is not updated on bot login +## optional: provides a donate address to the /donate command - required for /donate +DONATE_WALLET= # XX API variables -ENDPOINT=https://dashboard-api.xx.network/v1/nodes -ENDPOINT_CRON=* * * * * # how often (in seconds) to pull the current node list (default is every minute) -DASHBOARD_URL=https://dashboard.xx.network/nodes # used to build dashbord URIs +## api endpoint to pull cmix node status +CMIX_API_ENDPOINT=https://dashboard-api.xx.network/v1/nodes +## cron expression for polling the cmix node list (default is every minute) +CMIX_API_CRON=* * * * * +## base url to build dashbord URIs +CMIX_DASH_URL=https://dashboard.xx.network/nodes +## base url to build explorer URIs +EXPLORER_URL=https://explorer.xx.network/accounts + +# XX Chain variables +## optional: location for cert for connecting to chain rpc. required if using endpoint with self-signed cert +NODE_EXTRA_CA_CERTS= +## xx chain RPC endpoint (wss://...) +CHAIN_RPC_ENDPOINT= + +# XX Claim variables +## cron expression for regular payout (default is every day at 7:05 AM) +CLAIM_CRON_DAILY=5 7 * * * +## optional cron expression for irregular payout (default is off, or every Monday at 7:05 AM) - if this is set, /claim weekly will be activated +#CLAIM_CRON_WEEKLY=5 7 * * 1 +## maximun number of transactions to batch together +CLAIM_BATCH=10 +## JSON string from exported wallet +CLAIM_WALLET='' +## password string for exported wallet +CLAIM_PASSWORD='' +## optional: external claim endpoint if hosting claim wallet list externally +CLAIM_ENDPOINT='' +## optional: key for external claim endpoint +CLAIM_ENDPOINT_KEY='' # MongoDb variables MONGO_INITDB_ROOT_USERNAME=root -MONGO_INITDB_ROOT_PASSWORD= # just generate a random password here +## generate a random password here +MONGO_INITDB_ROOT_PASSWORD= MONGO_CONTAINER_NAME=mongo MONGO_PORT=27017 MONGO_URI=mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@${MONGO_CONTAINER_NAME}:${MONGO_PORT}/ ME_CONFIG_BASICAUTH_USERNAME=bot_admin -ME_CONFIG_BASICAUTH_PASSWORD= # put your mongo-express password here +## choose a password to access mongo-express password here +ME_CONFIG_BASICAUTH_PASSWORD= +## port used to access mongo express when in development +ME_PORT=8081 # Watchtower variables # Timezone - used for update cron diff --git a/.vscode/launch.json b/.vscode/launch.json index 6dbb4a5..e944364 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,7 +7,9 @@ "request": "launch", "runtimeArgs": [ "-r", - "ts-node/register" + "ts-node/register", + "--loader", + "ts-node/esm" ], "args": [ "${workspaceFolder}/src/index.ts" @@ -15,7 +17,11 @@ "env": { "TS_NODE_PROJECT": "${workspaceFolder}/tsconfig.json" }, - "envFile": "${workspaceFolder}/.env" + "envFile": "${workspaceFolder}/.env", + "outFiles": [ + "${workspaceFolder}/dist/**/*.js", + "!**/node_modules/**" + ] } ] } diff --git a/Dockerfile b/Dockerfile index a744900..bee671c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:16 +FROM node:20-alpine # Create app directory WORKDIR /usr/src/app diff --git a/README.md b/README.md index 79dbd38..73acfab 100644 --- a/README.md +++ b/README.md @@ -2,195 +2,14 @@ The latest version of this bot is accessible at the [engul.ph discord server](https://discord.gg/Y7jQEbv5za). -# Getting Started +# Features -Clone this repo into your linux environment. +- Monitor xx node status +- Monitor xx node commission changes +- Monitor xx node name changes +- Subscribe to daily payouts for your nominator or validator -### Requirements +# Installation -* `docker` +Instructions are found on the [wiki](https://github.com/dadatuputi/xx_monitor_bot/wiki/Installation) -### Development Requirements - -If you want to change the code and test it outside docker, install: - -* `nodejs` -* `npm` - -## Step 1: Create a Discord Application on the Discord Developer Portal - -Visit the [Discord Developer Portal](https://discord.com/developers/applications) and create an application. - -Make note of your: - -1. `Application ID` found under `General Information` -2. `Bot Token` found under `Bot` - -## Optional: Create a Discord Server for Testing / Development - -Create a private discord server to test the bot on. - -## Step 2: Authorize the Bot on your Server - -To authorize the bot with the necessary permissions (`Create commands in a server`) to join your server, build the url as follows (taken from the [`discord.js` guide](https://discordjs.guide/preparations/adding-your-bot-to-servers.html)): - -``` -https://discord.com/api/oauth2/authorize?client_id=&permissions=0&scope=bot%20applications.commands -``` - -## Step 3: Set up environmental variables - -Copy `.env.template` to `.env`: - -```bash -$ cp .env.template .env -$ vi .env -``` - -Fill in missing variables in `.env`, such as the Discord Token and App ID. Edit any others as desired. If you are using a test server, copy the server id to the `DEV_GUILD_ID` variable. The server id is found in Discord through these steps: - -1. Enable `Developer Mode` in the Discord App -2. Right click on the server and select `Copy ID` - -## Step 4: Build the Docker Image - -Run the following command to build an image from source: - -```bash -$ docker compose build -``` - -## Step 5: Publish the commands to your server - -Use the `bot-utils.js` script to deploy the bot commands to your dev server or globally to all servers. You can either run the script in a container, or locally if you have `node.js` installed. - -### Using `bot-utils.js` in docker - -Run this command to view the script options: - -``` -$ docker run --env-file .env -it xx_monitor_bot-bot node built/bot-utils.js -``` - -### **Required:** Deploy commands to server or globally - -You may choose to deploy globally or to a specific server. Deploying globally allows users to interact with the bot over Direct Messages. Don't deploy to both, however, because the commands will appear multiple times in the Discord UI. - -To deploy globally: - -``` -$ docker run --env-file .env -it xx_monitor_bot-bot node built/bot-utils.js deploy --global -``` - -To deploy to a server: - -``` -$ docker run --env-file .env -it xx_monitor_bot-bot node built/bot-utils.js deploy -``` - -To un-deploy: - -``` -$ docker run --env-file .env -it xx_monitor_bot-bot node built/bot-utils.js deploy --reset -``` - -**To deploy changes you make to the `SlashCommandBuilder` object in your commands**: - -Just deploy as you originally did, either globally or to a server. - -### Update username -``` -$ docker run --env-file .env -it xx_monitor_bot-bot node built/bot-utils.js username "xx monitor bot" -``` - -### Update avatar -``` -$ docker run --env-file .env --volume :/image -it xx_monitor_bot-bot node built/bot-utils.js avatar /image -``` - -### Using `bot-utils.js` locally - -Use the following commands to install the required packages and publish the slash commands to your server: - -```bash -$ npm install -$ node built/bot-utils.js help -$ [... command output ...] -``` - -You can also set the bot username and status with the `BOT_USERNAME` and `BOT_STATUS` variables in `.env` and they will be set each time the bot starts. - - -## Step 6: Start the Bot - -Run the following command to start the bot in the background: - -```bash -$ docker compose up -d -``` - -To view the logs of the running containers, try the following commands: - -```bash -$ docker compose logs # shows most recent logs from all containers -$ docker compose logs -f # follows the log output of all containers continuously -$ docker compose logs -f bot # follows the bot console continuously -``` - -## Step 7: Interact with the bot - -The slash commands are self-documenting. In your server, start typing `/` and a list of available slash commands will display. - -### Commands - -#### `/monitor_node id name` - -Enter this command to monitor a node of the given `id`. Because node status changes are made over DM, the bot will try to send you a DM. If it is successful, it will start monitoring the node for future status changes. `name` is optional. If not set, the bot will try to use the name provided by the API. - -#### `/list_monitored_nodes format` - -This command will show a list of nodes that you are monitoring. `format` is optional, and is either `Text` (default) or `Buttons`. - -#### `/unmonitor_node id` - -This command will instruct the bot to stop monitor the given node `id` for you. - -# Development & Testing - -## Running bot in development mode - -To run the bot outside docker for testing or development, first start up an ephemeral mongodb service: - -```bash -$ cd bot -$ docker compose -f mongo-only-compose.yml --env-file .env up -``` - -From another terminal, run: - -```bash -$ npm install # if it hasn't been run before -$ npm run start -``` - -Now you can run the app without building a docker image for each test run. - -When you are done testing, you can clean up the containers by returning to the `docker compose` terminal and stopping the `mongo` services with `Ctrl-c` and: - -```bash -$ docker compose -f mongo-only-compose.yml -v -``` - -## Deploying Updates - -To deploy updates, simply pull the changes from git, rebuild the container in docker, bring the new container online, and optionally redeploy the commands (if the `SlashCommandBuilder` object in any of the commands has been updated): - -```bash -$ git pull -... -$ docker compose build -... -$ docker compose up -d -... -$ docker run --env-file .env -it xx_monitor_bot-bot node built/bot-utils.js deploy --global # note! deploy how you originally deployed -``` \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100755 index 0000000..83257d2 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,22 @@ +version: '3.1' +name: xx-monitor-bot-dev +services: + + mongo-express: + image: mongo-express + restart: always + depends_on: + - mongo + ports: + - ${ME_PORT}:8081 + environment: + - ME_CONFIG_MONGODB_ADMINUSERNAME=${MONGO_INITDB_ROOT_USERNAME} + - ME_CONFIG_MONGODB_ADMINPASSWORD=${MONGO_INITDB_ROOT_PASSWORD} + - ME_CONFIG_MONGODB_SERVER=${MONGO_CONTAINER_NAME} + - ME_CONFIG_MONGODB_PORT=${MONGO_PORT} + - ME_CONFIG_BASICAUTH_USERNAME + - ME_CONFIG_BASICAUTH_PASSWORD + + bot: + environment: + - NODE_ENV=development \ No newline at end of file diff --git a/mongo-only-compose.yml b/docker-compose.mongo.yml similarity index 58% rename from mongo-only-compose.yml rename to docker-compose.mongo.yml index c5df28d..4562a97 100644 --- a/mongo-only-compose.yml +++ b/docker-compose.mongo.yml @@ -3,14 +3,15 @@ version: '3.1' services: mongo: - image: mongo - restart: always + image: mongo:6.0.6 environment: - MONGO_INITDB_ROOT_USERNAME # Value-less variables are pulled directly from .env - MONGO_INITDB_ROOT_PASSWORD + volumes: + - ./mongo/db:/data/db ports: - - ${MONGO_PORT}:${MONGO_PORT} - command: --port ${MONGO_PORT} --bind_ip 0.0.0.0 --quiet + - ${MONGO_PORT}:27017 + command: --quiet mongo-express: image: mongo-express @@ -18,8 +19,12 @@ services: depends_on: - mongo ports: - - 8081:8081 + - ${ME_PORT}:8081 environment: - ME_CONFIG_MONGODB_ADMINUSERNAME=${MONGO_INITDB_ROOT_USERNAME} - ME_CONFIG_MONGODB_ADMINPASSWORD=${MONGO_INITDB_ROOT_PASSWORD} - - ME_CONFIG_MONGODB_URL=${MONGO_URI} + - ME_CONFIG_MONGODB_SERVER=${MONGO_CONTAINER_NAME} + - ME_CONFIG_MONGODB_PORT=${MONGO_PORT} + - ME_CONFIG_BASICAUTH_USERNAME + - ME_CONFIG_BASICAUTH_PASSWORD + \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 0b06da3..2facf86 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,35 +1,20 @@ version: '3.1' - +name: xx-monitor-bot services: mongo: - image: mongo - container_name: xx-monitor-bot-mongo + image: mongo:6.0.6 restart: always environment: - MONGO_INITDB_ROOT_USERNAME # Value-less variables are pulled directly from .env - MONGO_INITDB_ROOT_PASSWORD volumes: - ./mongo/db:/data/db - - ./mongo/config:/data/configdb - command: --port ${MONGO_PORT} --bind_ip 0.0.0.0 --quiet - - mongo-express: - image: mongo-express - restart: always - depends_on: - - mongo ports: - - 8081:8081 - environment: - - ME_CONFIG_MONGODB_ADMINUSERNAME=${MONGO_INITDB_ROOT_USERNAME} - - ME_CONFIG_MONGODB_ADMINPASSWORD=${MONGO_INITDB_ROOT_PASSWORD} - - ME_CONFIG_MONGODB_URL=${MONGO_URI} - - ME_CONFIG_BASICAUTH_USERNAME - - ME_CONFIG_BASICAUTH_PASSWORD + - ${MONGO_PORT}:27017 + command: --quiet bot: - container_name: xx-monitor-bot restart: always depends_on: - mongo @@ -45,7 +30,6 @@ services: # and restart it with the same options that were used when it was deployed initially # https://github.com/containrrr/watchtower image: containrrr/watchtower - container_name: xx-monitor-bot-watchtower restart: always depends_on: - bot diff --git a/mongo/config/.gitignore b/mongo/config/.gitignore deleted file mode 100644 index 5e7d273..0000000 --- a/mongo/config/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -# Ignore everything in this directory -* -# Except this file -!.gitignore diff --git a/mongo/db/.gitignore b/mongo/db/.gitignore old mode 100644 new mode 100755 diff --git a/package-lock.json b/package-lock.json index 4a5ed78..6372afc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,15 +12,19 @@ "@commander-js/extra-typings": "^11.0.0", "@polkadot/api": "^9.14.2", "base64url": "^3.0.1", + "chalk": "^5.3.0", "cron": "^2.3.1", + "cronstrue": "^2.27.0", "discord.js": "^14.11.0", "moment": "^2.29.4", - "mongodb": "^4.11.0" + "mongodb": "^4.11.0", + "pubsub-js": "^1.9.4" }, "devDependencies": { "@polkadot/types": "^9.14.2", "@types/cron": "^2.0.1", "@types/node": "^20.3.1", + "@types/pubsub-js": "^1.8.3", "@xxnetwork/types": "^1.0.4", "dotenv": "^16.0.3", "dotenv-expand": "^9.0.0", @@ -1953,6 +1957,12 @@ "node": ">= 6" } }, + "node_modules/@types/pubsub-js": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@types/pubsub-js/-/pubsub-js-1.8.3.tgz", + "integrity": "sha512-6BqY04dh2UV1dNV690tyJVJYQ0U6qBH4tU+FCwY1Mhl8jOPOP9qiIvgLnB59cVik/E6/R002oXZpGiDm+2C8eA==", + "dev": true + }, "node_modules/@types/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -2117,6 +2127,17 @@ "node": ">=10.16.0" } }, + "node_modules/chalk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -2151,6 +2172,14 @@ "luxon": "^3.2.1" } }, + "node_modules/cronstrue": { + "version": "2.27.0", + "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.27.0.tgz", + "integrity": "sha512-p+w818EttA27EfIeZP5Z3k3ps9hy6DlRv3txbWxysTIlWEAE6DdYIjCaaeZhWaNfcowuXZrg0HVFWLTqGb85hg==", + "bin": { + "cronstrue": "bin/cli.js" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -2687,6 +2716,11 @@ "node": ">= 8" } }, + "node_modules/pubsub-js": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/pubsub-js/-/pubsub-js-1.9.4.tgz", + "integrity": "sha512-hJYpaDvPH4w8ZX/0Fdf9ma1AwRgU353GfbaVfPjfJQf1KxZ2iHaHl3fAUw1qlJIR5dr4F3RzjGaWohYUEyoh7A==" + }, "node_modules/punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", diff --git a/package.json b/package.json index 83010f3..f1545ff 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,11 @@ "description": "", "main": "built/index.js", "scripts": { - "start": "node built/index.js", + "start": "NODE_ENV=production node built/index.js", "build": "tsc --build", "clean": "tsc --build --clean", - "dev": "NODE_ENV=development bash -c 'env-cmd npm run build && npm run start'" + "dev-start": "MONGO_CONTAINER_NAME=localhost node -r dotenv-expand/config built/index.js dotenv_config_path=./.env", + "dev": "npm run build && env $(grep NODE_EXTRA_CA_CERTS ./.env | cut -d '#' -f1 | xargs) NODE_ENV=development npm run dev-start" }, "keywords": [], "author": "", @@ -17,15 +18,19 @@ "@commander-js/extra-typings": "^11.0.0", "@polkadot/api": "^9.14.2", "base64url": "^3.0.1", + "chalk": "^5.3.0", "cron": "^2.3.1", + "cronstrue": "^2.27.0", "discord.js": "^14.11.0", "moment": "^2.29.4", - "mongodb": "^4.11.0" + "mongodb": "^4.11.0", + "pubsub-js": "^1.9.4" }, "devDependencies": { "@polkadot/types": "^9.14.2", "@types/cron": "^2.0.1", "@types/node": "^20.3.1", + "@types/pubsub-js": "^1.8.3", "@xxnetwork/types": "^1.0.4", "dotenv": "^16.0.3", "dotenv-expand": "^9.0.0", diff --git a/src/chain/claim.ts b/src/chain/claim.ts new file mode 100644 index 0000000..77bd4c2 --- /dev/null +++ b/src/chain/claim.ts @@ -0,0 +1,417 @@ +// built this file with inspiration from https://github.com/w3f/polkadot-k8s-payouts/blob/master/src/actions/start.ts + +import "@xxnetwork/types"; +import { BN } from "@polkadot/util"; +import { CronJob } from "cron"; +import { codeBlock, inlineCode, spoiler } from "discord.js"; +import { Icons, prettify_address_alias, xx_price as get_xx_price, pluralize, engulph_fetch_claimers, EXTERNAL } from "../utils.js"; +import { Chain } from "./index.js"; +import { ClaimFrequency } from "./types.js"; +import { NotifyData, XXEvent } from "../events/types.js"; +import chalk from 'chalk'; +import cronstrue from 'cronstrue'; +import PubSub from 'pubsub-js'; + +import type { Database } from "../db/index.js"; +import type { ChalkInstance } from 'chalk'; +import type { SubmittableExtrinsic } from "@polkadot/api/types/submittable.js"; +import type { ISubmittableResult } from "@polkadot/types/types/extrinsic.js"; +import type { KeyringPair$Json } from "@polkadot/keyring/types"; +import type { + Staker, + StakerRewards, + StakerRewardsAvailable, + EraClaim, + StakerNotify, + ClaimConfig, + ExternalStakerConfig, +} from "./types.js"; + +// env guard +import '../env-guard/claim.js' + +// test that we can connect to the provided endpoint except when deploying commands +if (!process.env.BOT_DEPLOY && ! await Chain.test(process.env.CHAIN_RPC_ENDPOINT!)) throw new Error("Can't connect to chain, exiting"); + +export async function startAllClaiming( + db: Database, + chain_rpc: string, +) { + + ClaimFrequency.DAILY.cron = process.env.CLAIM_CRON_DAILY!; + const cfg_daily: ClaimConfig = { + frequency: ClaimFrequency.DAILY, + batch: +process.env.CLAIM_BATCH!, + wallet: Chain.init_key(JSON.parse(process.env.CLAIM_WALLET!) as KeyringPair$Json, process.env.CLAIM_PASSWORD!), + } + ClaimFrequency.WEEKLY.cron = process.env.CLAIM_CRON_WEEKLY!; + const cfg_weekly: ClaimConfig = { + frequency: ClaimFrequency.WEEKLY, + batch: +process.env.CLAIM_BATCH!, + wallet: Chain.init_key(JSON.parse(process.env.CLAIM_WALLET!) as KeyringPair$Json, process.env.CLAIM_PASSWORD!), + } + + // start discord claim cron + startClaiming(db, chain_rpc, cfg_daily); + + if (process.env.CLAIM_CRON_WEEKLY) { + // start irregular claim cron if set + startClaiming(db, chain_rpc, cfg_weekly); + + // start external staker claim cron + const external_stakers: ExternalStakerConfig = { + fn: engulph_fetch_claimers, + identifier: EXTERNAL, + args: {endpoint: process.env.CLAIM_ENDPOINT, key: process.env.CLAIM_ENDPOINT_KEY} + } + startClaiming(db, chain_rpc, cfg_weekly, external_stakers); + } +} + +export async function startClaiming( + db: Database, + chain_rpc: string, + claim_cfg: ClaimConfig, + external?: ExternalStakerConfig, +): Promise { + const job = new CronJob( + claim_cfg.frequency.cron, + async function () { + const chain = await Chain.create(chain_rpc) + const claim = await Claim.create(db, chain, claim_cfg, external) + claim.log(`*** Starting ${external ? Icons.EXTERNAL:Icons.BOT} ${claim_cfg.frequency} claim cron ***`); + await claim.submit(); + await chain.api.disconnect(); + claim.log(`*** Completed ${external ? Icons.EXTERNAL:Icons.BOT} ${claim_cfg.frequency} claim cron; next run: ${job.nextDate().toRFC2822()} ***`); + }, + null, + true, + 'UTC' + ); + + console.log(`*** Claim Cron Started: ${external ? Icons.EXTERNAL:Icons.BOT} ${claim_cfg.frequency} ${cronstrue.toString(claim_cfg.frequency.cron)}; next run: ${job.nextDate().toRFC2822()} ***`); +} + +export class Claim { + private stakers: StakerRewardsAvailable[] = [] + private price: number | undefined; + private era_claims: EraClaim[] = []; + private is_prepared: boolean = false; + private _log_color: ChalkInstance; + private _prefix: string; + private static _log_color_gen = Claim.log_color_gen() + + constructor( + private readonly db: Database, + private readonly chain: Chain, + private readonly cfg: ClaimConfig, + private readonly external?: ExternalStakerConfig) { + this._log_color = Claim._log_color_gen.next().value + this._prefix = Math.floor(Math.random() * 0xFFFF).toString(16).toUpperCase() + } + + private static *log_color_gen(): Generator { + const colors = [chalk.red, chalk.green, chalk.blue] + let idx = 0; + while (true){ + yield colors[idx++%colors.length] + } + } + + public log(...msg: unknown[]){ + console.log(this._log_color(this._prefix, "\t", msg)); + } + + public static async create(db: Database, chain: Chain, cfg: ClaimConfig, external?: ExternalStakerConfig) { + const me = new Claim(db, chain, cfg, external); + return me; + } + + public async prepare(){ + // populate stakers + const stakers: Array = []; + if (this.external) { + const external_stakers = await this.external.fn(this.external.identifier, this.external.args); + stakers.push(...external_stakers); // if a ExternalStakerConfig object is provided, grab stakers from that endpoint + this.log(`Pulled ${external_stakers.length} stakers from external`) + } else { + stakers.push(...await this.db.getClaimers(this.cfg.frequency)); // get stakers from mongodb + this.log(`Pulled ${stakers.length} stakers from db`) + } + + try{ + this.price = await get_xx_price(); + this.log(`Current token price: ${this.price}`) + } catch(e) { + this.log(`Error getting xx token price: ${e}`) + } + + // query the chain to populate stakers with available rewards + // STEP 1 + this.log(`*** Claim Step 1: Querying the chain for rewards for ${pluralize(new Set(stakers.map((value)=>value.user_id)), 'claimer')} / ${pluralize(stakers, 'wallet')} ***`) + this.stakers = await this.get_available_rewards(stakers); + + // prepare a list of claims to submit with unique era/address combinations + // STEP 2 + this.log('*** Claim Step 2: Preparing claims from stakers list ***') + // Build EraClaim[] from StakerRewardsAvailable[] + this.era_claims = this.build_era_claims(this.stakers); + this.is_prepared = true; // set prepared flag + this.log(`\tPreparation of ${this.era_claims.length} claims completed`) + } + + public async submit() { + // check claim is prepped and ready to go + if (!this.is_prepared) await this.prepare(); + + // submit payout transactions + // STEP 3 + this.log(`*** Claim Step 3: Submitting ${this.era_claims.length} claims ***`) + const [claims_fulfilled, claims_failed] = await this.submit_claim(this.era_claims); + + // notify stakers + // STEP 4 + this.log("*** Claim Step 4: Notifying stakers of completed claims ***") + if (this.external){ + this.log("\tExternal stakers, skipping") + } else { + await this.notify_stakers(claims_fulfilled, claims_failed) + this.log(`\tNotified ${new Set(claims_fulfilled.flatMap( (claim) => claim.notify.map( (staker) => staker.user_id))).size} users of a payout`) + this.log(`\t${new Set(claims_failed.flatMap( (claim) => claim.notify.map( (staker) => staker.user_id))).size} users had failed payouts`) + } + // disconnect + this.log(`Disconnecting from ${this.chain.endpoint}`) + this.chain.api.disconnect(); + } + + private async get_available_rewards(stakers: Staker[]): Promise { + // Populate a list of StakerPayout objects with the eras with rewards available + + try { + // get all available rewards for all claimer wallets + const claimer_wallet_addresses: string[] = stakers.map((value) => value.wallet); + const available_eras = await this.chain.api.derive.staking.erasHistoric(); + // stakerRewardsMultiEras builds an array (one for each claimer_wallet_address) of arrays (one for each era) of DeriveStakerReward + // from https://github.com/polkadot-js/apps/blob/85c3af2055ff55a26fb77f8dd4de6d584055c579/packages/react-hooks/src/useOwnEraRewards.ts#L104 + const available_rewards = await this.chain.api.derive.staking.stakerRewardsMultiEras(claimer_wallet_addresses, available_eras); + + // plug staker rewards into staker payout items - assumes that stakerRewardsMultiEras rpc returns an array of same length & indexing as stakers + const claimer_rewards = stakers.map( (staker_payout, index) => ({ + ...staker_payout, + rewards: available_rewards[index] + })); + + // populate stakers with amount available to claim + const claimer_rewards_available = claimer_rewards.map( (staker_rewards) => ({ + ...staker_rewards, + available: staker_rewards.rewards!.reduce( (acc, current) => acc.iadd(Object.values(current.validators).reduce( (acc, current) => acc.iadd(current.value), new BN(0))), new BN(0)) + })); + + + // log summary of what rewards are available + const rewarded_claimers = available_rewards.filter( (value) => value.length) + const stash_total: BN = claimer_rewards_available.reduce( (result, { available }) => result.iadd(available!), new BN(0)); + this.log(`\tGathered rewards for ${rewarded_claimers.length} of the supplied ${pluralize(stakers, 'wallet')}`); + this.log(`\tTotal to claim: ${this.chain.xx_bal_usd_string(stash_total, this.price)})`); + + // table + // validator | eras | users + const rows = new Array(); + const validators = new Set(available_rewards.map( (value) => value.map( (value) => Object.keys(value.validators))).flat(2)); + for(const validator of validators){ + rows.push({ + validator, + eras: Array.from(new Set(available_rewards.map( (value) => value.filter( (value) => Object.keys(value.validators).includes(validator)).map( (value) => value.era.toNumber())).flat())).sort().toString(), + users: Array.from(new Set(claimer_rewards_available.map( (staker_payout) => staker_payout.rewards!.filter( (value) => Object.keys(value.validators).includes(validator)).map( (_) => staker_payout.user_id)).flat())).sort().toString(), + })} + console.table(rows) + + + return claimer_rewards_available; + } catch (e) { + this.log(e); + throw new Error("Failed getting staking rewards"); + } + } + + private async submit_claim(era_claims: EraClaim[]): Promise<[EraClaim[], EraClaim[]]> { + const claims_fulfilled = new Array() as EraClaim[]; + const claims_failed = new Array() as EraClaim[]; + + while (era_claims.length > 0) { + const payoutCalls: Array> = []; + const claims_batch = era_claims.splice(0, this.cfg.batch); //end not included + + claims_batch.forEach(({ validator, era, notify: stakers }) => { + this.log(`Adding era ${era} claim for ${validator} (stakers: ${Array.from(new Set(stakers.map( (staker) => staker.user_id))).join(", ")})`); + payoutCalls.push(this.chain.api.tx.staking.payoutStakers(validator, era)); + }); + + try { + if (payoutCalls.length > 0) { + this.log(`Batching ${payoutCalls.length} payouts:`); + const transactions = this.chain.api.tx.utility.batchAll(payoutCalls); + const { partialFee, weight } = await transactions.paymentInfo(this.cfg.wallet); + this.log(`transaction will have a weight of ${weight}, with ${partialFee.toHuman()} weight fees`); + + if (!this.cfg.dry_run) { + this.log(`Submitting ${transactions.length} in batch`) + const unsub = await transactions.signAndSend(this.cfg.wallet, { nonce: -1 }, ({ events = [], status }) => + { + this.log(`Current status is ${status.type}`); + if (status.isInBlock) { + this.log(`Transaction included at blockHash ${status.asInBlock.toHex()}`); + events.forEach(({ event: { data, method, section }, phase }) => { + this.log('\t', phase.toString(), `: ${section}.${method}`, data.toString()); + }); + } else if (status.isFinalized) { + this.log(`Transaction finalized at blockHash ${status.asFinalized.toHex()}`); + unsub(); + } + }); + } else { + this.log("Dry run; transactions not submitted"); + } + + // add the tx fee to fulfilled claims + claims_fulfilled.push(...claims_batch.map( (claim) => ({ + ...claim, + fee: partialFee.div(new BN(payoutCalls.length))}))); + + } + } catch (e) { + this.log(`Could not perform one of the claims: ${e}`); + claims_failed.push(...claims_batch); + } + } + this.log( + `\tClaimed ${claims_fulfilled.length} payouts, ${claims_failed.length} failed.` + ); + + return [claims_fulfilled, claims_failed]; + } + + private async notify_stakers(claims_fulfilled: EraClaim[], claims_failed: EraClaim[]): Promise { + + function eraclaim_to_stakernotify(claims: EraClaim[]): Map> { + // convert EraClaim[] to Map> + const claims_notify = new Map>() + claims.map( ({era, validator, notify, fee}) => { + notify.map( ({user_id, wallet, alias, rewards, available}) => { + claims_notify.has(user_id) || claims_notify.set(user_id, new Map()) + claims_notify.get(user_id)!.has(wallet) || claims_notify.get(user_id)!.set(wallet, []) + const reward = rewards.find( (reward) => reward.era.toNumber() === era)! + const staker_notify: StakerNotify = { + user_id: user_id, + wallet: wallet, + alias: alias, + era: era, + payout: Object.values(reward.validators).reduce( (acc, val) => acc.iadd(val.value), new BN(0)), + isValidator: reward.isValidator, + validators: Object.keys(reward.validators), + fee: fee?.divn(notify.length) // this further divids the fee by the number of claimers + } + claims_notify.get(user_id)!.get(wallet)?.push(staker_notify) + }) + }) + return claims_notify; + } + + const claims_fulfilled_notify = eraclaim_to_stakernotify(claims_fulfilled) + const claims_failed_notify = eraclaim_to_stakernotify(claims_failed) + const claim_wallet_bal = await this.chain.wallet_balance(this.cfg.wallet); + + + for(const [user_id, stakernotify_by_wallet] of claims_fulfilled_notify) { + // Send a notification to the user + const data: NotifyData = { + id: user_id, + msg: await this.notify_user_message(stakernotify_by_wallet, claim_wallet_bal, true), + } + PubSub.publish(XXEvent.CLAIM_EXECUTED, data) + } + + for(const [user_id, stakernotify_by_wallet] of claims_failed_notify) { + // Send a notification to the user + const data: NotifyData = { + id: user_id, + msg: await this.notify_user_message(stakernotify_by_wallet, claim_wallet_bal, false) + } + PubSub.publish(XXEvent.CLAIM_FAILED, data) + } + } + + private async notify_user_message(claims: Map, claim_wallet_bal: BN, success: boolean = true) : Promise { + const retrows = new Array(); + + // header is always the same + const eras = Array.from(new Set([ ...claims.values() ].flat().map( (claim_notify) => claim_notify.era))).sort() + const wallets = Array.from(claims.keys()) + const _total: BN = [ ...claims.values() ].flat().reduce( (acc, val) => val.payout.add(acc), new BN(0)); + const _total_string = `${this.chain.xx_bal_usd_string(_total, this.price)}`; + retrows.push(`${success ? `${this.cfg.frequency.symbol} claim results: ${_total_string}` : 'failed '}: ${pluralize(eras, 'era')} | ${pluralize(wallets, 'wallet')}`); + + // msg format + // Claimed rewards xx/$ for x eras / x wallets (tx xx/$) + // alias / xxxxxx: + // Era xxx: xx/$ as validator|nominator of xxxxx + const codeblock = new Array(); + claims.forEach( (stakers_notify, wallet) => { + // build the top wallet string: alias / xxxxxx: + const alias: string | undefined | null = stakers_notify.find( (claim_notify) => Boolean(claim_notify.alias) )?.alias; + codeblock.push(`${Icons.WALLET} ${prettify_address_alias(alias, wallet, false, 30)}:`); + + stakers_notify.forEach( (staker_notify) => { + // build the era line: Era xxx: xx + const _nominator_string = staker_notify.isValidator ? "" : `${Icons.NOMINATOR}⭆${Icons.VALIDATOR} ${staker_notify.validators.map( (validator) => prettify_address_alias(null, validator, false, 9)).join(", ")}`; + const _val_nom_info = `as ${staker_notify.isValidator ? Icons.VALIDATOR : _nominator_string}` + codeblock.push(` Era ${staker_notify.era}: ${this.chain.xx_bal_usd_string(staker_notify.payout, this.price)} ${_val_nom_info}`); + }); + }); + + const _total_fee: BN = [ ...claims.values() ].flat().reduce( (acc, val) => acc.add(val.fee ?? new BN(0)), new BN(0)); + codeblock.push(""); + codeblock.push(` Fee: ${this.chain.xx_bal_string(_total_fee)} of ${this.chain.xx_bal_string(claim_wallet_bal)} in ${Icons.BOT} wallet`) + if (claim_wallet_bal.lt(new BN(10000*(10**this.chain.decimals)))) codeblock.push(` To support this bot, type /donate`) // print donate pitch if wallet is < 10000 xx + codeblock.push(""); + + codeblock.push(ClaimLegend); + + retrows.push(spoiler(codeBlock(codeblock.join('\n')))) + return retrows; + } + + private build_era_claims(stakers: StakerRewardsAvailable[]): EraClaim[] { + // fugly approach but easiest to manage complexity for now + + // era validator + const era_claims_map = new Map>(); + stakers.forEach( (staker) => { + staker.rewards.forEach( (reward) => { + const e = reward.era.toNumber(); + const validator = Object.keys(reward.validators)[0]; // not sure if there are ever multiple validators + + // Create new map for the current era/address/id if it doesn't exist + era_claims_map.has(e) || era_claims_map.set(e, new Map()); + era_claims_map.get(e)!.has(validator) || era_claims_map.get(e)!.set(validator, []); + era_claims_map.get(e)!.get(validator)!.push(staker); + }); + }); + + // era_claims_map: era/validator/user_id/wallet:StakerNotify + const era_claims: EraClaim[] = []; + era_claims_map.forEach((validators, era) => { + validators.forEach((stakers, validator) => { + era_claims.push({ + era, + validator, + notify: stakers, + }); + }); + }); + + return era_claims; + } +} + +export const ClaimLegend: string = `Key: ${Icons.WALLET}=wallet, ${Icons.NOMINATOR}=nominator, ${Icons.VALIDATOR}=validator`; \ No newline at end of file diff --git a/src/chain/index.ts b/src/chain/index.ts new file mode 100755 index 0000000..8d3b97b --- /dev/null +++ b/src/chain/index.ts @@ -0,0 +1,200 @@ +import "@xxnetwork/types"; +import custom from "../custom-derives/index.js"; +import { decodeAddress, encodeAddress } from '@polkadot/keyring'; +import { hexToU8a, isHex, formatBalance } from '@polkadot/util'; +import { ApiPromise, WsProvider, Keyring } from "@polkadot/api"; +import { wait } from "../utils.js"; +import { cmix_id_b64 } from "../cmix/index.js"; +import { XXEvent } from "../events/types.js"; +import PubSub from 'pubsub-js'; + +import type { KeyringPair, KeyringPair$Json, KeyringOptions } from "@polkadot/keyring/types"; +import type { BN } from "@polkadot/util"; +import type { Balance, Era } from "@polkadot/types/interfaces/types.js"; +import type { PalletStakingValidatorPrefs, } from "@polkadot/types/lookup"; +import type { CommissionChange } from "./types.js"; + +const XX_SS58_PREFIX = 55; + +export function isValidXXAddress(address: string) : boolean { + try { + encodeAddress( + isHex(address) + ? hexToU8a(address) + : decodeAddress(address, false, XX_SS58_PREFIX) + ); + + return true; + } catch (error) { + return false; + } +}; + +export async function startListeningCommission(rpc: string) { + (await Chain.create(rpc)).subscribe_commission_change( (change) => { + PubSub.publish(XXEvent.VALIDATOR_COMMISSION_CHANGE, change) + }); +} + +export class Chain{ + public endpoint: string; + public api!: ApiPromise; + public decimals: number = 9; + + constructor(endpoint: string) { + this.endpoint = endpoint; + } + + public async connect(): Promise { + const provider = new WsProvider(this.endpoint); + const options = { + derives: custom, + provider: provider, + throwOnConnect: true, + } + const api = await ApiPromise.create(options); + await api.isReady; + + this.decimals = api.registry.chainDecimals[0]; + + // ensure chain is syncronized; from https://github.com/xx-labs/exchange-integration/blob/a027526819fdcfd4145fd45b7ceeeaaf371ebcf2/detect-transfers/index.js#L33 + while((await api.rpc.system.health()).isSyncing.isTrue){ + const sec = 5; + console.log(`Chain is syncing, waiting ${sec} seconds`); + await wait(sec*1000); + } + + const [chain, nodeName, nodeVersion, era] = await Promise.all([ + api.rpc.system.chain(), + api.rpc.system.name(), + api.rpc.system.version(), + api.query.staking.activeEra() + ]); + + console.log(`Connected to chain ${chain} using ${nodeName} v${nodeVersion}, era: ${(era.toJSON() as unknown as Era).index}`); + + this.api = api; + } + + public static async create(endpoint: string) { + const me = new Chain(endpoint); + await me.connect(); + return me; + } + + public static async test(endpoint: string): Promise { + try { + console.log(`Testing rpc connection to ${endpoint}...`) + const me = await Chain.create(endpoint); + console.log(`Connection successful`) + await me.api!.disconnect() + } catch (e) { + console.log(`Could not connect to endpoint ${endpoint}: ${e}`) + return false; + } + return true; + } + + public async wallet_balance(wallet: string | KeyringPair): Promise { + let address: string; + if (typeof wallet !== 'string') address = wallet.address; + else address = wallet; + + const { data: balance } = await this.api.query.system.account(address) + return balance.free + } + + public async subscribe_commission_change(callbackfn: (change: CommissionChange) => void){ + // https://polkadot.js.org/docs/api/cookbook/blocks/ + console.log('Listening for commission changes'); + + this.api.rpc.chain.subscribeFinalizedHeads(async (header) => { + const blockNumber = header.number.toNumber(); + const blockHash = await this.api.rpc.chain.getBlockHash(blockNumber); + const signedBlock = await this.api.rpc.chain.getBlock(blockHash); + + for(const [index, extr] of signedBlock.block.extrinsics.entries()){ + if (this.api.tx.staking.validate.is(extr)) { + const { method: { args } } = extr; + const arg = (args as [PalletStakingValidatorPrefs]).find((a) => a.has('commission')) // check that extrinsics args has 'commission' + if (arg) { + // check if event is a successful extrinsic + const apiAt = await this.api.at(signedBlock.block.header.hash); + const events = await apiAt.query.system.events(); + for(const { event } of events.filter(({ phase }) => phase.isApplyExtrinsic && phase.asApplyExtrinsic.eq(index))) { + if (this.api.events.system.ExtrinsicSuccess.is(event)) { + // this was a successful commision change + const { cmixId } = (await apiAt.query.staking.ledger(extr.signer.toString())).unwrap(); // get ledger/cmixid of the signer + const change: CommissionChange = { + wallet: extr.signer.toString(), + cmix_id: cmix_id_b64(cmixId.unwrap().toU8a()), + commission: arg.commission.unwrap().toNumber(), + commission_previous: await this.get_commission(extr.signer.toString(), blockNumber-1), + chain_decimals: this.decimals + } + callbackfn(change); + } + } + } + } + } + }); + } + + private async get_commission(validator: string, block?: number): Promise { + if (block) { + const blockHash = await this.api.rpc.chain.getBlockHash(block); + const apiAt = await this.api.at(blockHash); + const { commission } = await apiAt.query.staking.validators(validator); + return commission.toNumber(); + } else { + const { commission } = await this.api.query.staking.validators(validator); + return commission.toNumber(); + } + } + + public xx_bal_string(xx: number | bigint | BN | Balance, sig_digits: number = 2): string { + formatBalance.setDefaults({ decimals: this.decimals, unit: 'xx'}) + const balfor = formatBalance(xx) + const [num, unit] = balfor.split(' '); + const [int, frac] = num.split('.'); + const frac_short = frac?.slice(0,sig_digits) ?? '' + return `${int}${frac_short ? `.${frac_short}` : ''} ${unit}` + } + + public xx_bal_usd_string(xx: BN, price: number | undefined): string { + return `${this.xx_bal_string(xx)}${price ? ` (${Chain.xx_to_usd(xx, price)})` : ''}` + } + + public static xx_to_usd(xx: BN, price: number): string { + const usd: number = (xx.toNumber() * price) / 1000000000; + const usd_formatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }); + return usd_formatter.format(usd); + } + + public static init_key(key: KeyringPair$Json, password: string): KeyringPair { + const keyring_options: KeyringOptions = { + ss58Format: XX_SS58_PREFIX, + type: "sr25519" + } + const keyring = new Keyring(keyring_options); + const key_pair = keyring.addFromJson(key); + key_pair.decodePkcs8(password); + + // console.log(`key init: read account with address: ${keyring.pairs[0].toJson().address}`); + + if (key_pair.isLocked) { + throw new Error(`Could not unlock the wallet: ${keyring.pairs[0].toJson().address}`); + } + + return key_pair; + } + + public static commissionToHuman(commission: number, decimals: number): string { + return `${(100 * commission/10**decimals).toFixed(2)}%`; + } + +}; diff --git a/src/chain/types.ts b/src/chain/types.ts new file mode 100644 index 0000000..d6eb7fe --- /dev/null +++ b/src/chain/types.ts @@ -0,0 +1,86 @@ +import type { DeriveStakerReward } from "@polkadot/api-derive/types"; +import type { BN } from "@polkadot/util"; +import type { KeyringPair } from "@polkadot/keyring/types"; + +export class ClaimFrequency { // from https://stackoverflow.com/a/51398471/1486966 + static readonly DAILY = new ClaimFrequency('daily', ''); + static readonly WEEKLY = new ClaimFrequency('weekly', ''); + static readonly IMMEDIATE = new ClaimFrequency('immediate', ''); + private _cron: string = ''; + + // private to disallow creating other instances of this type + private constructor(private readonly key: string, public readonly symbol: string) { + this.symbol = `${key.charAt(0).toUpperCase()}${key.slice(1)}${symbol ? ` ${symbol}` : ''}` // e.g., Daily ☀️ + } + + public set cron(cron: string) { + this._cron = cron; + } + + public get cron(){ + return this._cron; + } + + toString() { return this.key; } +} + +export interface ExternalStaker { + // records from external staker source + wallet: string; + ip: string; +} + +export interface Staker { + // used to manage user-staker claim subscriptions + user_id: string; + wallet: string; + alias?: string | null; +} + +export interface StakerRewards extends Staker { + // associates available rewards for a staker + rewards: DeriveStakerReward[]; +} + +export interface StakerRewardsAvailable extends StakerRewards { + // total available tokens for staker + available: BN; +} + +export interface EraClaim { + // Used for executing the claim + era: number; + validator: string; + notify: StakerRewardsAvailable[]; // all of the claimers for this era/validator, indexed by user_id and wallet + fee?: BN; +} + +export interface StakerNotify extends Staker { + // everything needed to notify a user of claims made on their behalf + era: number; + payout: BN; + isValidator: boolean; + validators: string[]; + fee?: BN; +} + +export interface ClaimConfig { + frequency: ClaimFrequency, + batch: number, + wallet: KeyringPair, + dry_run?: boolean +} + +export interface ExternalStakerConfig { + fn: Function, + identifier: string, + args: {[key: string]: any} +} + +export interface CommissionChange { + wallet: string, + cmix_id: string, + commission: number, + commission_previous: number, + chain_decimals: number, +} diff --git a/src/cmix/index.ts b/src/cmix/index.ts index 6dcde1b..3a109a3 100644 --- a/src/cmix/index.ts +++ b/src/cmix/index.ts @@ -1,75 +1,102 @@ import { CronJob } from "cron"; import { Database } from "../db/index.js"; -import { dmStatusChange } from "../messager.js"; -import { StatusCmix } from "../db/index.js"; -import type { Client } from "discord.js"; -import type { CmixNode } from "./types.js"; -import type { MonitorRecord } from "../db/types.js"; +import { Icons, prettify_address_alias } from "../utils.js"; +import { StatusCmix, Status, StatusIcon } from "./types.js"; +import { inlineCode, italic, spoiler } from "discord.js"; +import { NotifyData, XXEvent } from "../events/types.js"; +import cronstrue from "cronstrue"; +import PubSub from 'pubsub-js'; -// Polls the dashboard API and gets the entire list of nodes every ENDPOINT_POLLING seconds +import type { CmixNode } from './types.js' -const endpoint: string = process.env.ENDPOINT!; -const endpoint_retries = process.env.ENDPOINT_RETRIES; -const cmix_poll_cron: string = process.env.ENDPOINT_CRON!; -const timezone: string = process.env.TZ!; +// Polls the dashboard API and gets the entire list of nodes per the CMIX_CRON schedule -export async function startPolling(db: Database, client: Client) { - console.log("*** API Polling Started ***"); - - new CronJob( - cmix_poll_cron, +export async function startPolling(db: Database, api_endpoint: string, cmix_cron: string) { + const job = new CronJob( + cmix_cron, function () { - poll(db, client); + poll(db, api_endpoint); }, null, true, - timezone + 'UTC' ); -} -async function poll(db: Database, client: Client) { - const response: Response = await fetch(endpoint, { - headers: { accept: "application/json; charset=utf-8" }, - }); - const results = await response.json(); - if (response.status !== 200) { - console.log(`non-200 response:\nresponse: ${response}\nbody: ${results}}`); - } else { - // Process the results - console.log(`parsing ${results.nodes.length} nodes`); - - // step through each node result and send its status to the monitoring db - results.nodes.forEach(async (node: CmixNode) => { - const name: string = node.name; - const status = node.status as keyof typeof StatusCmix; - const status_new: StatusCmix = node.status - ? StatusCmix[status] - : StatusCmix.unknown; // when status is an empty string, status is Status.UNKNOWN - const node_id: string = node.id; - const changed: Date = new Date(); + console.log(`*** cMix Cron Started: ${cronstrue.toString(cmix_cron)}; next run: ${job.nextDate().toRFC2822()} ***`); +} - // update database with new name, as appropriate - if (node.name) { - db.updateNodeName(node_id, name); - } +async function poll(db: Database, api_endpoint: string) { + try { + const response: Response = await fetch(api_endpoint, { + headers: { accept: "application/json; charset=utf-8" }, + }); + const results = await response.json() as {nodes: CmixNode[]}; + if (response.status !== 200) { + console.log(`Non-200 response:\nresponse: ${response}\nbody: ${results}}`); + } else { + // Process the results + console.log(`${Icons.CMIX} Parsing ${results.nodes.length} cMix nodes`); + + // step through each node result and send its status to the monitoring db + results.nodes.forEach(async (node) => { + const name = node.name; + const status_new: string = node.status ? + StatusCmix[node.status as keyof typeof StatusCmix] : + StatusCmix.unknown; // when status is an empty string, status is Status.UNKNOWN + + // update database with new name, as appropriate + if (node.name) { + const monitor_results = await db.updateNodeName(node.id, name); + monitor_results.length && console.log(`Notifying ${monitor_results.length} monitor of node ${node.id} of name change to ${node.name}`); + for(const record of monitor_results){ + const retrows = new Array(); + retrows.push(`${Icons.UPDATE} cMix node ${prettify_address_alias(null, node.id, true)} name updated: ${inlineCode(record.name ? record.name : 'empty')}${Icons.TRANSIT}${inlineCode(node.name)}`) + retrows.push(`${Icons.UPDATE} ${spoiler(`Use command ${inlineCode('/monitor add')} to set your own alias and stop name updates from the dashboard`)}`) + const data: NotifyData = { + id: record.user, + msg: retrows, + } + PubSub.publish(XXEvent.VALIDATOR_NAME_CHANGE, data) + } - // update database with new status - var result: MonitorRecord[] | undefined = await db.updateNodeStatus( - node.id, - status_new, - changed - ); + const claim_results = await db.updateClaimAlias(node.walletAddress, name); + claim_results.length && console.log(`Notifying ${claim_results.length} claimers of validator ${node.walletAddress} of name change to ${node.name}`); + for(const record of claim_results){ + const retrows = new Array(); + retrows.push(`${Icons.UPDATE} Validator ${prettify_address_alias(null, node.walletAddress, true, 48)} alias updated: ${inlineCode(record.name ? record.name : 'empty')}${Icons.TRANSIT}${inlineCode(node.name)}`) + retrows.push(`${Icons.UPDATE} ${spoiler(inlineCode(`Use command /claim to set your own alias and stop name updates from the dashboard`))}`) + const data: NotifyData = { + id: record.user, + msg: retrows, + } + PubSub.publish(XXEvent.VALIDATOR_NAME_CHANGE, data) + } + } - // notify users of status change - if (result) { - console.log( - `notifying ${result.length} users of node ${node_id} status change to ${status_new} at ${changed}` - ); - result.forEach(async (entry) => { + // update database with new status + const status_results = await db.updateNodeStatus(node.id, status_new); + status_results.length && console.log(`Notifying ${status_results.length} monitor of node ${node.id} of status change to ${status_new}`); + for(const record of status_results) { + console.log(record) // Send a notification to the user - dmStatusChange(client, entry, status_new); - }); - } - }); + var message = `${StatusIcon[record.status.toUpperCase() as keyof typeof Status]} ${Icons.TRANSIT} ${StatusIcon[status_new.toUpperCase() as keyof typeof Status]}`; // old -> new status icon + message += ` ${prettify_address_alias(record.name, record.node)} is now ${status_new == Status.ERROR ? "in " : ""}${italic(status_new)}`; // new status + const data: NotifyData = { + id: record.user, + msg: message, + } + console.log('pubing this sub') + console.log(data) + PubSub.publish(XXEvent.VALIDATOR_STATUS_CHANGE, data) + } + }); + } + } catch(e) { + console.log(`Error during cmix poll: ${e}`) } + } + +export function cmix_id_b64(id: Uint8Array): string { + return Buffer.from(id).toString('base64').replace('=', 'C'); +} \ No newline at end of file diff --git a/src/cmix/types.ts b/src/cmix/types.ts index 9e661fe..9c40c6b 100644 --- a/src/cmix/types.ts +++ b/src/cmix/types.ts @@ -25,3 +25,27 @@ export interface CmixNode { whois: string; walletAddress: string; } + +export enum StatusIcon { + ONLINE = "🟢", + OFFLINE = "🔴", + ERROR = "⛔", + UNELECTED = "⬇️", + UNKNOWN = "❓", +} + +export enum Status { + ONLINE = "online", + OFFLINE = "offline", + ERROR = "error", + UNELECTED = "unelected", + UNKNOWN = "unknown", +} + +export enum StatusCmix { + "online" = Status.ONLINE, + "offline" = Status.OFFLINE, + "error" = Status.ERROR, + "not currently a validator" = Status.UNELECTED, + "unknown" = Status.UNKNOWN, +} \ No newline at end of file diff --git a/src/custom-derives/staking/index.ts b/src/custom-derives/staking/index.ts index 6fe4f66..0ae39fc 100644 --- a/src/custom-derives/staking/index.ts +++ b/src/custom-derives/staking/index.ts @@ -3,4 +3,4 @@ // from https://github.com/xx-labs/web-wallet/blob/fcdcd4f5ddeeb69c272e4550fde3353ecd0328b7/packages/custom-derives/src/staking/index.ts -export * from "./stakerRewards"; +export * from "./stakerRewards.js"; diff --git a/src/db/index.ts b/src/db/index.ts index 9b4854e..8a819af 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,34 +1,35 @@ +import { MongoClient } from "mongodb"; +import { ClaimFrequency, Staker } from "../chain/types.js"; +import { Status } from "../cmix/types.js"; + import type { - Document, Filter, FindOptions, InsertOneResult, UpdateResult, WithId, - OptionalId, UpdateFilter, DeleteResult, Db, Collection, } from "mongodb"; -import type { LogActionRecord, MonitorRecord } from "./types.js"; -import { Status } from "./types.js"; -import { MongoClient } from "mongodb"; +import type { ClaimRecord, LogActionRecord, MonitorRecord, RecordUpdate } from "./types.js"; -export { Status, StatusIcon, StatusCmix } from "./types.js"; export class Database { private client: MongoClient; private db: Db; - private mainnet: Collection; + private monitor_state: Collection; + private claims: Collection; private stats: Db; - private actions: Collection; + private actions: Collection; constructor(client: MongoClient) { // Initialize mongodb this.client = client; this.db = this.client.db("xx"); - this.mainnet = this.db.collection("mainnet"); + this.monitor_state = this.db.collection("mainnet"); + this.claims = this.db.collection("claims"); this.stats = this.client.db("stats"); this.actions = this.stats.collection("actions"); } @@ -43,7 +44,7 @@ export class Database { user_id: string, action: string, data: string - ): Promise> { + ): Promise> { // Add a record for an action taken by a user const new_doc: LogActionRecord = { @@ -52,45 +53,41 @@ export class Database { action: action, data: data, }; - const options: FindOptions = {}; - const result: InsertOneResult = await this.actions.insertOne( + const options: FindOptions = {}; + const result: InsertOneResult = await this.actions.insertOne( new_doc ); return result; } - public async addNode( - user_id: string, - node_id: string, - node_name: string | null - ): Promise { + public async addNode(user_id: string, node_id: string, node_name: string | null): Promise | UpdateResult | undefined> { // Add a node to the monitered node list // check if user is already monitoring this node - const query: Filter = { + const query: Filter = { user: user_id, node: node_id, }; - const options: FindOptions = {}; - const result: WithId | null = await this.mainnet.findOne( - query, - options - ); + const options: FindOptions = {}; + const result: WithId | null = await this.monitor_state.findOne(query, options); + + // If result isn't null, user is already monitoring this node + // check if node name is set and the same if (result) { - // User is already monitoring this node - // check if node name is set and the same - if (node_name && node_name !== result.name) { + if (node_name && node_name !== result.name){ // update node name - const update: Partial = { + const update: Partial = { $set: { name: node_name, user_set_name: true, }, }; - return await this.mainnet.updateOne(query, update); + return await this.monitor_state.updateOne(query, update); } - return false; - } else { + return undefined; + } + else { + // user isn't monitoring this node yet const new_doc: MonitorRecord = { user: user_id, node: node_id, @@ -99,93 +96,283 @@ export class Database { status: Status.UNKNOWN, changed: null, }; - return await this.mainnet.insertOne(new_doc); + return await this.monitor_state.insertOne(new_doc); } } - public async updateNodeStatus( - node_id: string, - status: string, - changed: Date - ): Promise { - // notify any users monitoring the provided node of a status change + public async addClaim(user_id: string, frequency: string, wallet: string, alias: string | null): Promise | null> { + // Add a node to the monitered node list + const updates = new Array(); + + // check if user is already subscribed to claims for this + const query: Filter = { + user: user_id, + wallet: wallet, + }; + const options: FindOptions = {}; + const result: WithId | null = await this.claims.findOne( + query, + options + ); + if (result) { + // User is already subscribed for this wallet + + const update: Partial = {$set: {}}; + // check if node name is set and the same + if (alias && alias !== result.alias) { + // update node name + update.$set = { + alias: alias, + user_set_alias: true} + updates.push({ + key: "name", + old: result.alias ? result.alias : "empty", + new: alias + }) + } + if (frequency !== result.frequency) { + // update interval + update.$set.frequency = frequency; + updates.push({ + key: "interval", + old: result.frequency, + new: frequency, + }) + } + + if (updates.length) { + if (!await this.claims.updateOne(query, update)) throw new Error(`Could not update: query (${query}), update (${update})`); + return updates; + } else { + return null; // record already exists in its current state + } + + } else { + const new_doc: ClaimRecord = { + user: user_id, + frequency: frequency, + wallet: wallet, + alias: alias, + user_set_alias: Boolean(alias) + }; + if (!await this.claims.insertOne(new_doc)) throw new Error(`Could not update: doc (${new_doc})`); + return updates; + } + } - const query: Filter = { + public async updateNodeStatus(node_id: string, status: string): Promise { + // notify any users monitoring the provided node of a status change + const query: Filter = { node: node_id, status: { $ne: status, }, }; - const options: FindOptions = { + const options: FindOptions = { projection: { _id: false, }, }; - const result: WithId[] = await this.mainnet - .find(query, options) - .toArray(); + const result: MonitorRecord[] = await this.monitor_state.find(query, options).toArray(); if (result.length) { // update the value in the database - const update: UpdateFilter = { + const update: UpdateFilter = { $set: { status: status, - changed: changed, + changed: new Date(), }, }; - this.mainnet.updateMany(query, update); + this.monitor_state.updateMany(query, update); + } + return result + } + + public async updateNodeCommission(node_id: string, commission: number): Promise { + // notify any users monitoring the provided node of a status change + const query: Filter = { + node: node_id, + commission: { + $ne: commission, + }, + }; + const options: FindOptions = { + projection: { + _id: false, + }, + }; + const result: MonitorRecord[] = await this.monitor_state.find(query, options).toArray(); - return result as MonitorRecord[]; + if (result.length) { + // update the value in the database + const update: UpdateFilter = { + $set: { + commission: commission, + commission_changed: new Date(), + }, + }; + this.monitor_state.updateMany(query, update); } + return result; } - public async updateNodeName(node_id: string, new_name: string): Promise { + public async updateNodeName(node_id: string, new_name: string): Promise { // update all nodes with the new name, where user_set_name = false - const query: Filter = { + const query: Filter = { node: node_id, - user_set_name: { + user_set_name: { // only update records where the user hasn't set a name themselves + $ne: true, + }, + name: { + $ne: new_name, // only update records where the name is different + } + }; + const options: FindOptions = { + projection: { + _id: false, + }, + }; + const result: MonitorRecord[] = await this.monitor_state.find(query, options).toArray(); + + if (result.length) { + // update the value in the database + const update: UpdateFilter = { + $set: { + name: new_name, + }, + }; + this.monitor_state.updateMany(query, update); + } + return result + } + + + public async updateClaimAlias(wallet: string, new_alias: string): Promise { + // update all claims with the new alias, where user_set_alias = false + + const query: Filter = { + wallet: wallet, + user_set_alias: { $ne: true, }, + alias: { + $ne: new_alias, + } }; - const update: UpdateFilter = { - $set: { - name: new_name, - user_set_name: false, + const options: FindOptions = { + projection: { + _id: false, }, }; - return this.mainnet.updateMany(query, update); + + const result: ClaimRecord[] = await this.claims.find(query, options).toArray(); + if (result.length) { + // update the value in the database + const update: UpdateFilter = { + $set: { + alias: new_alias, + }, + }; + this.claims.updateMany(query, update); + } + return result } - public async listUserNodes(user_id: string): Promise[]> { + public async listUserNodes(user_id: string): Promise { // Get list of user's subscriptions - const query: Filter = { + const query: Filter = { user: user_id, }; - const options: FindOptions = { + const options: FindOptions = { projection: { _id: false, }, }; - return await this.mainnet.find(query, options).toArray(); + return await this.monitor_state.find(query, options).toArray(); } - public async deleteNode( - user_id: string, - node_id: string - ): Promise<[DeleteResult, WithId[]]> { + public async listUserClaimsFlat(user_id: string): Promise { + // Get list of user's subscriptions + + const query: Filter = { + user: user_id, + }; + const options: FindOptions = { + projection: { + _id: false, + }, + }; + return await this.claims.find(query, options).toArray(); + } + + public async listUserClaims(user_id: string): Promise> { + // Get list of user's subscriptions + + const claims_map = new Map(); + + for(const frequency of Object.values(ClaimFrequency)){ + const query: Filter = { + user: user_id, + frequency: frequency.key + }; + const options: FindOptions = { + projection: { + _id: false, + }, + }; + const results = await this.claims.find(query, options).toArray(); + if (results.length) claims_map.set(frequency.key, results); + } + return claims_map; + } + + public async deleteNode(user_id: string, node_id: string): Promise<[DeleteResult, WithId[]]> { // Delete the given node from the user record. - const query: Filter = { + const query: Filter = { user: user_id, node: node_id, }; - const options: FindOptions = {}; - const deleted: WithId[] = await this.mainnet - .find(query, options) - .toArray(); - const result: DeleteResult = await this.mainnet.deleteMany(query, options); + const options: FindOptions = {}; + const deleted: WithId[] = await this.monitor_state.find(query, options).toArray(); + const result: DeleteResult = await this.monitor_state.deleteMany(query, options); + return [result, deleted]; + } + + public async deleteClaim( + user_id: string, + wallet: string + ): Promise<[DeleteResult, WithId[]]> { + // Delete the given node from the user record. + + const query: Filter = { + user: user_id, + wallet: wallet, + }; + const options: FindOptions = {}; + const deleted: WithId[] = await this.claims.find(query, options).toArray(); + const result: DeleteResult = await this.claims.deleteMany(query, options); return [result, deleted]; } -} + + public async getClaimers(claim_frequency: ClaimFrequency): Promise { + // Get all claimers for a certain frequency + + const query: Filter = claim_frequency !== ClaimFrequency.IMMEDIATE ? { + frequency: claim_frequency.toString(), + } : {}; + const options: FindOptions = { + projection: { + _id: false, + }, + }; + const db_claimers = await this.claims.find(query, options).toArray(); + return db_claimers.map( (value): Staker => ({ + user_id: value.user, + wallet: value.wallet, + alias: value.alias + })); + } +} \ No newline at end of file diff --git a/src/db/types.ts b/src/db/types.ts index e1764ca..3f614eb 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -1,27 +1,20 @@ import type { Document, OptionalId } from "mongodb"; +import type { BN } from "@polkadot/util"; -export enum Status { - ONLINE = "online", - OFFLINE = "offline", - ERROR = "error", - UNELECTED = "unelected", - UNKNOWN = "unknown", +export interface RecordUpdate { + key: string, + old: string, + new: string } -export enum StatusIcon { - ONLINE = "🟢", - OFFLINE = "🔴", - ERROR = "⛔", - UNELECTED = "⬇️", - UNKNOWN = "❓", -} - -export enum StatusCmix { - "online" = Status.ONLINE, - "offline" = Status.OFFLINE, - "error" = Status.ERROR, - "not currently a validator" = Status.UNELECTED, - "unknown" = Status.UNKNOWN, +export interface ClaimRecord extends OptionalId { + user: string; // discord_id + wallet: string; // wallet address + frequency: string; // how often to claim + alias?: string | null; // wallet name + user_set_alias: boolean; // true if user set the name, false otherwise + last_claim?: Date | null; // timestamp of last claim + last_amount?: BN | null; // last claim amount } export interface MonitorRecord extends OptionalId { @@ -31,6 +24,8 @@ export interface MonitorRecord extends OptionalId { user_set_name: boolean; // true if user set the name, false otherwise status: string; // based on status object below, changed: Date | null; // timestamp of last state change, + commission?: number; + commission_changed?: Date } export interface LogActionRecord extends OptionalId { diff --git a/src/discord/commands/claim.ts b/src/discord/commands/claim.ts new file mode 100755 index 0000000..e223bab --- /dev/null +++ b/src/discord/commands/claim.ts @@ -0,0 +1,247 @@ +import moment from "moment"; +import { SlashCommandBuilder, DiscordAPIError } from "discord.js"; +import { prettify_address_alias, Icons, engulph_fetch_claimers, EXTERNAL } from "../../utils.js"; +import { Chain, isValidXXAddress } from "../../chain/index.js"; +import { ClaimRecord } from "../../db/types.js"; +import { ClaimConfig, ClaimFrequency, ExternalStakerConfig } from "../../chain/types.js"; +import { Claim } from "../../chain/claim.js"; + +import type { DeleteResult, WithId } from "mongodb"; +import type { Database } from "../../db/index.js"; +import type { AutocompleteInteraction, ChatInputCommandInteraction } from "discord.js"; +import type { KeyringPair$Json } from "@polkadot/keyring/types"; + +// env guard +import '../../env-guard/claim.js'; +import '../../env-guard/discord.js'; + +export const data = new SlashCommandBuilder() + .setName("claim") + .setDescription("Claim rewards for a validator or nominator wallet") + .addSubcommand(subcommand => + subcommand + .setName('daily') + .setDescription('Subscribe to daily payouts') + .addStringOption((option) => + option + .setName("wallet") + .setDescription("The wallet to payout") + .setRequired(true) + .setMaxLength(48) + .setMinLength(47) + .setAutocomplete(true)) + .addStringOption((option) => + option + .setName("name") + .setDescription("A friendly name for the wallet"))) + +if (process.env.CLAIM_CRON_WEEKLY) { // add a /claim weekly subcommand + data.addSubcommand(subcommand => + subcommand + .setName('weekly') + .setDescription('Subscribe to weekly payouts') + .addStringOption((option) => + option + .setName("wallet") + .setDescription("The wallet to payout") + .setRequired(true) + .setMaxLength(48) + .setMinLength(47) + .setAutocomplete(true)) + .addStringOption((option) => + option + .setName("name") + .setDescription("A friendly name for the wallet"))) +} +if (process.env.NODE_ENV === "development") { // add a /claim now subcommand when in dev mode + data.addSubcommand(subcommand => + subcommand + .setName('now') + .setDescription('Development command')) +} + +// continue adding so order is preserved +data .addSubcommand(subcommand => + subcommand + .setName('list') + .setDescription('List subscribed claim wallets')) + .addSubcommand(subcommand => + subcommand + .setName('remove') + .setDescription('Unsubscribe a wallet') + .addStringOption((option) => + option + .setName("wallet") + .setDescription("The wallet to unsubscribe") + .setRequired(true) + .setMaxLength(48) + .setMinLength(47) + .setAutocomplete(true))); + + + + +export async function execute(interaction: ChatInputCommandInteraction, db: Database) { + const subcommand = interaction.options.getSubcommand(); + const user = interaction.user; + const channel = interaction.channel + ? interaction.channel + : await interaction.client.channels.fetch(interaction.channelId); + const eph = channel ? (!channel.isDMBased() ? true : false) : false; // make the message ephemeral / visible only to user if not in dm + let reply_string = ``; + + switch (subcommand) { + + case "daily": + case "weekly": { + const wallet : string = interaction.options.getString("wallet", true); + const wallet_name = interaction.options.getString("name", false); + if (!isValidXXAddress(wallet)) { + reply_string = "Not a valid xx wallet address"; + break; + } + const updates = await db.addClaim(user.id, subcommand, wallet, wallet_name); // returns empty array if new record, or array of updates if not + + if (updates === null) { + // User is already monitoring this wallet as-is + reply_string = `${Icons.ERROR} Error: You are already subscribed to ${subcommand} payouts for ${prettify_address_alias(wallet_name, wallet)}.`; + } + else if (updates.length) { + const _replies = new Array(); + updates.forEach( ( value ) => { + _replies.push(`${Icons.SUCCESS} Updated ${value.key} from \`${value.old}\` to \`${value.new}\``); + }); + reply_string += _replies.join('\n'); + } else { + const subscribed = `${Icons.WATCH} Subscribed to ${subcommand} payouts for ${prettify_address_alias(wallet_name, wallet)}. Reporting payouts `; + + try { + // if this interaction is from a channel, verify their dms are open by sending one + if (eph) { + await user.send(subscribed + "here."); + } + } catch (err) { + // when the bot can't send a dm, an exception is thrown + if (err instanceof DiscordAPIError) { + console.log(err); + + // delete the db entry + await db.deleteClaim(user.id, wallet); + + reply_string = `${Icons.ERROR} Error: I cannot send you a Direct Message. Please resolve that and try again.`; + } else throw err; // this is some other kind of error, pass it on + } + + reply_string = subscribed + (eph ? "in your DMs." : "here."); + } + break; + } + + + case "now": { + reply_string = "trying to claim"; + + const external_stakers: ExternalStakerConfig = { + fn: engulph_fetch_claimers, + identifier: EXTERNAL, + args: {endpoint: process.env.CLAIM_ENDPOINT, key: process.env.CLAIM_ENDPOINT_KEY} + } + + async function doit(): Promise { + const cfg: ClaimConfig = { + frequency: ClaimFrequency.IMMEDIATE, + batch: +process.env.CLAIM_BATCH!, + wallet: Chain.init_key(JSON.parse(process.env.CLAIM_WALLET!) as KeyringPair$Json, process.env.CLAIM_PASSWORD!), + dry_run: true + }; + + const chain = await Chain.create(process.env.CHAIN_RPC_ENDPOINT!); + + await (await Claim.create(db, chain, cfg)).submit(); + await chain.api.disconnect(); + } + + doit() + + break; + } + + case "list": { + // Get a list of user's subscribed wallets + const wallets: Map = await db.listUserClaims(user.id); + + // User isn't subscribed for any wallets + if (wallets.size <= 0) { + reply_string += `${Icons.ERROR} You aren't subscribed to payouts for any wallet.` + } else { + // Print a list of nodes + wallets.forEach( (claims, frequency) => { + reply_string += `You are subscribed to _${frequency}_ payouts for ${claims.length} wallet${ + claims.length > 1 ? "s" : "" + }:\n`; + claims.forEach((claim) => { + const url = `${process.env.EXPLORER_URL}/${claim.wallet}`; + const amount = claim.last_amount ? ` ${claim.last_amount} ` : ' '; + const changed = claim.last_claim + ? ` _(claimed${amount}${moment(claim.last_claim).fromNow()})_ ` + : " "; + + let line = `${Icons.WALLET}`; // node status icon + line += ` ${prettify_address_alias(claim.alias, claim.wallet, true, 48)}`; // node name & id + line += `${changed}`; // status text & time since change + line += ` [${Icons.LINK}](<${url}>)`; // link to dashboard page for node + + reply_string += line + "\n"; + }); + }); + + } + + break; + } + + + case "remove": { + const wallet : string = interaction.options.getString("wallet", true); + + // Get list of users subscriptions + const [result, deleted]: [DeleteResult, WithId[]] = await db.deleteClaim(user.id, wallet); + if (deleted.length) { + // Deleted node successfully + reply_string = `${Icons.DELETE} You are no longer subscribed to ${deleted[0].frequency} payouts for ${prettify_address_alias(deleted[0].alias, wallet)}.`; + } else { + // Node wasn't monitored + reply_string = `${Icons.ERROR} Error: You are not subscribed to payouts for ${prettify_address_alias(null, wallet)}.`; + } + + break; + } + } + + + await interaction.reply({ content: reply_string, ephemeral: eph }); + const options = interaction.options.data[0].options?.map( (opt) => `${opt.name}: ${opt.value}`) + console.log(`User ${user.id} interaction from ${eph ? "channel" : "dm"}: /${data.name} ${subcommand}${options && ` - ${options.join(', ')}`}`); +} + +export async function autocomplete( + interaction: AutocompleteInteraction, + db: Database +) { + const user = interaction.user; + const focusedValue = interaction.options.getFocused(); + + // Get list of nodes monitored from db + const monitored_nodes = await db.listUserClaimsFlat(user.id); + const choices = monitored_nodes.map((entry) => ({ + id: entry.wallet, + text: `${prettify_address_alias(entry.alias, entry.wallet, false)}`, + })); + const filtered = choices.filter((choice) => + choice.text.toLowerCase().includes(focusedValue.toLowerCase()) + ); + + await interaction.respond( + filtered.map((choice) => ({ name: choice.id, value: choice.id })) // setting name: choice.text should work, but it doesn't. Asked on SO: https://stackoverflow.com/q/74532512/1486966 + ); +} diff --git a/src/discord/commands/donate.ts b/src/discord/commands/donate.ts new file mode 100644 index 0000000..9b5b9cc --- /dev/null +++ b/src/discord/commands/donate.ts @@ -0,0 +1,42 @@ +import { SlashCommandBuilder, bold, italic, inlineCode } from "discord.js"; +import { Chain } from "../../chain/index.js"; + +import type { ChatInputCommandInteraction } from "discord.js"; +import type { KeyringPair$Json } from "@polkadot/keyring/types"; +import type { Database } from "../../db/index.js"; + +// env guard +import '../../env-guard/donate.js'; +import '../../env-guard/claim.js'; +import '../../env-guard/discord.js'; + +export const data = new SlashCommandBuilder() + .setName("donate") + .setDescription("View information about supporting this bot") + +export async function execute(interaction: ChatInputCommandInteraction, db: Database) { + const user = interaction.user; + const channel = interaction.channel + ? interaction.channel + : await interaction.client.channels.fetch(interaction.channelId); + const eph = channel ? (!channel.isDMBased() ? true : false) : false; // make the message ephemeral / visible only to user if not in dm + + const claim_wallet = Chain.init_key(JSON.parse(process.env.CLAIM_WALLET!) as KeyringPair$Json, process.env.CLAIM_PASSWORD!) + + const retrows = new Array(); + retrows.push(bold('Thank you for your interest in donating.')) + retrows.push('') + retrows.push(` Donate to the 🤖 developer: 💎 ${inlineCode(process.env.DONATE_WALLET!)}`) + retrows.push('') + retrows.push(` Donate to the 🤖 claim wallet: 🪙 ${inlineCode(claim_wallet.address)}`) + + // send the wallet details immediately + await interaction.reply({ content: retrows.join('\n'), ephemeral: eph }); + console.log(`User ${user.id} interaction from ${eph ? "channel" : "dm"}: /${data.name}`); + + // update with the wallet balance + const chain = await Chain.create(process.env.CHAIN_RPC_ENDPOINT!); + const claim_balance = chain.xx_bal_string(await chain.wallet_balance(claim_wallet)) + retrows.push(`${retrows.pop()} ${italic(`bal: ${claim_balance}`)}`) + await interaction.editReply({ content: retrows.join('\n') }); +} diff --git a/src/discord/commands/list_monitored_nodes.ts b/src/discord/commands/list_monitored_nodes.ts deleted file mode 100644 index d911a97..0000000 --- a/src/discord/commands/list_monitored_nodes.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { - SlashCommandBuilder, - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, - MessageFlags, - BaseInteraction, -} from "discord.js"; -import type { - ChatInputCommandInteraction, - BaseGuildTextChannel, -} from "discord.js"; -import moment from "moment"; -import base64url from "base64url"; -import { prettifyNode, Icons } from "../../utils.js"; -import { Database, Status, StatusIcon } from "../../db/index.js"; -import type { DeleteResult, Document, WithId } from "mongodb"; - -function buildResponseButtons( - db: Database, - nodes: WithId[], - unmonitor_buttons: boolean = true -) { - let rows = new Array>(); - const MAX_BUTTON_TEXT_LEN = 80; // 80 is value from exception thrown when string is too long - - nodes.forEach((node) => { - const row = new ActionRowBuilder(); // let's build on this puppy - - // node status - disabled (just used to show node id) - const button_style = - node.status === Status.UNKNOWN - ? ButtonStyle.Secondary - : node.status === Status.ONLINE - ? ButtonStyle.Success - : ButtonStyle.Danger; - row.addComponents( - new ButtonBuilder() - .setCustomId(`${node.node}-status`) - .setDisabled(true) - .setLabel((node.status as string).toUpperCase()) - .setStyle(button_style) - ); - - // node id button - disabled - row.addComponents( - new ButtonBuilder() - .setCustomId(`${node.node}-text`) - .setDisabled(true) - .setLabel( - prettifyNode(node.name, node.node, false, MAX_BUTTON_TEXT_LEN) - ) - .setStyle(ButtonStyle.Primary) - ); - - // unmonitor button - if (unmonitor_buttons) { - row.addComponents( - new ButtonBuilder() - .setCustomId(node.node) - .setLabel("Unmonitor") - .setStyle(ButtonStyle.Danger) - ); - } - - // dashboard link - const url = `${process.env.DASHBOARD_URL}/${base64url.fromBase64( - node.node - )}`; - row.addComponents( - new ButtonBuilder() - .setURL(url) - .setLabel("Dashboard") - .setStyle(ButtonStyle.Link) - ); - - rows.push(row); - }); - - return rows; -} - -function buildResponseText(db: Database, nodes: WithId[]) { - let reply_string = ""; - - // Print a list of nodes - nodes.forEach((node) => { - const url = `${process.env.DASHBOARD_URL}/${base64url.fromBase64( - node.node - )}`; - const changed = node.changed - ? ` since ${moment(node.changed).fromNow()}` - : ""; - const status: keyof typeof Status = node.status - ? node.status.toUpperCase() - : Status.UNKNOWN.toUpperCase(); // edge case for empty string status in the database - - let line = StatusIcon[status].toString(); // node status icon - line += ` ${prettifyNode(node.name, node.node)}`; // node name & id - line += ` _(${status}${changed})_`; // status text & time since change - line += ` [${Icons.LINK}](${url})`; // link to dashboard page for node - - reply_string += line + "\n"; - }); - - return reply_string; -} - -async function buildResponse( - db: Database, - user_id: string, - fancy: boolean = true, - unmonitor_buttons: boolean = true -) { - // Get a list of user's monitored nodes - const nodes = await db.listUserNodes(user_id); - - // User isn't monitoring any nodes - if (nodes.length <= 0) { - return { - text: `${Icons.ERROR} You aren't monitoring any nodes.`, - components: [], - }; - } - - const reply_string = `You are monitoring ${nodes.length} node${ - nodes.length > 1 ? "s" : "" - }:\n`; - - // User is monitoring 1-5 nodes AND the fancy flag is set - show buttons - if (nodes.length > 0 && nodes.length <= 5 && fancy) { - return { - text: reply_string, - components: buildResponseButtons(db, nodes, unmonitor_buttons), - }; - } - // Build a codeblock if we have results > 5 or fancy flag is unset - else { - return { - text: `${reply_string}${buildResponseText(db, nodes)}`, - components: [], - }; - } -} - -export const data = new SlashCommandBuilder() - .setName("list_monitored_nodes") - .setDescription("Display a list of validators that you are monitoring") - .addStringOption((option) => - option - .setName("format") - .setDescription( - "Choose the format of the validator list. Default is Text." - ) - .addChoices( - { name: "Text", value: "text" }, - { name: "Buttons", value: "buttons" } - ) - ); - -export async function execute( - interaction: ChatInputCommandInteraction, - db: Database -) { - const user = interaction.user; - const format = interaction.options.getString("format"); - const fancy = format == "buttons" ? true : false; - const channel = ( - interaction.channel - ? interaction.channel - : await interaction.client.channels.fetch(interaction.channelId) - ) as BaseGuildTextChannel; - const eph = channel ? (!channel.isDMBased() ? true : false) : false; // make the message ephemeral / visible only to user if not in dm - - let { text, components } = await buildResponse(db, user.id, fancy); // build fancy list (if plain not set by user) - await interaction.reply({ - content: text, - components: components, - ephemeral: eph, - flags: MessageFlags.SuppressEmbeds, - }); - - // if we have button components, make sure we have the right callback - if (components.length) { - // button event handling - https://discordjs.guide/interactions/buttons.html#updating-the-button-message - - const filter = (i: BaseInteraction) => i.user.id === user.id; - const collector = channel.createMessageComponentCollector({ - filter, - time: 45000, - dispose: true, - }); - - collector.on("collect", async (i) => { - // if button was clicked, delete it from user and update message - const [result, deleted]: [DeleteResult, WithId[]] = - await db.deleteNode(user.id, i.customId); - if (deleted.length) { - // Deleted node successfully - let reply_string = `${ - Icons.DELETE - } You are no longer monitoring ${prettifyNode( - deleted[0].name, - i.customId - )}.`; - let { text, components } = await buildResponse(db, user.id); - await i.update({ content: text, components: components }); - await interaction.followUp({ content: reply_string, ephemeral: eph }); - } - }); - - collector.on("end", async () => { - // Disable the unmonitor buttons because we're done listening for them - let { text, components } = await buildResponse(db, user.id, true, false); - await interaction.editReply({ content: text, components: components }); - }); - } - - console.log( - `User ${user.id} interaction from ${eph ? "channel" : "dm"}: listed nodes` - ); -} diff --git a/src/discord/commands/monitor.ts b/src/discord/commands/monitor.ts new file mode 100755 index 0000000..893ef02 --- /dev/null +++ b/src/discord/commands/monitor.ts @@ -0,0 +1,175 @@ +import moment from "moment"; +import { SlashCommandBuilder, DiscordAPIError, italic } from "discord.js"; +import { prettify_address_alias, Icons, XX_ID_LEN } from "../../utils.js"; +import base64url from "base64url"; + +import type { Database } from "../../db/index.js"; +import type { AutocompleteInteraction, ChatInputCommandInteraction } from "discord.js"; +import { Status, StatusIcon } from "../../cmix/types.js"; + +// env guard +import '../../env-guard/monitor.js'; +import '../../env-guard/discord.js'; + + +export const data = new SlashCommandBuilder() + .setName("monitor") + .setDescription("Manage cmix validator monitoring") + .addSubcommand(subcommand => + subcommand + .setName('add') + .setDescription('Monitor a cmix node') + .addStringOption((option) => + option + .setName("id") + .setDescription("The cmix ID to monitor") + .setRequired(true) + .setMaxLength(XX_ID_LEN) + .setMinLength(XX_ID_LEN) + .setAutocomplete(true)) + .addStringOption((option) => + option + .setName("name") + .setDescription("An alias for the cmix node") + )) + .addSubcommand(subcommand => + subcommand + .setName('list') + .setDescription('List monitored cmix nodes') + ) + .addSubcommand(subcommand => + subcommand + .setName('remove') + .setDescription('Stop monitoring a cmix node') + .addStringOption((option) => + option + .setName("id") + .setDescription("The cmix ID to stop monitoring") + .setRequired(true) + .setMaxLength(44) + .setMinLength(44) + .setAutocomplete(true))); + + +export async function execute(interaction: ChatInputCommandInteraction, db: Database) { + const subcommand = interaction.options.getSubcommand(); + const user = interaction.user; + const channel = interaction.channel + ? interaction.channel + : await interaction.client.channels.fetch(interaction.channelId); + const eph = channel ? (!channel.isDMBased() ? true : false) : false; // make the message ephemeral / visible only to user if not in dm + let reply_string = ``; + + switch (subcommand) { + case "add": { + const cmix_id = interaction.options.getString('id', true); + const cmix_node_name = interaction.options.getString('name', false); + + const status = await db.addNode(user.id, cmix_id, cmix_node_name); // returns false if the user is already monitoring this node/name combination + if (status !== undefined) { + // Successfully added or updated node + + if ("modifiedCount" in status) { + // result was a record update + reply_string = `${Icons.SUCCESS} Updated \`${cmix_id}\` name to \`${cmix_node_name}\`.`; + } else { + // result was a new record + const monitoring = `${Icons.WATCH} Monitoring ${prettify_address_alias(cmix_node_name, cmix_id)}. Reporting changes `; + + try { + // if this interaction is from a channel, verify their dms are open by sending one + if (eph) { + await user.send(monitoring + "here."); + } + } catch (err) { + // when the bot can't send a dm, an exception is thrown + if (err instanceof DiscordAPIError) { + console.log(err); + + // delete the db entry + await db.deleteNode(user.id, cmix_id); + + reply_string = `${Icons.ERROR} Error: I cannot send you a Direct Message. Please resolve that and try again.`; + } else throw err; // this is some other kind of error, pass it on + } + + reply_string = monitoring + (eph ? "in your DMs." : "here"); + } + } else { + // User is already monitoring this node + reply_string = `${Icons.ERROR} Error: You are already monitoring ${prettify_address_alias(cmix_node_name, cmix_id)}.`; + } + + break; + } + + + case "remove": { + const cmix_id = interaction.options.getString('id', true); + + // Get list of users subscriptions + const [_, deleted] = await db.deleteNode(user.id, cmix_id); + if (deleted.length) { + // Deleted node successfully + reply_string = `${Icons.DELETE} You are no longer monitoring ${prettify_address_alias(deleted[0].name, cmix_id)}.`; + } else { + // Node wasn't monitored + reply_string = `${Icons.ERROR} Error: You are not monitoring ${prettify_address_alias(null, cmix_id)}.`; + } + break; + } + + case "list": { + reply_string = await buildResponseText(db, user.id) + } + } + + await interaction.reply({ content: reply_string, ephemeral: eph }); + const options = interaction.options.data[0].options?.map( (opt) => `${opt.name}: ${opt.value}`) + console.log(`User ${user.id} interaction from ${eph ? "channel" : "dm"}: /${data.name} ${subcommand}${options && ` - ${options.join(', ')}`}`); +} + +export async function autocomplete(interaction: AutocompleteInteraction, db: Database) { + const user = interaction.user; + const focusedValue = interaction.options.getFocused(); + + // Get list of nodes monitored from db + const monitored_nodes = await db.listUserNodes(user.id); + const choices = monitored_nodes.map((entry) => ({ + id: entry.node, + text: `${prettify_address_alias(entry.name, entry.node, false)}`, + })); + const filtered = choices.filter((choice) => + choice.text.toLowerCase().includes(focusedValue.toLowerCase()) + ); + + await interaction.respond( + filtered.map((choice) => ({ name: choice.id, value: choice.id })) // setting name: choice.text should work, but it doesn't. Asked on SO: https://stackoverflow.com/q/74532512/1486966 + ); +} + +async function buildResponseText(db: Database, id: string) { + // Get a list of user's monitored nodes + const nodes = await db.listUserNodes(id); + + // User isn't monitoring any nodes + if (nodes.length <= 0) return `${Icons.ERROR} You aren't monitoring any nodes.` + + let node_list = ""; + + // Print a list of nodes + nodes.forEach((node) => { + const url = `${process.env.CMIX_DASH_URL}/${base64url.fromBase64(node.node)}`; + const changed = node.changed ? ` ${moment(node.changed).fromNow()}` : ""; + const status = node.status ? node.status : Status.UNKNOWN; // edge case for empty string status in the database + + let line = StatusIcon[status.toUpperCase() as keyof typeof Status].toString(); // node status icon + line += ` ${prettify_address_alias(node.name, node.node)}`; // node name & id + line += ` [${Icons.LINK}](<${url}>)`; // link to dashboard page for node + line += ` ${italic(status+changed)}`; // status text & time since change + + node_list += line + "\n"; + }); + + return `You are monitoring ${nodes.length} node${nodes.length > 1 ? "s" : ""}:\n${node_list}`; +} diff --git a/src/discord/commands/monitor_node.ts b/src/discord/commands/monitor_node.ts deleted file mode 100644 index 494e966..0000000 --- a/src/discord/commands/monitor_node.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { SlashCommandBuilder, DiscordAPIError } from "discord.js"; -import type { - AutocompleteInteraction, - ChatInputCommandInteraction, -} from "discord.js"; -import { prettifyNode, Icons } from "../../utils.js"; -import type { Database } from "../../db/index.js"; - -export const data = new SlashCommandBuilder() - .setName("monitor_node") - .setDescription("Register a new validator node to monitor") - .addStringOption((option) => - option - .setName("id") - .setDescription("The Node ID to monitor") - .setRequired(true) - .setMaxLength(44) - .setMinLength(44) - .setAutocomplete(true) - ) - .addStringOption((option) => - option.setName("name").setDescription("A friendly name for the node") - ); - -export async function execute( - interaction: ChatInputCommandInteraction, - db: Database -) { - const node_id = interaction.options.getString("id", true); - const node_name = interaction.options.getString("name", false); - const user = interaction.user; - const channel = interaction.channel - ? interaction.channel - : await interaction.client.channels.fetch(interaction.channelId); - const eph = channel ? (!channel.isDMBased() ? true : false) : false; // make the message ephemeral / visible only to user if not in dm - let reply_string = ``; - - const status = await db.addNode(user.id, node_id, node_name); // returns false if the user is already monitoring this node/name combination - - if (status) { - // Successfully added or updated node - - if ("modifiedCount" in status) { - // result was a record update - reply_string = `${Icons.SUCCESS} Updated \`${node_id}\` name to \`${node_name}\`.`; - } else { - // result was a new record - const monitoring = `${Icons.WATCH} Monitoring ${prettifyNode( - node_name, - node_id - )}. Reporting changes `; - - try { - // if this interaction is from a channel, verify their dms are open by sending one - if (eph) { - await user.send(monitoring + "here."); - } - } catch (err) { - // when the bot can't send a dm, an exception is thrown - if (err instanceof DiscordAPIError) { - console.log(err); - - // delete the db entry - await db.deleteNode(user.id, node_id); - - reply_string = `${Icons.ERROR} Error: I cannot send you a Direct Message. Please resolve that and try again.`; - } else throw err; // this is some other kind of error, pass it on - } - - reply_string = monitoring + (eph ? "in your DMs." : "here"); - } - } else { - // User is already monitoring this node - reply_string = `${ - Icons.ERROR - } Error: You are already monitoring ${prettifyNode(node_name, node_id)}.`; - } - - await interaction.reply({ content: reply_string, ephemeral: eph }); - console.log( - `User ${user.id} interaction from ${ - eph ? "channel" : "dm" - }: monitor ${node_id}: ${reply_string}` - ); -} - -export async function autocomplete( - interaction: AutocompleteInteraction, - db: Database -) { - const user = interaction.user; - const focusedValue = interaction.options.getFocused(); - - // Get list of nodes monitored from db - const monitored_nodes = await db.listUserNodes(user.id); - const choices = monitored_nodes.map((entry) => ({ - id: entry.node, - text: `${prettifyNode(entry.name, entry.node, false)}`, - })); - const filtered = choices.filter((choice) => - choice.text.toLowerCase().includes(focusedValue.toLowerCase()) - ); - - await interaction.respond( - filtered.map((choice) => ({ name: choice.id, value: choice.id })) // setting name: choice.text should work, but it doesn't. Asked on SO: https://stackoverflow.com/q/74532512/1486966 - ); -} diff --git a/src/discord/commands/unmonitor_node.ts b/src/discord/commands/unmonitor_node.ts deleted file mode 100644 index 2341134..0000000 --- a/src/discord/commands/unmonitor_node.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { SlashCommandBuilder } from "discord.js"; -import type { - AutocompleteInteraction, - ChatInputCommandInteraction, -} from "discord.js"; -import { prettifyNode, Icons, XX_ID_LEN } from "../../utils.js"; -import type { Database } from "../../db/index.js"; -import type { Document, DeleteResult, WithId } from "mongodb"; - -export const data = new SlashCommandBuilder() - .setName("unmonitor_node") - .setDescription("Stop monitoring a validator") - .addStringOption((option) => - option - .setName("id") - .setDescription("The Node ID to stop monitoring") - .setRequired(true) - .setMaxLength(XX_ID_LEN) - .setMinLength(XX_ID_LEN) - .setAutocomplete(true) - ); - -export async function execute( - interaction: ChatInputCommandInteraction, - db: Database -) { - const node_id = interaction.options.getString("id", true); - const user = interaction.user; - const channel = interaction.channel - ? interaction.channel - : await interaction.client.channels.fetch(interaction.channelId); - const eph = channel ? (!channel.isDMBased() ? true : false) : false; // make the message ephemeral / visible only to user if not in dm - let reply_string = ""; - - // Get list of users subscriptions - const [result, deleted]: [DeleteResult, WithId[]] = - await db.deleteNode(user.id, node_id); - if (deleted.length) { - // Deleted node successfully - reply_string = `${ - Icons.DELETE - } You are no longer monitoring ${prettifyNode(deleted[0].name, node_id)}.`; - } else { - // Node wasn't monitored - reply_string = `${ - Icons.ERROR - } Error: You are not monitoring ${prettifyNode(null, node_id)}.`; - } - - await interaction.reply({ content: reply_string, ephemeral: eph }); - console.log( - `User ${user.id} interaction from ${ - eph ? "channel" : "dm" - }: unmonitor ${node_id}: ${reply_string}` - ); -} - -export async function autocomplete( - interaction: AutocompleteInteraction, - db: Database -) { - const user = interaction.user; - const focusedValue = interaction.options.getFocused(); - - // Get list of nodes monitored from db - const monitored_nodes = await db.listUserNodes(user.id); - const choices = monitored_nodes.map((entry) => ({ - id: entry.node, - text: `${prettifyNode(entry.name, entry.node, false, XX_ID_LEN)}`, - })); - const filtered = choices.filter((choice) => - choice.text.toLowerCase().includes(focusedValue.toLowerCase()) - ); - - await interaction.respond( - filtered.map((choice) => ({ name: choice.id, value: choice.id })) // setting name: choice.text should work, but it doesn't. Asked on SO: https://stackoverflow.com/q/74532512/1486966 - ); -} diff --git a/src/bot-utils.ts b/src/discord/discord-utils.ts similarity index 74% rename from src/bot-utils.ts rename to src/discord/discord-utils.ts index 4b1fa70..d92df0f 100755 --- a/src/bot-utils.ts +++ b/src/discord/discord-utils.ts @@ -1,10 +1,10 @@ // Script from https://discordjs.guide/creating-your-bot/command-deployment.html#command-registration, modified import { Command, Option, Argument } from "@commander-js/extra-typings"; -import { REST, Routes, Client, GatewayIntentBits, Events } from "discord.js"; -import { DiscordClient } from "./discord/types.js"; -import fs from "node:fs"; +import { REST, Routes, Client, GatewayIntentBits, Events, SlashCommandBuilder, RESTPostAPIChatInputApplicationCommandsJSONBody, ApplicationCommandOptionType } from "discord.js"; +import { DiscordClient } from "./types.js"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import fs from "node:fs"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -45,31 +45,37 @@ async function init_client(): Promise { async function deploy(guildId?: string) { const rest = new REST({ version: "10" }).setToken(token); - const commands = new Array(); + const commands = new Array(); // Grab all the command files from the commands directory - const commandsPath = join(__dirname, "discord", "commands"); + const commandsPath = join(__dirname, "commands"); const commandFiles = fs .readdirSync(commandsPath) .filter((file) => file.endsWith(".js")); // Grab the SlashCommandBuilder#toJSON() output of each command's data for deployment for (const file of commandFiles) { - const filePath = join(commandsPath, file); - const command = await import(filePath); - if ("data" in command && "execute" in command) { - commands.push(command.data.toJSON()); - } else { - console.log( - `[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.` - ); + try { + const filePath = join(commandsPath, file); + const command = await import(filePath); + if ("data" in command && "execute" in command) { + const data = command.data as SlashCommandBuilder; + commands.push(data.toJSON()); + console.log(`Loading ${data.name} command: ${data.description}`) + // check for subcommands + for (const subcommand of data.options.filter( (option) => option.toJSON().type === ApplicationCommandOptionType.Subcommand)) { + console.log(`\tLoading ${subcommand.toJSON().name} subcommand: ${subcommand.toJSON().description}`) + } + } else { + throw new Error(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`); + } + } catch (e) { + console.log(e); } } - console.log( - `Prepared ${commands.length} application (/) commands for deployment.` - ); + console.log(`Prepared ${commands.length} application (/) commands for deployment ${guildId ? `to guildId ${guildId}` : "globally"}.`); - (async () => { + await (async () => { try { // Deploying commands const route = guildId @@ -77,9 +83,6 @@ async function deploy(guildId?: string) { : Routes.applicationCommands(clientId); const data = await rest.put(route, { body: commands }) as any; - console.log( - `Successfully reloaded ${data.length} application (/) commands.` - ); } catch (error) { // And of course, make sure you catch and log any errors! console.error(error); @@ -115,7 +118,17 @@ program "Resets the global and guild commands if you have deployed to both" ) ) + .addOption( + new Option( + "--dev", + "Includes development commands in the deploy" + ) + ) .action(async (options) => { + process.env["BOT_DEPLOY"] = "true"; + if (options.dev) { + process.env["NODE_ENV"] = "development" + } if (options.reset) { // reset the commands in server and globally; see https://stackoverflow.com/a/70167704/1486966 console.log("Resetting commands..."); diff --git a/src/discord/events/interactionCreate.ts b/src/discord/events/interactionCreate.ts index 3680ba9..a0ed146 100644 --- a/src/discord/events/interactionCreate.ts +++ b/src/discord/events/interactionCreate.ts @@ -11,7 +11,7 @@ export async function execute(interaction: CommandInteraction, db: Database) { ); if (!command) { - console.error(`No command matching ${interaction.commandName} was found.`); + console.error(`No bot command matching /${interaction.commandName} was found.`); return; } @@ -24,17 +24,13 @@ export async function execute(interaction: CommandInteraction, db: Database) { if (interaction.isChatInputCommand()) { // log action in db const user_id = interaction.user.id; - await db.logAction( - user_id, - interaction.commandName, - interaction.options.data.toString() - ); + await db.logAction(user_id, interaction.commandName, interaction.options.data.toString()); await command.execute(interaction, db); } else if (interaction.isAutocomplete()) { await command.autocomplete(interaction, db); } } catch (error) { - console.error(`Error executing ${interaction.commandName}`); + console.error(`Error executing bot command /${interaction.commandName}`); console.error(error); } } diff --git a/src/discord/events/ready.ts b/src/discord/events/ready.ts index 4ce9fa7..a4d6f54 100644 --- a/src/discord/events/ready.ts +++ b/src/discord/events/ready.ts @@ -1,5 +1,11 @@ -import { Events, ActivityType } from "discord.js"; -import { startPolling } from "../../cmix/index.js"; +import { Events, ActivityType, inlineCode } from "discord.js"; +import { Icons, prettify_address_alias } from "../../utils.js"; +import { Chain } from "../../chain/index.js"; +import { NotifyData, XXEvent } from "../../events/types.js"; +import { sendToChannel, sendToDM } from "../messager.js"; +import PubSub from 'pubsub-js'; + +import type { CommissionChange } from "../../chain/types.js"; import type { Database } from "../../db/index.js"; import type { DiscordClient } from "../types.js"; @@ -22,7 +28,45 @@ export function execute(client: DiscordClient, db: Database) { }); } - // start cmix poller - startPolling(db, client); - + // Subscribe to events + // Validator Status Change + const validator_status_change: PubSubJS.SubscriptionListener = (msg, data) => { + console.log('got it loud and clear beyotch') + data && sendToDM(client, data.id, data.msg) + } + PubSub.subscribe(XXEvent.VALIDATOR_STATUS_CHANGE, validator_status_change); + + // Validator Name Change + const validator_name_change: PubSubJS.SubscriptionListener = (msg, data) => { + data && sendToDM(client, data.id, data.msg) + } + PubSub.subscribe(XXEvent.VALIDATOR_NAME_CHANGE, validator_name_change); + + // Validator Commission Change + const validator_commission_change: PubSubJS.SubscriptionListener = async (msg, data) => { + if (data) { + for(const record of await db.updateNodeCommission(data.cmix_id, data.commission)){ + const commission_update = `${Chain.commissionToHuman(data.commission_previous, data.chain_decimals)}${Icons.TRANSIT}${Chain.commissionToHuman(data.commission, data.chain_decimals)}` + const retrows = new Array(); + retrows.push(`${Icons.UPDATE} Validator ${prettify_address_alias(record.name, record.node, true)} commission ${data.commission_previous = (msg, data) => { + data && sendToDM(client, data.id, data.msg); + } + PubSub.subscribe(XXEvent.CLAIM_EXECUTED, claim_executed) + + // Claim Failed + const claim_failed: PubSubJS.SubscriptionListener = (msg, data) => { + if (process.env.ADMIN_NOTIFY_CHANNEL && data){ + if (process.env.ADMIN_NOTIFY_CHANNEL.toLowerCase() === 'dm') sendToDM(client, data.id, data.msg); + else sendToChannel(client, process.env.ADMIN_NOTIFY_CHANNEL, data.msg); + } + } + PubSub.subscribe(XXEvent.CLAIM_FAILED, claim_failed) } diff --git a/src/discord/index.ts b/src/discord/index.ts index 79bd941..da558ed 100644 --- a/src/discord/index.ts +++ b/src/discord/index.ts @@ -24,15 +24,18 @@ export async function initDiscord(db: Database, token: string): Promise { // Build collection of available commands from the commands directory for (const file of commandFiles) { - const filePath = join(commandsPath, file); - const command = await import(filePath); - // Set a new item in the Collection with the key as the command name and the value as the exported module - if ("data" in command && "execute" in command) { - client.commands.set(command.data.name, command); - } else { - console.log( - `[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.` - ); + // each command file determines whether it will load by throwing an error when it can't + try { + const filePath = join(commandsPath, file); + const command = await import(filePath); + // Set a new item in the Collection with the key as the command name and the value as the exported module + if ("data" in command && "execute" in command) { + client.commands.set(command.data.name, command); + } else { + throw new Error(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`) + } + } catch (e) { + console.log(e); } } diff --git a/src/discord/messager.ts b/src/discord/messager.ts new file mode 100644 index 0000000..d566346 --- /dev/null +++ b/src/discord/messager.ts @@ -0,0 +1,50 @@ +import type { Client, TextChannel } from "discord.js"; + +const MAX_MESSAGE_SIZE = 2000; + +function chunkString(str: string | string[], size: number = MAX_MESSAGE_SIZE): string[] { + const _lines_in = new Array(); + const _lines_out = new Array(); + if (typeof str === 'string'){ + _lines_in.push(...str.split('\n')); + } else { + _lines_in.push(...str); + } + + let _char_count = 0; + let _line_buffer = new Array(); + for (const line of _lines_in) { + if (_char_count + line.length + _line_buffer.length > size) { + // line buffer is full, push it to lines out and make a new one + _lines_out.push(_line_buffer.join('\n')) + _line_buffer = new Array(); + _char_count = 0; + } + _char_count += line.length + _line_buffer.push(line); + } + _lines_out.push(_line_buffer.join('\n')) + + return _lines_out; +} + +export async function sendToDM(client: Client, user_id: string, message: string | string[]): Promise { + const chunks = chunkString(message); + + client.users.fetch(user_id).then((dm) => { + for(const chunk of chunks) { + dm.send(chunk); + } + }); +} + +export async function sendToChannel(client: Client, channel: string, message: string | string[]): Promise { + const chunks = chunkString(message); + + client.channels.fetch(channel).then((channel) => { + if (channel === null) throw new Error(`Channel ${channel} does not exist`); + for(const chunk of chunks) { + (channel as TextChannel).send(chunk); + } + }) +} diff --git a/src/env-guard/claim.ts b/src/env-guard/claim.ts new file mode 100644 index 0000000..0476809 --- /dev/null +++ b/src/env-guard/claim.ts @@ -0,0 +1,12 @@ +import { guard } from "./index.js" + +const vars = [ + 'CHAIN_RPC_ENDPOINT', + 'CLAIM_CRON_DAILY', + 'CLAIM_BATCH', + 'CLAIM_WALLET', + 'CLAIM_PASSWORD', + 'EXPLORER_URL', +] + +guard(vars, 'claims'); diff --git a/src/env-guard/discord.ts b/src/env-guard/discord.ts new file mode 100644 index 0000000..4dca455 --- /dev/null +++ b/src/env-guard/discord.ts @@ -0,0 +1,9 @@ +import { guard } from "./index.js" + +const vars = [ + 'DISCORD_TOKEN', + 'APP_ID', + 'DEV_GUILD_ID' +] + +guard(vars, 'discord'); \ No newline at end of file diff --git a/src/env-guard/donate.ts b/src/env-guard/donate.ts new file mode 100644 index 0000000..3a333bf --- /dev/null +++ b/src/env-guard/donate.ts @@ -0,0 +1,7 @@ +import { guard } from "./index.js" + +const vars = [ + 'DONATE_WALLET' +] + +guard(vars, 'donate'); \ No newline at end of file diff --git a/src/env-guard/index.ts b/src/env-guard/index.ts new file mode 100644 index 0000000..191c02e --- /dev/null +++ b/src/env-guard/index.ts @@ -0,0 +1,7 @@ +export function guard(vars: string[], guarding?: string) { + const environment = Object.keys(process.env) + + const missing = vars.filter( v => !environment.includes(v) ); + if (missing.length) + throw new Error(`Missing${guarding && ` ${guarding}`} env vars: ${missing.join(', ')}`) +} \ No newline at end of file diff --git a/src/env-guard/monitor.ts b/src/env-guard/monitor.ts new file mode 100644 index 0000000..cdae7ba --- /dev/null +++ b/src/env-guard/monitor.ts @@ -0,0 +1,10 @@ +import { guard } from "./index.js" + +const vars = [ + 'CMIX_API_ENDPOINT', + 'CMIX_API_CRON', + 'CMIX_DASH_URL', + 'CHAIN_RPC_ENDPOINT', +] + +guard(vars, 'monitor'); diff --git a/src/events/types.ts b/src/events/types.ts new file mode 100644 index 0000000..d01a0ad --- /dev/null +++ b/src/events/types.ts @@ -0,0 +1,13 @@ +export enum XXEvent { + VALIDATOR_STATUS_CHANGE = "VALIDATOR_STATUS_CHANGE", + VALIDATOR_NAME_CHANGE = "VALIDATOR_NAME_CHANGE", + VALIDATOR_COMMISSION_CHANGE = "VALIDATOR_COMMISSION_CHANGE", + CLAIM_EXECUTED = "CLAIM_EXECUTED", + CLAIM_FAILED = "CLAIM_FAILED", +} + + +export interface NotifyData { + id: string, + msg: string | string[] +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index f9294e2..9105825 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,34 +1,39 @@ +import { startAllClaiming } from "./chain/claim.js"; +import { startListeningCommission } from "./chain/index.js"; +import { startPolling } from "./cmix/index.js"; import { Database } from "./db/index.js"; import { initDiscord } from "./discord/index.js"; var env = process.env.NODE_ENV || "development"; - console.log(`NODE_ENV: ${env}`); -console.log(`NODE_EXTRA_CA_CERTS: ${process.env.NODE_EXTRA_CA_CERTS}`); - -// If in development, load & expand variables manually if (env === "development") { - var dotenv = (await import("dotenv")).config({ path: ".env" }); - var dotenvExpand = await import("dotenv-expand"); - dotenvExpand.expand(dotenv); + console.log(process.env); +} - console.log(dotenv); +// // initialize database +if (!process.env.MONGO_URI) { throw new Error('Missing env var MONGO_URI, exiting') } +const db: Database = await Database.connect(process.env.MONGO_URI); - console.log(`NODE_EXTRA_CA_CERTS: ${process.env.NODE_EXTRA_CA_CERTS}`); +// start cmix cron +// todo - consolidate /monitor commands into single command, then handle command loading like claim i.e. throw error when env vars aren't available. +await import('./env-guard/monitor.js') +startPolling(db, process.env.CMIX_API_ENDPOINT!, process.env.CMIX_API_CRON!); - // set mongodb uri to localhost - process.env.MONGO_URI = `mongodb://${process.env.MONGO_INITDB_ROOT_USERNAME}:${process.env.MONGO_INITDB_ROOT_PASSWORD}@localhost:${process.env.MONGO_PORT}/`; -} +// start chain listener +await import('./env-guard/claim.js') +startAllClaiming(db, process.env.CHAIN_RPC_ENDPOINT!); +startListeningCommission(process.env.CHAIN_RPC_ENDPOINT!); -// initialize database -const db: Database = await Database.connect(process.env.MONGO_URI!); -// start discord.js +// start bots +// start discord.js +await import('./env-guard/discord.js') initDiscord(db, process.env.DISCORD_TOKEN!); -// client.js: loads all the event handlers for discord -// once discord.js client is ready, magic starts in events/ready.js +// discord/index.js: loads all the event handlers for discord +// once discord.js client is ready, magic starts in events/ready.js // -// events/ready.js: fires off the poller that downloads the current nodes list, -// compares it to the database of monitored nodes, and sends dms when -// node status changes have happened. +// discord/events/ready.js: fires off the poller that downloads the current nodes list, +// compares it to the database of monitored nodes, and sends dms when +// node status changes have happened. + diff --git a/src/messager.ts b/src/messager.ts deleted file mode 100644 index 5b7273d..0000000 --- a/src/messager.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Status, StatusIcon } from "./db/index.js"; -import type { MonitorRecord } from "./db/types.js"; -import { prettifyNode, Icons } from "./utils.js"; -import type { Client } from "discord.js"; - -export async function sendDM(client: Client, user_id: string, message: string) { - client.users.fetch(user_id).then((dm) => { - dm.send(message); - }); -} - -export async function dmStatusChange( - client: Client, - node: MonitorRecord, - status_new: string -) { - const status = status_new.toUpperCase() as keyof typeof Status; - var message = StatusIcon[status] + ` ${Icons.TRANSIT} ` + StatusIcon[status]; // old -> new status icon - message += ` ${prettifyNode(node.name, node.node)} `; // pretty node name - message += `is now ${status_new == Status.ERROR ? "in " : ""}${status}`; // new status - - sendDM(client, node.user, message); -} diff --git a/src/utils.ts b/src/utils.ts index 4e42990..a64349f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,8 +1,31 @@ import { inlineCode } from "discord.js"; +import type { ExternalStaker, Staker } from "./chain/types"; + +export enum Icons { + WATCH = "👀", + ERROR = "💢", + SUCCESS = "🙌", + DELETE = "🗑️", + TRANSIT = "➜", + LINK = "🔗", + WALLET = "🪙", + VALIDATOR = "❤️", + NOMINATOR = "💚", + UPDATE = "✨", + BOT = "🤖", + EXTERNAL = "🌐", + CMIX = "🖧", + DIAMOND = "💎", +} + +export const XX_ID_LEN = 44; + +const ADDRESS_ALIAS_MIN_ID: number = 5; // the minimum length for an ID +const ADDRESS_ALIAS_SEPARATOR: string = " / "; // truncate a string to set length, using ellipsis in the center -function truncate(text: string, length: number = 44): string { - length = length < 5 ? 5 : length; // do not truncate anything shorter than 5 characters +function truncate(text: string, length: number = XX_ID_LEN): string { + length = length < ADDRESS_ALIAS_MIN_ID ? ADDRESS_ALIAS_MIN_ID : length; // do not truncate anything shorter than 5 characters const trunc = text.length > length ? `${text.substring(0, Math.ceil(length / 2) - 1)}…${text.substring( @@ -13,28 +36,83 @@ function truncate(text: string, length: number = 44): string { } // take a pretty name and an id and combine; if no name provided, just return id -export function prettifyNode( - name: string | null, +export function prettify_address_alias( + name: string | null | undefined, id: string, codify: boolean = true, - maxlen: number = 44 + maxlen: number = XX_ID_LEN ) { - if (!name) return codify ? inlineCode(id) : id; // just return id if no name is given - const MAX_LEN = maxlen - 3; // arbitrary, can be increased - const MAX_NAME_LEN = Math.ceil(MAX_LEN / 2); // name shouldn't be much longer than half the max length - const name_new = truncate(name, MAX_NAME_LEN); - const MAX_ID_LEN = MAX_LEN - name_new.length; // id takes up the rest of the space - const pretty = `${name_new} / ${truncate(id, MAX_ID_LEN)}`; - return codify ? inlineCode(pretty) : pretty; + let retval: string; + if (!name) { + // if there's no name, just truncate the id and return + retval = truncate(id, maxlen); + } else if(id.length + name.length + ADDRESS_ALIAS_SEPARATOR.length <= maxlen) { + // if the name and id are somehow less than the max, just return untruncated + retval = `${name} / ${id}` + } else { + // is the name too long? i.e., it doesn't allow for the minimum id length + const truncate_name: boolean = maxlen - (name.length + ADDRESS_ALIAS_SEPARATOR.length) < ADDRESS_ALIAS_MIN_ID; + if (truncate_name) { + const name_truncate_len: number = maxlen - ADDRESS_ALIAS_SEPARATOR.length - ADDRESS_ALIAS_MIN_ID; + name = truncate(name, name_truncate_len); + id = truncate(id, ADDRESS_ALIAS_MIN_ID); + } else { + const id_truncate_len: number = maxlen - ADDRESS_ALIAS_SEPARATOR.length - name.length; + id = truncate(id, id_truncate_len); + } + retval = `${name} / ${id}` + } + + return codify ? inlineCode(retval) : retval; } -export enum Icons { - WATCH = "👀", - ERROR = "💢", - SUCCESS = "🙌", - DELETE = "🗑️", - TRANSIT = "➜", - LINK = "🔗", +export async function xx_price(): Promise { + // get current price + const params = new URLSearchParams({ + ids: "xxcoin", + vs_currencies: "usd", + }); + const headers = new Headers({ + accept: "application/json", + }); + const price: number = ( + await ( + await fetch(`https://api.coingecko.com/api/v3/simple/price?${params}`, { + headers, + }) + ).json() + ).xxcoin.usd; + return price; } -export const XX_ID_LEN = 44; +export function pluralize(collection: Array | Map | Set, noun: string, suffix = 's') { + const count = ('size' in collection) ? collection.size : collection.length + return `${count} ${noun}${count !== 1 ? suffix : ''}`; +} + +export async function wait(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +export const EXTERNAL = 'external'; // string used to identify wallets claimed from web +// This is an engul.ph-specific implementation of an external staker source; it can be replaced with a function that returns Array +export async function engulph_fetch_claimers(identifier: string, args: {endpoint: string, key: string}): Promise> { + // load addresses from cloudflare kv + const response = await fetch( + args.endpoint, + { + headers: { "X-Custom-PSK": args.key }, + } + ); + const text = await response.text(); + const wallets = JSON.parse(text) as Array; + const claimers = wallets.map(({ ip, wallet }) => ({ + user_id: identifier, + alias: ip, + wallet: wallet, + })); + + return claimers as Array; +}