├── .eslintrc.js ├── .prettierrc.js ├── stashes.example.json ├── key.example.txt ├── tsconfig.json ├── .yarnrc.yml ├── Dockerfile ├── .github └── dependabot.yml ├── src ├── logger.ts ├── isValidSeed.ts ├── index.ts ├── handlers.ts └── services.ts ├── package.json ├── .gitignore ├── README.md └── LICENSE /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@substrate/dev/config/eslint'); -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@substrate/dev/config/prettier'); -------------------------------------------------------------------------------- /stashes.example.json: -------------------------------------------------------------------------------- 1 | [ 2 | "15Jbynf3EcRqdHV1K14LXYh7PQFTbp5wiXfrc4kbMReR9KxA" 3 | ] 4 | -------------------------------------------------------------------------------- /key.example.txt: -------------------------------------------------------------------------------- 1 | bottom drive obey lake curtain smoke basket hold race lonely fit walk//Alice 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@substrate/dev/config/tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./build", 5 | } 6 | } -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: mixed 2 | 3 | enableGlobalCache: false 4 | 5 | enableImmutableInstalls: false 6 | 7 | enableProgressBars: false 8 | 9 | logFilters: 10 | - code: YN0013 11 | level: discard 12 | 13 | nodeLinker: node-modules 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:current-alpine 2 | 3 | RUN apk add --no-cache python3 make g++ 4 | 5 | COPY . /app 6 | WORKDIR /app 7 | 8 | RUN npm install -g corepack --force && corepack enable && yarn install 9 | RUN yarn build 10 | 11 | ENTRYPOINT ["node", "/app/build/index.js"] 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Enable version updates for npm 4 | - package-ecosystem: "npm" 5 | # Look for `package.json` and `lock` files in the `root` directory 6 | directory: "/" 7 | # Check the npm registry for updates every day (weekdays) 8 | schedule: 9 | interval: "daily" 10 | open-pull-requests-limit: 10 -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, format, transports } from 'winston'; 2 | 3 | const myFormat = format.printf(({ level, message, label, timestamp }) => { 4 | return `${timestamp} [${label}] ${level}: ${message}`; 5 | }); 6 | 7 | export const log = createLogger({ 8 | level: 'debug', 9 | format: format.combine( 10 | format.label({ label: 'payouts' }), 11 | format.timestamp({ 12 | format: 'YYYY-MM-DD HH:mm:ss', 13 | }), 14 | format.errors({ stack: true }), 15 | format.colorize({ all: false }), 16 | myFormat 17 | ), 18 | transports: [new transports.Console()], 19 | }); 20 | -------------------------------------------------------------------------------- /src/isValidSeed.ts: -------------------------------------------------------------------------------- 1 | import { isHex } from '@polkadot/util'; 2 | import { keyExtractSuri, mnemonicValidate } from '@polkadot/util-crypto'; 3 | 4 | import { log } from './logger'; 5 | 6 | const SEED_LENGTHS = [12, 15, 18, 21, 24]; 7 | 8 | /** 9 | * Validate a mnemonic or hex seed. 10 | * 11 | * @source https://github.com/polkadot-js/tools/blob/31375f26804f5e3658d55981ff4531d3e5d77517/packages/signer-cli/src/cmdSign.ts#L14-L31 12 | */ 13 | export function isValidSeed(suri: string): boolean { 14 | const { phrase } = keyExtractSuri(suri); 15 | 16 | if (isHex(phrase)) { 17 | if (!isHex(phrase, 256)) { 18 | log.error('Hex seed needs to be 256-bits'); 19 | return false; 20 | } 21 | } else { 22 | if (!SEED_LENGTHS.includes((phrase as string).split(' ').length)) { 23 | log.error(`Mnemonic needs to contain ${SEED_LENGTHS.join(', ')} words`); 24 | return false; 25 | } 26 | 27 | if (!mnemonicValidate(phrase)) { 28 | log.error('Not a valid mnemonic seed'); 29 | return false; 30 | } 31 | } 32 | 33 | return true; 34 | } 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zekemostov/staking-payouts", 3 | "version": "1.6.0", 4 | "description": "CLI to make staking payout transactions for Substrate FRAME-based chains", 5 | "bin": { 6 | "payouts": "build/index.js" 7 | }, 8 | "repository": "https://github.com/emostov/staking-payouts.git", 9 | "author": "emostov ", 10 | "bugs": { 11 | "url": "https://github.com/emostov/staking-payouts/issues" 12 | }, 13 | "engines": { 14 | "node": ">=14" 15 | }, 16 | "files": [ 17 | "build" 18 | ], 19 | "homepage": "https://github.com/emostov/staking-payouts#readme", 20 | "license": "APACHE-2.0", 21 | "publishConfig": { 22 | "access": "public", 23 | "registry": "https://registry.npmjs.org" 24 | }, 25 | "scripts": { 26 | "deploy": "rm -rf build/ && yarn build && yarn publish && git push --tags && git push", 27 | "lint": "eslint", 28 | "build": "tsc -p tsconfig.json" 29 | }, 30 | "devDependencies": { 31 | "@substrate/dev": "^0.7.1", 32 | "@types/node": "^20.14.10", 33 | "@types/yargs": "^17.0.32", 34 | "typescript": "^5.5.3" 35 | }, 36 | "dependencies": { 37 | "@polkadot/api": "^12.2.1", 38 | "ansi-regex": "^6.0.1", 39 | "winston": "^3.13.1", 40 | "yargs": "^17.7.2" 41 | }, 42 | "resolutions": { 43 | "ansi-regex": "^5.0.1", 44 | "tmpl": "1.0.5" 45 | }, 46 | "packageManager": "yarn@4.5.3" 47 | } 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.txt 2 | !key.example.txt 3 | stashes.kusama.json 4 | !stashes.example.json 5 | stashes.[a-z]*.json 6 | /build 7 | .DS_Store 8 | 9 | # Logs 10 | logs 11 | *.log 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | lerna-debug.log* 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 19 | 20 | # Runtime data 21 | pids 22 | *.pid 23 | *.seed 24 | *.pid.lock 25 | 26 | # Directory for instrumented libs generated by jscoverage/JSCover 27 | lib-cov 28 | 29 | # Coverage directory used by tools like istanbul 30 | cover 31 | *.lcov 32 | 33 | # nyc test coverage 34 | .nyc_output 35 | 36 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | bower_components 41 | 42 | # node-waf configuration 43 | .lock-wscript 44 | 45 | # Compiled binary addons (https://nodejs.org/api/addons.html) 46 | build/Release 47 | 48 | # Dependency directories 49 | node_modules/ 50 | jspm_packages/ 51 | 52 | # TypeScript v1 declaration files 53 | typings/ 54 | 55 | # TypeScript cache 56 | *.tsbuildinfo 57 | 58 | # Optional npm cache directory 59 | .npm 60 | 61 | # Optional eslint cache 62 | .eslintcache 63 | 64 | # Microbundle cache 65 | .rpt2_cache/ 66 | .rts2_cache_cjs/ 67 | .rts2_cache_es/ 68 | .rts2_cache_umd/ 69 | 70 | # Optional REPL history 71 | .node_repl_history 72 | 73 | # Output of 'npm pack' 74 | *.tgz 75 | 76 | # Yarn Integrity file 77 | .yarn-integrity 78 | 79 | # dotenv environment variables file 80 | .env 81 | .env.test 82 | 83 | # parcel-bundler cache (https://parceljs.org/) 84 | .cache 85 | 86 | # Next.js build output 87 | .next 88 | 89 | # Nuxt.js build / generate output 90 | .nuxt 91 | dist 92 | 93 | # Gatsby files 94 | .cache/ 95 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 96 | # https://nextjs.org/blog/next-9-1#public-directory-support 97 | # public 98 | 99 | # vuepress build output 100 | .vuepress/dist 101 | 102 | # Serverless directories 103 | .serverless/ 104 | 105 | # FuseBox cache 106 | .fusebox/ 107 | 108 | # DynamoDB Local files 109 | .dynamodb/ 110 | 111 | # TernJS port file 112 | .tern-port 113 | 114 | .DS_Store 115 | 116 | 117 | # Yarn berry 118 | .yarn/* 119 | !.yarn/releases 120 | !.yarn/plugins 121 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable @typescript-eslint/no-misused-promises */ 3 | /* eslint-disable @typescript-eslint/await-thenable */ 4 | 5 | import yargs from 'yargs'; 6 | 7 | import { collect, commission, ls, lsNominators } from './handlers'; 8 | import { log } from './logger'; 9 | 10 | async function main() { 11 | await yargs 12 | .options({ 13 | ws: { 14 | alias: 'w', 15 | description: 16 | 'The API endpoint to connect to, e.g. wss://kusama-rpc.polkadot.io', 17 | string: true, 18 | demandOption: true, 19 | global: true, 20 | }, 21 | stashesFile: { 22 | alias: 'S', 23 | description: 24 | 'Path to .json file containing an array of the stash addresses to call payouts for.', 25 | string: true, 26 | demandOption: false, 27 | global: true, 28 | }, 29 | stashes: { 30 | alias: 's', 31 | description: 32 | 'Array of stash addresses to call payouts for. Required if not using stashesFile.', 33 | array: true, 34 | demandOption: false, 35 | global: true, 36 | }, 37 | eraDepth: { 38 | alias: 'e', 39 | description: 40 | 'How many eras prior to the last collected era to check for uncollected payouts.', 41 | number: true, 42 | demandOption: false, 43 | default: 0, 44 | global: true, 45 | }, 46 | }) 47 | .command( 48 | ['collect', '$0'], 49 | 'Collect pending payouts', 50 | // @ts-ignore 51 | (yargs) => { 52 | return yargs.options({ 53 | suriFile: { 54 | alias: 'u', 55 | description: 'Path to .txt file containing private key seed.', 56 | string: true, 57 | demandOption: true, 58 | }, 59 | }); 60 | }, 61 | // @ts-ignore 62 | collect 63 | ) 64 | // @ts-ignore 65 | .command('ls', 'List pending payouts', {}, ls) 66 | .command( 67 | 'lsNominators', 68 | 'List nominators backing the given stashes', 69 | {}, 70 | // @ts-ignore- 71 | lsNominators 72 | ) 73 | .command( 74 | 'commission', 75 | 'List validators with commission under and above the given value', 76 | // @ts-ignore 77 | (yargs) => { 78 | return yargs.options({ 79 | percent: { 80 | alias: 'p', 81 | description: 'Commission, expressed as a percent i.e "10" for 10%', 82 | number: true, 83 | demandOption: true, 84 | }, 85 | }); 86 | }, 87 | commission 88 | ) 89 | .parse(); 90 | } 91 | 92 | main() 93 | .then(() => { 94 | log.info('Exiting ...'); 95 | process.exit(0); 96 | }) 97 | .catch((err) => { 98 | log.error(err); 99 | process.exit(1); 100 | }); 101 | -------------------------------------------------------------------------------- /src/handlers.ts: -------------------------------------------------------------------------------- 1 | import { ApiPromise, WsProvider } from '@polkadot/api'; 2 | import fs from 'fs'; 3 | 4 | import { isValidSeed } from './isValidSeed'; 5 | import { log } from './logger'; 6 | import { 7 | collectPayouts, 8 | commissionData, 9 | listNominators, 10 | listPendingPayouts, 11 | } from './services'; 12 | 13 | const DEBUG = process.env.PAYOUTS_DEBUG; 14 | 15 | interface HandlerArgs { 16 | suriFile: string; 17 | ws: string; 18 | stashesFile?: string; 19 | stashes?: (string | number)[]; 20 | eraDepth: number; 21 | percent?: number; 22 | } 23 | 24 | export async function collect({ 25 | suriFile, 26 | ws, 27 | stashesFile, 28 | stashes, 29 | eraDepth, 30 | }: HandlerArgs): Promise { 31 | DEBUG && log.debug(`suriFile: ${suriFile}`); 32 | let suriData; 33 | try { 34 | suriData = fs.readFileSync(suriFile, 'utf-8'); 35 | } catch (e) { 36 | log.error('Suri file could not be opened'); 37 | throw e; 38 | } 39 | const suri = suriData.split(/\r?\n/)[0]; 40 | if (!suri) { 41 | throw Error('No suri could be read in from file.'); 42 | } 43 | if (!isValidSeed(suri)) { 44 | throw Error('Suri is invalid'); 45 | } 46 | 47 | const stashesParsed = parseStashes(stashesFile, stashes); 48 | if (!stashesParsed) return; 49 | DEBUG && log.debug(`Parsed stash address: ${stashesParsed.join(', ')}`); 50 | 51 | const provider = new WsProvider(ws); 52 | const api = await ApiPromise.create({ 53 | provider, 54 | }); 55 | 56 | await collectPayouts({ 57 | api, 58 | suri, 59 | stashes: stashesParsed, 60 | eraDepth, 61 | }); 62 | } 63 | 64 | export async function ls({ 65 | ws, 66 | stashesFile, 67 | stashes, 68 | eraDepth, 69 | }: Omit): Promise { 70 | const stashesParsed = parseStashes(stashesFile, stashes); 71 | DEBUG && log.debug(`Parsed stash address: ${stashesParsed.join(', ')}`); 72 | 73 | const provider = new WsProvider(ws); 74 | const api = await ApiPromise.create({ 75 | provider, 76 | }); 77 | 78 | await listPendingPayouts({ 79 | api, 80 | stashes: stashesParsed, 81 | eraDepth, 82 | }); 83 | } 84 | 85 | export async function lsNominators({ 86 | ws, 87 | stashes, 88 | stashesFile, 89 | }: Omit): Promise { 90 | const stashesParsed = parseStashes(stashesFile, stashes); 91 | DEBUG && log.debug(`Parsed stash address: ${stashesParsed.join(', ')}`); 92 | 93 | const provider = new WsProvider(ws); 94 | const api = await ApiPromise.create({ 95 | provider, 96 | }); 97 | 98 | await listNominators({ 99 | api, 100 | stashes: stashesParsed, 101 | }); 102 | } 103 | 104 | export async function commission({ 105 | ws, 106 | percent, 107 | }: Omit< 108 | HandlerArgs, 109 | 'stashes' | 'stashesFile' | 'suri' | 'eraDepth' 110 | >): Promise { 111 | const provider = new WsProvider(ws); 112 | const api = await ApiPromise.create({ 113 | provider, 114 | }); 115 | 116 | if (!(typeof percent === 'number')) { 117 | console.log('typeof commision', typeof percent); 118 | log.warn('Internal error proccessing CLI args'); 119 | process.exit(1); 120 | } 121 | 122 | await commissionData(api, percent); 123 | } 124 | 125 | export function parseStashes( 126 | stashesFile?: string, 127 | stashes?: (string | number)[] 128 | ): string[] { 129 | let stashesParsed: string[]; 130 | if (stashesFile) { 131 | let stashesData; 132 | try { 133 | stashesData = fs.readFileSync(stashesFile); 134 | } catch (e) { 135 | log.error('Stashes file could not be opened'); 136 | throw e; 137 | } 138 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 139 | stashesParsed = JSON.parse(stashesData as unknown as string); 140 | if (!Array.isArray(stashesParsed)) { 141 | throw Error('The stash addresses must be in a JSON file as an array.'); 142 | } 143 | } else if (Array.isArray(stashes)) { 144 | stashesParsed = stashes as string[]; 145 | } else { 146 | throw Error( 147 | 'You must provide a list of stashes with the --stashes or --stashesFile opton.' 148 | ); 149 | } 150 | 151 | return stashesParsed; 152 | } 153 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

@zekemostov/staking-payouts

3 |

💸 CLI to make staking payout transactions for Substrate FRAME-based chains 💸

4 |

🤖 Automation friendly 🤖

5 |

🧱⛓💰🚀

6 | 7 |

8 | 9 | npm 10 | 11 | 12 | GPL-3.0-or-later 13 | 14 |

15 |
16 | 17 |

18 | 19 | ### Table of contents 20 | 21 | - [About](#about) 22 | - [Usage notes](#usage-notes) 23 | - [Getting started](#getting-started) 24 | - [Options](#options) 25 | - [Docker](#docker) 26 | - [Debugging](#debugging) 27 | - [Questions, feature request, or bug reports](#questions-feature-request-or-bug-reports) 28 | - [Substrate chain assumptions](#substrate-chain-assumptions) 29 | - [Support this project](#support-this-project) 30 | 31 | ### About 32 | 33 | This simple tool enables you to create a batch of payout transactions for a given list of validators and/or nominators - automating the process of gathering unclaimed rewards. 34 | 35 | For each validator it finds the last era where they collected payouts and then creates payout transactions for the eras that have ocurred since and for which they where in the validator set. If you think there are un-paid out eras prior to the last payed out, you can also specify a `eraDepth`; the tool will check `lastPayedOutEra` through `lastPayedOutEra - eraDepth` to see if there are any eras where they where in the validator set and payouts have not been collected. 36 | 37 | #### Motivation 38 | 39 | Have a large list of validators and/or nominators you want to get payouts for? 40 | > Put their addresses in a JSON file once and simply run this program each time you want to collect. 41 | 42 | Want to automate the payout gather process? 43 | > Using something like systemd.timers or cron, run this program at regular intervals. Plus, its already docker ready! 44 | 45 | ### Usage Notes 46 | 47 | #### Perquisites 48 | 49 | - node.js > 14 50 | 51 | #### Updating 52 | 53 | Substrate chains use a non self describing codec, meaning clients that communicate with the chain need type definitions to decode the data. Some runtime upgrades require new type definitions which may affect this CLI. 54 | 55 | Thus, it is recommended to upgrade this CLI to the latest version prior to runtime upgrade in order to ensure it will always have the latest type definitions from polkadot-js/api. 56 | 57 | ## Getting started 58 | 59 | ### Install 60 | 61 | ```bash 62 | # NPM 63 | npm install -G @zekemostov/staking-payouts 64 | 65 | # Github 66 | git clone https://github.com/emostov/staking-payouts.git 67 | cd staking-payouts 68 | # Note depending on the version of Node being used, corepack might need to be enabled which will be prompted in the terminal. 69 | yarn install 70 | yarn run build 71 | ``` 72 | 73 | ### Collect unclaimed payouts 74 | 75 | ```bash 76 | # NPM 77 | payouts collect \ 78 | -w wss://kusama.api.onfinality.io/public \ 79 | -s 15Jbynf3EcRqdHV1K14LXYh7PQFTbp5wiXfrc4kbMReR9KxA \ 80 | -u ./key.example.txt \ 81 | -e 8 82 | 83 | # Github 84 | node build/index.js collect \ 85 | -w wss://kusama.api.onfinality.io/public \ 86 | -s 15Jbynf3EcRqdHV1K14LXYh7PQFTbp5wiXfrc4kbMReR9KxA \ 87 | -u ./key.example.txt \ 88 | -e 8 89 | ``` 90 | 91 | **NOTE:** you can also specify a json file with an array of validator stash addresses: 92 | 93 | ```bash 94 | payouts collect \ 95 | -w wss://kusama.api.onfinality.io/public \ 96 | --stashesFile ./stashes.example.json \ 97 | --suriFile ./key.example.txt 98 | ``` 99 | 100 | ### List unclaimed payouts 101 | 102 | ```bash 103 | payouts ls \ 104 | -w wss://kusama.api.onfinality.io/public \ 105 | --stashesFile ./stashes.example.json \ 106 | -e 8 107 | ``` 108 | 109 | ### List nominators of the given stash addresses in order of bonded funds 110 | 111 | ```bash 112 | payouts lsNominators \ 113 | -w wss://rpc.polkadot.io \ 114 | -s 111B8CxcmnWbuDLyGvgUmRezDCK1brRZmvUuQ6SrFdMyc3S \ 115 | ``` 116 | 117 | ### List count of validator's commission under and above the given value 118 | 119 | ```bash 120 | payouts commission \ 121 | -w wss://rpc.polkadot.io \ 122 | -p 0.9 123 | ``` 124 | 125 | ## Options 126 | 127 | ```log 128 | Commands: 129 | index.ts collect Collect pending payouts [default] 130 | index.ts ls List pending payouts 131 | index.ts lsNominators List nominators backing the given stashes 132 | index.ts commission List validators with commission under and above the 133 | given value 134 | 135 | Options: 136 | --help Show help [boolean] 137 | --version Show version number [boolean] 138 | -w, --ws The API endpoint to connect to, e.g. 139 | wss://kusama-rpc.polkadot.io [string] [required] 140 | -S, --stashesFile Path to .json file containing an array of the stash 141 | addresses to call payouts for. [string] 142 | -s, --stashes Array of stash addresses to call payouts for. Required if 143 | not using stashesFile. [array] 144 | -e, --eraDepth How many eras prior to the last collected era to check for 145 | uncollected payouts. [number] [default: 0] 146 | -u, --suriFile [string] [required] 147 | ``` 148 | 149 | **NOTES:** 150 | 151 | - `collect` is the default command and as such can be omitted. 152 | - `--suriFile` is only require for the `collect` command. 153 | - `lsNominators` only requires a stash address and api endpoint. 154 | 155 | ## Docker 156 | 157 | ### Build 158 | 159 | ```bash 160 | docker build -t payouts . 161 | ``` 162 | 163 | ### Run 164 | 165 | ```bash 166 | docker run payouts collect \ 167 | -w wss://kusama.api.onfinality.io/public \ 168 | -s GCporqtiw7ybKYUqAftjvUAjZnp3x9gfrWsTy1GrvrGwmYT \ 169 | -u ./key.example.txt 170 | ``` 171 | 172 | ## Debugging 173 | 174 | In order to get debug log messages you can set `PAYOUTS_DEBUG=1`. 175 | 176 | ## Questions, feature request, or bug reports 177 | 178 | If you have a question, feature request or believe you found a bug please open up a issue in the github repo. All feedback is appreciated. 179 | 180 | ## Substrate chain assumptions 181 | 182 | The chain must be `FRAME`-based and use the substrate staking pallet. 183 | 184 | ## Support this project 185 | 186 | - 👩‍💻 Contribute to the docs or code 187 | - ⭐️ Star the github repo 188 | - 🗳 Nominate (or tip) me! 189 | - **polkadot**: 13zBFyK97dg4hWjXwEpigeVdu69sHa4fc8JYegpB369PAafq 190 | - **kusama**: GCporqtiw7ybKYUqAftjvUAjZnp3x9gfrWsTy1GrvrGwmYT 191 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/services.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/restrict-template-expressions */ 2 | import { ApiPromise, Keyring } from '@polkadot/api'; 3 | import { SubmittableExtrinsic } from '@polkadot/api/submittable/types'; 4 | import { Option, Vec } from '@polkadot/types/codec'; 5 | import { u32 } from '@polkadot/types/primitive'; 6 | import { 7 | AccountId, 8 | ActiveEraInfo, 9 | Balance, 10 | Exposure, 11 | EraRewardPoints, 12 | StakingLedger, 13 | Nominations, 14 | ValidatorPrefs, 15 | } from '@polkadot/types/interfaces'; 16 | import { Codec, ISubmittableResult } from '@polkadot/types/types'; 17 | import { cryptoWaitReady } from '@polkadot/util-crypto'; 18 | import BN from 'bn.js'; 19 | 20 | import { log } from './logger'; 21 | 22 | const DEBUG = process.env.PAYOUTS_DEBUG; 23 | const MAX_CALLS = 3; 24 | 25 | export interface ServiceArgs { 26 | api: ApiPromise; 27 | suri: string; 28 | stashes: string[]; 29 | eraDepth: number; 30 | } 31 | 32 | interface NominatorInfo { 33 | nominatorId: string; 34 | voteWeight: Balance; 35 | targets: string[]; 36 | } 37 | 38 | type NominatorWeight = Omit; 39 | type NominatorTargets = Omit; 40 | 41 | /** 42 | * Gather uncollected payouts for each validator, checking each era since there 43 | * last claimed payout, and creating a `batch` tx with `payoutStakers` txs. 44 | * 45 | * Additionally check `eraDepth` number of eras prior to the era of the last 46 | * claimed payout. This can help in the (potentially malicious) scenario where 47 | * someone may have claimed payouts for a recent era, but left some prior eras 48 | * with unclaimed rewards. 49 | * 50 | * If there are no payouts does not create tx. 51 | * If there is only one tx, it optimizes and just sends that lone tx. 52 | * 53 | * 54 | * @param collectionOptions 55 | * @returns Promise 56 | */ 57 | export async function collectPayouts({ 58 | api, 59 | suri, 60 | stashes, 61 | eraDepth, 62 | }: ServiceArgs): Promise { 63 | const payouts = await listPendingPayouts({ 64 | stashes, 65 | eraDepth, 66 | api, 67 | }); 68 | 69 | if (!payouts || !payouts.length) { 70 | log.info('No payouts to claim'); 71 | return; 72 | } 73 | 74 | log.info( 75 | `Transactions are being created. This may take some time if there are many unclaimed eras.` 76 | ); 77 | 78 | await signAndSendTxs(api, payouts, suri); 79 | } 80 | 81 | export async function payoutClaimedForAddressForEra(api: ApiPromise, stashAddress: string, eraIndex: number): Promise { 82 | const claimed = (await api.query.staking.claimedRewards>(eraIndex, stashAddress)).length > 0; 83 | if (claimed) { 84 | // payout already issued 85 | return true; 86 | } 87 | const exposureForEra = await api.query.staking.erasStakersOverview>(eraIndex, stashAddress); 88 | return exposureForEra.isNone; 89 | } 90 | 91 | export async function listPendingPayouts({ 92 | api, 93 | stashes, 94 | eraDepth, 95 | }: Omit): Promise< 96 | SubmittableExtrinsic<'promise', ISubmittableResult>[] | null 97 | > { 98 | const activeInfoOpt = await api.query.staking.activeEra< 99 | Option 100 | >(); 101 | if (activeInfoOpt.isNone) { 102 | log.warn('ActiveEra is None, pending payouts could not be fetched.'); 103 | return null; 104 | } 105 | const currEra = activeInfoOpt.unwrap().index.toNumber(); 106 | 107 | // Get all the validator address to get payouts for 108 | const validatorStashes = []; 109 | for (const stash of stashes) { 110 | const maybeNominations = await api.query.staking.nominators< 111 | Option 112 | >(stash); 113 | if (maybeNominations.isSome) { 114 | const targets = maybeNominations.unwrap().targets.map((a) => a.toHuman()); 115 | DEBUG && 116 | log.debug( 117 | `Nominator address detected: ${stash}. Adding its targets: ${targets.join( 118 | ', ' 119 | )}` 120 | ); 121 | validatorStashes.push(...targets); 122 | } else { 123 | DEBUG && log.debug(`Validator address detected: ${stash}`); 124 | validatorStashes.push(stash); 125 | } 126 | } 127 | 128 | // Get pending payouts for the validator addresses 129 | const payouts = []; 130 | for (const stash of validatorStashes) { 131 | const controllerOpt = await api.query.staking.bonded>( 132 | stash 133 | ); 134 | if (controllerOpt.isNone) { 135 | log.warn(`${stash} is not a valid stash address.`); 136 | continue; 137 | } 138 | 139 | const controller = controllerOpt.unwrap(); 140 | // Check for unclaimed payouts from `current-eraDepth` to `current` era 141 | // The current era is not claimable. Therefore we need to substract by 1. 142 | for (let e = (currEra - 1) - eraDepth; e < (currEra - 1); e++) { 143 | const payoutClaimed = await payoutClaimedForAddressForEra(api, controller.toString(), e); 144 | if (payoutClaimed) { 145 | continue; 146 | } 147 | // Check if they received points that era 148 | if (await hasEraPoints(api, stash, e)) { 149 | const payoutStakes = api.tx.staking.payoutStakers(stash, e); 150 | payouts.push(payoutStakes); 151 | } 152 | } 153 | } 154 | 155 | if (payouts.length) { 156 | log.info( 157 | `The following unclaimed payouts where found: \n${payouts 158 | .map( 159 | ({ method: { section, method, args } }) => 160 | `${section}.${method}(${ 161 | args.map ? args.map((a) => `${a.toHuman()}`).join(', ') : args 162 | })` 163 | ) 164 | .join('\n')}` 165 | ) && 166 | log.info(`Total of ${payouts.length} unclaimed payouts.`); 167 | } else { 168 | log.info(`No payouts found.`) 169 | } 170 | 171 | return payouts; 172 | } 173 | 174 | export async function listNominators({ 175 | api, 176 | stashes, 177 | }: Omit): Promise { 178 | log.info('Querrying for nominators ...'); 179 | // Query for the nominators and make the data easy to use 180 | const nominatorEntries = await api.query.staking.nominators.entries< 181 | Option, 182 | [AccountId] 183 | >(); 184 | const nominatorWithTargets = nominatorEntries 185 | .filter(([_, noms]) => { 186 | return noms.isSome && noms.unwrap().targets?.length; 187 | }) 188 | .map(([key, noms]) => { 189 | return { 190 | nominatorId: key.args[0].toHuman(), 191 | targets: noms.unwrap().targets.map((a) => a.toHuman()), 192 | }; 193 | }); 194 | 195 | // Create a map of validator stash to arrays. The arrays will eventually be filled with nominators 196 | // backing them. 197 | const validatorMap = stashes.reduce((acc, cur) => { 198 | acc[cur] = []; 199 | return acc; 200 | }, {} as Record); 201 | 202 | // Find the nominators who are backing the given stashes 203 | const nominatorsBackingOurStashes = nominatorWithTargets.reduce( 204 | (acc, { nominatorId, targets }) => { 205 | for (const val of targets) { 206 | if (validatorMap[val] !== undefined) { 207 | acc.push({ nominatorId, targets }); 208 | return acc; 209 | } 210 | } 211 | 212 | return acc; 213 | }, 214 | [] as NominatorTargets[] 215 | ); 216 | 217 | log.info('Querrying for the ledgers of nominators ...'); 218 | // Query for the ledgers of the nominators we care about 219 | const withLedgers = await Promise.all( 220 | nominatorsBackingOurStashes.map(({ nominatorId, targets }) => 221 | api.query.staking 222 | .bonded>(nominatorId) 223 | .then((controller) => { 224 | if (controller.isNone) { 225 | log.warn( 226 | `An error occured while unwrapping the controller for ${nominatorId}. ` + 227 | 'Please file an issue at: https://github.com/canontech/staking-payouts/issues' 228 | ); 229 | process.exit(1); 230 | } 231 | return api.query.staking 232 | .ledger>(controller.unwrap().toHex()) 233 | .then((ledger) => { 234 | if (ledger.isNone) { 235 | log.warn( 236 | `An error occured while unwrapping a ledger for ${nominatorId}. ` + 237 | 'Please file an issue at: https://github.com/canontech/staking-payouts/issues' 238 | ); 239 | process.exit(1); 240 | } 241 | return { 242 | voteWeight: ledger.unwrap().active.unwrap(), 243 | nominatorId, 244 | targets, 245 | }; 246 | }); 247 | }) 248 | ) 249 | ); 250 | 251 | log.info('Finishing up some final computation ...'); 252 | 253 | // Add the nominators to the `validatorMap` 254 | withLedgers.reduce((acc, { nominatorId, targets, voteWeight }) => { 255 | for (const validator of targets) { 256 | if (acc[validator]) { 257 | acc[validator].push({ nominatorId, voteWeight }); 258 | } 259 | } 260 | 261 | return acc; 262 | }, validatorMap); 263 | 264 | // Sort the nominators in place and print them out 265 | Object.entries(validatorMap).forEach(([validator, nominators]) => { 266 | const nominatorsDisplay = nominators 267 | .sort((a, b) => a.voteWeight.cmp(b.voteWeight)) 268 | .reverse() 269 | .reduce((acc, { nominatorId, voteWeight }, index) => { 270 | const start = `${index}) `.padStart(8, ' '); 271 | const middle = `${nominatorId},`.padEnd(50, ' '); 272 | return acc + start + middle + `${voteWeight.toHuman()}\n`; 273 | }, ''); 274 | 275 | log.info( 276 | `\nValidator ${validator} has the following nominations:\n` + 277 | nominatorsDisplay 278 | ); 279 | }); 280 | } 281 | 282 | export async function commissionData( 283 | api: ApiPromise, 284 | mid: number 285 | ): Promise { 286 | const tenMillion = new BN(10_000_000); 287 | const midPer = new BN(mid).mul(tenMillion); 288 | 289 | const validators = await api.query.staking.validators.entries< 290 | ValidatorPrefs, 291 | [AccountId] 292 | >(); 293 | 294 | const activeInfoOpt = await api.query.staking.activeEra< 295 | Option 296 | >(); 297 | if (activeInfoOpt.isNone) { 298 | process.exit(1); 299 | } 300 | const currEra = activeInfoOpt.unwrap().index.toNumber() - 2; 301 | 302 | const greaterThanOrBlockedActive = []; 303 | const greaterThanOrBlockedWaiting = []; 304 | const lessThanActive = []; 305 | const lessThanWaiting = []; 306 | let sum = new BN(0); 307 | 308 | // For each validator determine if they are in the active set 309 | const withStatus = await Promise.all( 310 | validators.map(([valIdKey, prefs]) => { 311 | const commission = prefs.commission.unwrap(); 312 | const valId = valIdKey.args[0].toString(); 313 | 314 | sum = sum.add(commission); 315 | return isValidatingInEra(api, valId, currEra).then((isActive) => { 316 | return { isActive, valId, prefs }; 317 | }); 318 | }) 319 | ); 320 | 321 | // Go through validators and determine if their commision is greter than or less than than the 322 | // given commission (`mid`) 323 | withStatus.forEach((cur) => { 324 | const { prefs, isActive } = cur; 325 | const commission = prefs.commission.unwrap(); 326 | sum = sum.add(commission); 327 | 328 | if (prefs.blocked.isTrue || commission.gt(midPer)) { 329 | if (isActive) { 330 | greaterThanOrBlockedActive.push(commission); 331 | } else { 332 | greaterThanOrBlockedWaiting.push(commission); 333 | } 334 | } else { 335 | if (isActive) { 336 | lessThanActive.push(commission); 337 | } else { 338 | lessThanWaiting.push(commission); 339 | } 340 | } 341 | }); 342 | 343 | const sortedCommision = validators 344 | .map(([_, prefs]) => prefs.commission.unwrap()) 345 | .sort((a, b) => a.cmp(b)); 346 | const mean = sortedCommision[sortedCommision.length / 2]; 347 | const allAvg = sum.div(new BN(validators.length)); 348 | 349 | log.info(`average (floor): ${allAvg.div(tenMillion)}%`); 350 | log.info(`mean (floor): ${mean.div(tenMillion)}%`); 351 | log.info( 352 | `active validators blocked or commission > ${mid}%: ${greaterThanOrBlockedActive.length}` 353 | ); 354 | log.info( 355 | `active validators with commission <= ${mid}%: ${lessThanActive.length}` 356 | ); 357 | log.info( 358 | `waiting validators blocked or commission > ${mid}%: ${greaterThanOrBlockedWaiting.length}` 359 | ); 360 | log.info( 361 | `waiting validators with commission <= ${mid}%: ${lessThanWaiting.length}` 362 | ); 363 | } 364 | 365 | async function isValidatingInEra( 366 | api: ApiPromise, 367 | stash: string, 368 | eraToCheck: number 369 | ): Promise { 370 | try { 371 | const exposure = await api.query.staking.erasStakers( 372 | eraToCheck, 373 | stash 374 | ); 375 | // If their total exposure is greater than 0 they are validating in the era. 376 | return exposure.total.toBn().gtn(0); 377 | } catch { 378 | return false; 379 | } 380 | } 381 | 382 | async function hasEraPoints( 383 | api: ApiPromise, 384 | stash: string, 385 | eraToCheck: number 386 | ): Promise { 387 | try { 388 | const rewardpoints = 389 | await api.query.staking.erasRewardPoints(eraToCheck); 390 | let found = false; 391 | rewardpoints.individual.forEach((_record, validator) => { 392 | if (stash === validator.toString()) found = true; 393 | }); 394 | return found; 395 | } catch { 396 | return false; 397 | } 398 | } 399 | 400 | async function signAndSendTxs( 401 | api: ApiPromise, 402 | payouts: SubmittableExtrinsic<'promise', ISubmittableResult>[], 403 | suri: string 404 | ) { 405 | await cryptoWaitReady(); 406 | const keyring = new Keyring(); 407 | const signingKeys = keyring.createFromUri(suri, {}, 'sr25519'); 408 | 409 | DEBUG && 410 | log.debug( 411 | `Sender address: ${keyring.encodeAddress( 412 | signingKeys.address, 413 | api.registry.chainSS58 414 | )}` 415 | ); 416 | 417 | // Create batch calls of size `MAX_CALLS` or less 418 | const txs = payouts 419 | .reduce((byMaxCalls, tx, idx) => { 420 | if (idx % MAX_CALLS === 0) { 421 | byMaxCalls.push([]); 422 | } 423 | byMaxCalls[byMaxCalls.length - 1].push(tx); 424 | 425 | return byMaxCalls; 426 | }, [] as SubmittableExtrinsic<'promise'>[][]) 427 | .map((payoutTxs) => api.tx.utility.batch(payoutTxs)); 428 | 429 | DEBUG && 430 | log.debug( 431 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access 432 | `Calls per tx ${txs 433 | .map((t) => 434 | t.method.method.toLowerCase() == 'batch' 435 | ? (t.args[0] as Vec).length 436 | : 1 437 | ) 438 | .toString()}` 439 | ); 440 | 441 | // Send all the transactions 442 | log.info(`Getting ready to send ${txs.length} transactions.`); 443 | for (const [i, tx] of txs.entries()) { 444 | log.info( 445 | `Sending ${tx.method.section}.${tx.method.method} (tx ${i + 1}/${ 446 | txs.length 447 | })` 448 | ); 449 | 450 | DEBUG && 451 | tx.method.method.toLowerCase() === 'batch' && 452 | log.debug( 453 | `${tx.method.section}.${tx.method.method} has ${ 454 | (tx.method.args[0] as unknown as [])?.length 455 | } calls` 456 | ); 457 | 458 | try { 459 | const res = await tx.signAndSend(signingKeys, { nonce: -1 }); 460 | log.info(`Node response to tx: ${res.toString()}`); 461 | } catch (e) { 462 | log.error(`Tx failed to sign and send (tx ${i + 1}/${txs.length})`); 463 | throw e; 464 | } 465 | } 466 | } 467 | --------------------------------------------------------------------------------