├── payctl ├── __init__.py ├── __version__.py ├── payctl.py └── utils.py ├── runner.py ├── default.conf ├── LICENSE ├── setup.py ├── .gitignore └── README.md /payctl/__init__.py: -------------------------------------------------------------------------------- 1 | from .payctl import main -------------------------------------------------------------------------------- /payctl/__version__.py: -------------------------------------------------------------------------------- 1 | VERSION = (1, 1, 0) 2 | 3 | __version__ = '.'.join(map(str, VERSION)) 4 | -------------------------------------------------------------------------------- /runner.py: -------------------------------------------------------------------------------- 1 | from payctl.payctl import main 2 | 3 | if __name__ == '__main__': 4 | main() 5 | -------------------------------------------------------------------------------- /default.conf: -------------------------------------------------------------------------------- 1 | [Defaults] 2 | RPCURL = wss://kusama-rpc.polkadot.io/ 3 | Network = kusama 4 | DepthEras = 10 5 | MinEras = 5 6 | SigningAccount= 7 | SigningMnemonic= 8 | 9 | [GetZUSLFAaKorkQU8R67mA3mC15EpLRvk8199AB5DLbnb2E] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 STAKE LINK 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | from setuptools import setup 6 | 7 | NAME = 'payctl' 8 | DESCRIPTION = 'Simple command line application to control the payouts of Substrate validators (Polkadot and Kusama among others).' 9 | URL = 'https://github.com/stakelink/substrate-payctl' 10 | EMAIL = 'ops@stakelink.io' 11 | AUTHOR = 'STAKELINK' 12 | REQUIRES_PYTHON = '>=3.6.0' 13 | VERSION = None 14 | LICENSE = 'MIT' 15 | REQUIRED = [ 16 | 'substrate-interface>=0.13.3' 17 | ] 18 | 19 | here = os.path.abspath(os.path.dirname(__file__)) 20 | 21 | with open("README.md", "r", encoding="utf-8") as fh: 22 | LONG_DESCRIPTION = fh.read() 23 | 24 | about = {} 25 | if not VERSION: 26 | with open(os.path.join(here, NAME, '__version__.py')) as f: 27 | exec(f.read(), about) 28 | else: 29 | about['__version__'] = VERSION 30 | 31 | setup( 32 | name=NAME, 33 | version=about['__version__'], 34 | description=DESCRIPTION, 35 | long_description=LONG_DESCRIPTION, 36 | long_description_content_type="text/markdown", 37 | author=AUTHOR, 38 | author_email=EMAIL, 39 | python_requires=REQUIRES_PYTHON, 40 | url=URL, 41 | classifiers=[ 42 | "Programming Language :: Python :: 3 :: Only", 43 | "License :: OSI Approved :: MIT License", 44 | "Operating System :: OS Independent", 45 | ], 46 | packages=['payctl'], 47 | entry_points={ 48 | 'console_scripts': ['payctl=payctl:main'], 49 | }, 50 | data_files=[('etc/payctl', ['default.conf'])], 51 | install_requires=REQUIRED, 52 | license=LICENSE, 53 | project_urls={ 54 | 'Bug Reports': 'https://github.com/stakelink/substrate-payctl/issues', 55 | 'Source': 'https://github.com/stakelink/substrate-payctl', 56 | }, 57 | ) 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Subtrate Validator Pay Control 2 | 3 | Simple command line application to control the payouts of Substrate validators (Polkadot and Kusama among others). 4 | 5 | ## About 6 | 7 | [Substrate](https://substrate.dev/) is a modular framework that enables the creation of new blokchains by composing custom pre-build components, and it is the foundation on which some of the most important recent blockchains, such as [Polkadot](https://polkadot.network/) and [Kusama](https://kusama.network/), are built. 8 | 9 | Substrate provides an [Staking](https://substrate.dev/rustdocs/v3.0.0/pallet_staking/index.html) module that enables network protection through a NPoS (Nominated Proof-of-Stake) algorithm, where validators and nominators may stake funds as a gurantee to protect the network and in return they receive a reward. 10 | 11 | The paymemt of the reward to validators and nominators is not automatic, and must be triggered by activating the function **payout_stakers**. It can be done using a browser and the [Polkador.{js} app](https://polkadot.js.org/apps/), but this tool provides an alternative way to do it in the command line, which in turn facilitates the automation of recurring payments. 12 | 13 | ## Install 14 | 15 | Clone the repository and install the package: 16 | 17 | ``` 18 | git clone http://github.com/stakelink/substrate-payctl 19 | pip install substrate-payctl/ 20 | ``` 21 | 22 | NOTE: pip install argument is ambiguous at it can refer to a python package or a local folder. Make sure to include the '/' at the end to avoid ambiguity. 23 | 24 | 25 | ## Usage 26 | 27 | After installig the package the _payctl_ executable should be available on the system. 28 | 29 | ``` 30 | $ payctl 31 | usage: payctl [-h] [-c CONFIG] [-n NETWORK] [-r RPCURL] [-d DEPTHERAS] 32 | {list,pay} ... 33 | 34 | optional arguments: 35 | -h, --help show this help message and exit 36 | -c CONFIG, --config CONFIG 37 | specify config file 38 | -n NETWORK, --network NETWORK 39 | -r RPCURL, --rpc-url RPCURL 40 | -d DEPTHERAS, --depth-eras DEPTHERAS 41 | 42 | subcommands: 43 | {list,pay} 44 | list 45 | pay 46 | ``` 47 | 48 | ### Configuration 49 | 50 | The config contains default parameters, and a list of validators used by default: 51 | 52 | ``` 53 | [Defaults] 54 | RPCURL = wss://kusama-rpc.polkadot.io/ 55 | Network = kusama 56 | DepthEras = 10 57 | MinEras = 5 58 | SigningAccount= 59 | SigningMnemonic= 60 | 61 | [GetZUSLFAaKorkQU8R67mA3mC15EpLRvk8199AB5DLbnb2E] 62 | ``` 63 | 64 | The payment functionalities requires to sign the extrinsic. _SigningAccount_ is used to specify the account used for the signature, while the secret to generate the key could be specified in tree ways; _SigningMnemonic_, _SigningSeed_ or _SigningUri_. 65 | 66 | That information can also be provided on the command-line: 67 | 68 | ``` 69 | $ payctl pay --help 70 | usage: payctl pay [-h] [-m MINERAS] [-a SIGNINGACCOUNT] [-n SIGNINGMNEMONIC] 71 | [-s SIGNINGSEED] [-u SIGNINGURI] 72 | [validators [validators ...]] 73 | 74 | positional arguments: 75 | validators specify validator 76 | 77 | optional arguments: 78 | -h, --help show this help message and exit 79 | -m MINERAS, --min-eras MINERAS 80 | -a SIGNINGACCOUNT, --signing-account SIGNINGACCOUNT 81 | -n SIGNINGMNEMONIC, --signing-mnemonic SIGNINGMNEMONIC 82 | -s SIGNINGSEED, --signing-seed SIGNINGSEED 83 | -u SIGNINGURI, --signing-uri SIGNINGURI 84 | ``` 85 | 86 | Important security considerations: 87 | 88 | 1. Signing information must be secret. Be careful on not exposing the configuration file if it contains signing information. 89 | 2. Use accounts with limited balance to sign petitions in order to minimize the impact of secret's leak. 90 | 91 | 92 | ### Examples 93 | 94 | List rewards for default validators (NOTE: execution may take some time): 95 | 96 | ``` 97 | $ payctl list 98 | Era: 2020 99 | GetZUSLFAaKorkQU8R67mA3mC15EpLRvk8199AB5DLbnb2E => 0.321933155324 KSM (claimed) 100 | Era: 2021 101 | GetZUSLFAaKorkQU8R67mA3mC15EpLRvk8199AB5DLbnb2E => 0.646077588588 KSM (claimed) 102 | Era: 2022 103 | GetZUSLFAaKorkQU8R67mA3mC15EpLRvk8199AB5DLbnb2E => 0.965218253805 KSM (claimed) 104 | Era: 2023 105 | GetZUSLFAaKorkQU8R67mA3mC15EpLRvk8199AB5DLbnb2E => 0.804815899715 KSM (claimed) 106 | Era: 2024 107 | GetZUSLFAaKorkQU8R67mA3mC15EpLRvk8199AB5DLbnb2E => 0.645752264159 KSM (claimed) 108 | Era: 2025 109 | GetZUSLFAaKorkQU8R67mA3mC15EpLRvk8199AB5DLbnb2E => 1.291473497890 KSM (claimed) 110 | Era: 2026 111 | GetZUSLFAaKorkQU8R67mA3mC15EpLRvk8199AB5DLbnb2E => 0.643756878971 KSM (claimed) 112 | Era: 2027 113 | GetZUSLFAaKorkQU8R67mA3mC15EpLRvk8199AB5DLbnb2E => 0.323847362646 KSM (claimed) 114 | Era: 2028 115 | GetZUSLFAaKorkQU8R67mA3mC15EpLRvk8199AB5DLbnb2E => 0.160875886681 KSM (claimed) 116 | Era: 2029 117 | GetZUSLFAaKorkQU8R67mA3mC15EpLRvk8199AB5DLbnb2E => 0.803195226697 KSM (claimed) 118 | Era: 2030 119 | GetZUSLFAaKorkQU8R67mA3mC15EpLRvk8199AB5DLbnb2E => 1.445198542873 KSM (claimed) 120 | 121 | ``` 122 | 123 | List rewards for default validators including the last 30 eras: 124 | 125 | ``` 126 | $ payctl -d 30 list 127 | Era: 2000 128 | GetZUSLFAaKorkQU8R67mA3mC15EpLRvk8199AB5DLbnb2E => 0.636226339558 KSM (claimed) 129 | Era: 2001 130 | GetZUSLFAaKorkQU8R67mA3mC15EpLRvk8199AB5DLbnb2E => 0.951997041045 KSM (claimed) 131 | 132 | ... 133 | 134 | Era: 2029 135 | GetZUSLFAaKorkQU8R67mA3mC15EpLRvk8199AB5DLbnb2E => 0.803195226697 KSM (claimed) 136 | Era: 2030 137 | GetZUSLFAaKorkQU8R67mA3mC15EpLRvk8199AB5DLbnb2E => 1.445198542873 KSM (claimed) 138 | ``` 139 | 140 | List rewards for an specific validator: 141 | 142 | ``` 143 | $ payctl list DSA55HQ9uGHE5MyMouE8Geasi2tsDcu3oHR4aFkJ3VBjZG5 144 | Era: 2020 145 | DSA55HQ9uGHE5MyMouE8Geasi2tsDcu3oHR4aFkJ3VBjZG5 => 0.643866310648 KSM (claimed) 146 | Era: 2021 147 | DSA55HQ9uGHE5MyMouE8Geasi2tsDcu3oHR4aFkJ3VBjZG5 => 0.161519397147 KSM (claimed) 148 | 149 | ... 150 | 151 | Era: 2029 152 | DSA55HQ9uGHE5MyMouE8Geasi2tsDcu3oHR4aFkJ3VBjZG5 => 0.803195226697 KSM (unclaimed) 153 | Era: 2030 154 | DSA55HQ9uGHE5MyMouE8Geasi2tsDcu3oHR4aFkJ3VBjZG5 => 0.802888079374 KSM (unclaimed) 155 | ``` 156 | 157 | List **only pending** rewards for an specific validator: 158 | 159 | ``` 160 | $ payctl list DSA55HQ9uGHE5MyMouE8Geasi2tsDcu3oHR4aFkJ3VBjZG5 --unclaimed 161 | Era: 2027 162 | DSA55HQ9uGHE5MyMouE8Geasi2tsDcu3oHR4aFkJ3VBjZG5 => 0.971542087938 KSM (unclaimed) 163 | Era: 2028 164 | DSA55HQ9uGHE5MyMouE8Geasi2tsDcu3oHR4aFkJ3VBjZG5 => 0.643503546725 KSM (unclaimed) 165 | Era: 2029 166 | DSA55HQ9uGHE5MyMouE8Geasi2tsDcu3oHR4aFkJ3VBjZG5 => 0.803195226697 KSM (unclaimed) 167 | Era: 2030 168 | DSA55HQ9uGHE5MyMouE8Geasi2tsDcu3oHR4aFkJ3VBjZG5 => 0.802888079374 KSM (unclaimed) 169 | ``` 170 | 171 | Pay rewards for the default validators: 172 | 173 | ``` 174 | $payctl pay 175 | ``` 176 | 177 | Pay rewards for the default validators only if there are more than 4 eras pending: 178 | 179 | ``` 180 | $payctl pay -m 4 181 | ``` -------------------------------------------------------------------------------- /payctl/payctl.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | from configparser import ConfigParser 3 | from collections import OrderedDict 4 | from substrateinterface import SubstrateInterface 5 | 6 | from .utils import * 7 | 8 | 9 | # 10 | # cmd_list - 'list' subcommand handler. 11 | # 12 | def cmd_list(args, config): 13 | substrate = SubstrateInterface( 14 | url=get_config(args, config, 'rpcurl'), 15 | type_registry_preset=get_type_preset(get_config(args, config, 'network')) 16 | ) 17 | 18 | active_era = substrate.query( 19 | module='Staking', 20 | storage_function='ActiveEra' 21 | ) 22 | active_era = active_era.value['index'] 23 | 24 | depth = get_config(args, config, 'deptheras') 25 | depth = int(depth) if depth is not None else 84 26 | 27 | start = active_era - depth 28 | end = active_era 29 | 30 | eras_payment_info = get_eras_payment_info_filtered( 31 | substrate, start, end, 32 | accounts=get_included_accounts(args, config), 33 | only_unclaimed=args.only_unclaimed 34 | ) 35 | eras_payment_info = OrderedDict(sorted(eras_payment_info.items(), reverse=True)) 36 | 37 | for era_index, era in eras_payment_info.items(): 38 | print(f"Era: {era_index}") 39 | for accountId in era: 40 | msg = "claimed" if era[accountId]['claimed'] else "unclaimed" 41 | formatted_amount = format_balance_to_symbol(substrate, era[accountId]['amount'], substrate.token_decimals) 42 | 43 | print(f"\t {accountId} => {formatted_amount} ({msg})") 44 | 45 | 46 | # 47 | # cmd_pay - 'pay' subcommand handler. 48 | # 49 | def cmd_pay(args, config): 50 | substrate = SubstrateInterface( 51 | url=get_config(args, config, 'rpcurl'), 52 | type_registry_preset=get_config(args, config, 'network') 53 | ) 54 | 55 | active_era = substrate.query( 56 | module='Staking', 57 | storage_function='ActiveEra' 58 | ) 59 | active_era = active_era.value['index'] 60 | 61 | depth = get_config(args, config, 'deptheras') 62 | depth = int(depth) if depth is not None else 84 63 | 64 | minEras = get_config(args, config, 'mineras') 65 | minEras = int(minEras) if minEras is not None else 5 66 | 67 | start = active_era - depth 68 | end = active_era 69 | 70 | eras_payment_info = get_eras_payment_info_filtered( 71 | substrate, start, end, 72 | accounts=get_included_accounts(args, config), 73 | only_unclaimed=True 74 | ) 75 | eras_payment_info = OrderedDict(sorted(eras_payment_info.items(), reverse=True)) 76 | 77 | if len(eras_payment_info.keys()) == 0: 78 | print(f"There are no rewards to claim in the last {depth} era(s)") 79 | return 80 | 81 | if len(eras_payment_info.keys()) < minEras: 82 | print( 83 | f"There are rewards to claim on {len(eras_payment_info.keys())} era(s), " + 84 | f"but those are not enough to reach the minimum threshold ({minEras})" 85 | ) 86 | return 87 | 88 | keypair = get_keypair(args, config) 89 | 90 | payout_calls = [] 91 | for era in eras_payment_info: 92 | for accountId in eras_payment_info[era]: 93 | payout_calls.append({ 94 | 'call_module': 'Staking', 95 | 'call_function': 'payout_stakers', 96 | 'call_args': { 97 | 'validator_stash': accountId, 98 | 'era': era, 99 | } 100 | }) 101 | 102 | # Check if batch exstrinsic is available 103 | batch_is_available = substrate.get_metadata_call_function('Utility', 'batch') is not None 104 | 105 | # If batch extrinsic is available, we can batch all the payouts into a single extrinsic 106 | if batch_is_available: 107 | call = substrate.compose_call( 108 | call_module='Utility', 109 | call_function='batch', 110 | call_params={ 111 | 'calls': payout_calls 112 | } 113 | ) 114 | 115 | # Reduce the list of extrinsics/calls to just the batched one 116 | payout_calls = [call] 117 | 118 | # If batch extrinsic is not available, let's create a payout extrinsic for each era and for each validator 119 | else: 120 | payout_calls = list(map(lambda call: substrate.compose_call( 121 | call_module=call['call_module'], 122 | call_function=call['call_function'], 123 | call_params=call['call_args'] 124 | ), payout_calls)) 125 | 126 | for call in payout_calls: 127 | payment_info = substrate.get_payment_info(call=call, keypair=keypair) 128 | account_info = get_account_info(substrate, get_config(args, config, 'signingaccount')) 129 | 130 | expected_fees = payment_info['partialFee'] 131 | free_balance = account_info['data']['free'] 132 | existential_deposit = get_existential_deposit(substrate) 133 | 134 | if (free_balance - expected_fees) < existential_deposit: 135 | print(f"Account with not enough funds. Needed {existential_deposit + expected_fees}, but got {free_balance}") 136 | return 137 | 138 | signature_payload = substrate.generate_signature_payload( 139 | call=call, 140 | nonce=account_info['nonce'] 141 | ) 142 | signature = keypair.sign(signature_payload) 143 | 144 | extrinsic = substrate.create_signed_extrinsic( 145 | call=call, 146 | keypair=keypair, 147 | nonce=account_info['nonce'], 148 | signature=signature 149 | ) 150 | 151 | if batch_is_available: 152 | print( 153 | "Submitting one batch extrinsic to claim " + 154 | f"rewards (in {len(eras_payment_info)} eras)" 155 | ) 156 | else: 157 | print( 158 | "Submitting single extrinsic to claim reward " + 159 | f"for validator {call.value['call_args']['validator_stash']} " + 160 | f"(in era {call.value['call_args']['era']})" 161 | ) 162 | 163 | extrinsic_receipt = substrate.submit_extrinsic( 164 | extrinsic=extrinsic, 165 | wait_for_inclusion=True 166 | ) 167 | 168 | fees = extrinsic_receipt.total_fee_amount 169 | 170 | print(f"\t Extrinsic hash: {extrinsic_receipt.extrinsic_hash}") 171 | print(f"\t Block hash: {extrinsic_receipt.block_hash}") 172 | print(f"\t Fee: {format_balance_to_symbol(substrate, fees)} ({fees})") 173 | print(f"\t Status: {'ok' if extrinsic_receipt.is_success else 'error'}") 174 | if not extrinsic_receipt.is_success: 175 | print(f"\t Error message: {extrinsic_receipt.error_message.get('docs')}") 176 | 177 | def main(): 178 | args_parser = ArgumentParser(prog='payctl') 179 | args_parser.add_argument("-c", "--config", help="read config from a file", default="/usr/local/etc/payctl/default.conf") 180 | 181 | args_parser.add_argument("-r", "--rpc-url", dest="rpcurl", help="substrate RPC Url") 182 | args_parser.add_argument("-n", "--network", dest="network", help="name of the network to connect") 183 | args_parser.add_argument("-d", "--depth-eras", dest="deptheras", help="depth of eras to include") 184 | 185 | args_subparsers = args_parser.add_subparsers(title="Commands", help='', dest="command") 186 | 187 | args_subparser_list = args_subparsers.add_parser("list", help="list rewards") 188 | args_subparser_list.add_argument("-u", "--unclaimed", dest="only_unclaimed", help='show unclaimed only', action='store_true', default=False) 189 | args_subparser_list.add_argument("validators", nargs='*', help="", default=None) 190 | 191 | args_subparser_pay = args_subparsers.add_parser('pay', help="pay rewards") 192 | args_subparser_pay.add_argument("validators", nargs='*', help="", default=None) 193 | args_subparser_pay.add_argument("-m", "--min-eras", dest="mineras", help="minum eras pending to pay to proceed payment") 194 | args_subparser_pay.add_argument("-a", "--signing-account", dest="signingaccount", help="account used to sign requests") 195 | args_subparser_pay.add_argument("-n", "--signing-mnemonic", dest="signingmnemonic", help="mnemonic to generate the signing key") 196 | args_subparser_pay.add_argument("-s", "--signing-seed", dest="signingseed", help="seed to generate the signing key") 197 | args_subparser_pay.add_argument("-u", "--signing-uri", dest="signinguri", help="uri to generate the signing key") 198 | 199 | args = args_parser.parse_args() 200 | 201 | try: 202 | config = ConfigParser() 203 | config.read_file(open(args.config)) 204 | except Exception as exc: 205 | print(f"Unable to read config: {str(exc)}") 206 | exit(0) 207 | 208 | if not args.command: 209 | args_parser.print_help() 210 | exit(1) 211 | 212 | if args.command == 'list': 213 | cmd_list(args, config) 214 | if args.command == 'pay': 215 | cmd_pay(args, config) 216 | 217 | 218 | if __name__ == '__main__': 219 | main() 220 | -------------------------------------------------------------------------------- /payctl/utils.py: -------------------------------------------------------------------------------- 1 | from substrateinterface import Keypair 2 | 3 | # 4 | # get_config - Get a default and validator specific config elements from args and config. 5 | # 6 | def get_config(args, config, key, section='Defaults'): 7 | if vars(args).get(key) is not None: 8 | return vars(args)[key] 9 | 10 | if config[section].get(key) is not None: 11 | return config[section].get(key) 12 | 13 | return config['Defaults'].get(key) 14 | 15 | 16 | # 17 | # get_eras_rewards_point - Collect the ErasRewardPoints (total and invididual) for a given range of eras. 18 | # 19 | def get_eras_rewards_point(substrate, start, end): 20 | eras_rewards_point = {} 21 | 22 | for era in range(start, end): 23 | reward_points = substrate.query( 24 | module='Staking', 25 | storage_function='ErasRewardPoints', 26 | params=[era] 27 | ) 28 | 29 | try: 30 | eras_rewards_point[era] = {} 31 | eras_rewards_point[era]['total'] = reward_points.value['total'] 32 | eras_rewards_point[era]['individual'] = {} 33 | 34 | for reward_points_item in reward_points.value['individual']: 35 | eras_rewards_point[era]['individual'][reward_points_item[0]] = reward_points_item[1] 36 | except: 37 | continue 38 | 39 | return eras_rewards_point 40 | 41 | 42 | # 43 | # get_eras_validator_rewards - Collect the ErasValidatorReward for a given range of eras. 44 | # 45 | def get_eras_validator_rewards(substrate, start, end): 46 | eras_validator_rewards = {} 47 | 48 | for era in range(start, end): 49 | validator_rewards = substrate.query( 50 | module='Staking', 51 | storage_function='ErasValidatorReward', 52 | params=[era] 53 | ) 54 | 55 | try: 56 | eras_validator_rewards[era] = validator_rewards.value 57 | except: 58 | continue 59 | 60 | return eras_validator_rewards 61 | 62 | # 63 | # get_eras_claims - Collect the ClaimedRewards for a given range of eras. 64 | # 65 | def get_eras_claims(substrate, start, end): 66 | eras_claims = {} 67 | 68 | for era in range(start, end): 69 | claims = substrate.query_map( 70 | module='Staking', 71 | storage_function='ClaimedRewards', 72 | params=[era] 73 | ) 74 | 75 | # Extract the list of validators who claimed a reward in this era 76 | validators = list(map(lambda x: x[0].value, claims)) 77 | 78 | eras_claims[era] = validators 79 | 80 | return eras_claims 81 | 82 | 83 | # 84 | # get_eras_payment_info - Combine information from ErasRewardPoints and ErasValidatorReward for given 85 | # range of eras to repor the amount of per validator instead of era points. 86 | # 87 | def get_eras_payment_info(substrate, start, end): 88 | eras_rewards_point = get_eras_rewards_point(substrate, start, end) 89 | eras_validator_rewards = get_eras_validator_rewards(substrate, start, end) 90 | 91 | eras_payment_info = {} 92 | 93 | # era indexes with rewards points and validator rewards 94 | eras = list(set(eras_rewards_point.keys()) & set(eras_validator_rewards.keys())) 95 | 96 | for era in eras: 97 | total_points = eras_rewards_point[era]['total'] 98 | 99 | for validatorId in eras_rewards_point[era]['individual']: 100 | total_reward = eras_validator_rewards[era] 101 | eras_rewards_point[era]['individual'][validatorId] *= (total_reward/total_points) 102 | 103 | eras_payment_info[era] = eras_rewards_point[era]['individual'] 104 | 105 | return eras_payment_info 106 | 107 | # 108 | # get_eras_payment_info_filtered - Similar than get_eras_payment_info but applying some filters; 109 | # 1 . Include only eras containing given acconts. 110 | # 2 . Include only eras containing unclaimed rewards. 111 | # 112 | # NOTE: The returned structure is slighly different than 113 | # get_eras_payment_info 114 | # 115 | def get_eras_payment_info_filtered(substrate, start, end, accounts=[], only_unclaimed=False): 116 | eras_payment_info_filtered = {} 117 | 118 | eras_payment_info = get_eras_payment_info(substrate, start, end) 119 | # get_accounts_ledger relies on a deprecated API that may stop working at some point 120 | accounts_ledger = get_accounts_ledger(substrate, accounts) 121 | # get_eras_claims replaces the previous call with a newer API 122 | claims = get_eras_claims(substrate, start, end) 123 | 124 | for era in eras_payment_info: 125 | for accountId in accounts: 126 | if accountId in eras_payment_info[era]: 127 | legacy_rewards = accounts_ledger.get(accountId, {}).get('legacy_claimed_rewards', []) 128 | era_claims = claims.get(era, []) 129 | claimed = era in legacy_rewards or accountId in era_claims 130 | 131 | # if we only want the unclaimed rewards, skip 132 | if claimed and only_unclaimed: 133 | continue 134 | 135 | if era not in eras_payment_info_filtered: 136 | eras_payment_info_filtered[era] = {} 137 | 138 | eras_payment_info_filtered[era][accountId] = {} 139 | 140 | amount = eras_payment_info[era][accountId] / (10**substrate.token_decimals) 141 | 142 | eras_payment_info_filtered[era][accountId]['claimed'] = claimed 143 | eras_payment_info_filtered[era][accountId]['amount'] = amount 144 | 145 | return eras_payment_info_filtered 146 | 147 | 148 | # 149 | # get_included_accounts - Get the list (for the filtering) of included accounts from the args or config. 150 | # 151 | def get_included_accounts(args, config): 152 | if len(args.validators) != 0: 153 | return [validator for validator in args.validators] 154 | 155 | return [section for section in config.sections() if section != "Defaults"] 156 | 157 | 158 | 159 | # 160 | # get_accounts_ledger - Collect the Ledger for a given list of accounts. 161 | # 162 | def get_accounts_ledger(substrate, accounts): 163 | accounts_ledger = {} 164 | 165 | for account in accounts: 166 | try: 167 | controller_account = substrate.query( 168 | module='Staking', 169 | storage_function='Bonded', 170 | params=[accounts[0]] 171 | ) 172 | 173 | ledger = substrate.query( 174 | module='Staking', 175 | storage_function='Ledger', 176 | params=[controller_account.value] 177 | ) 178 | 179 | accounts_ledger[account] = ledger.value 180 | except: 181 | continue 182 | 183 | return accounts_ledger 184 | 185 | 186 | # 187 | # get_keypair - Generate a Keypair from args and config. 188 | # 189 | def get_keypair(args, config): 190 | signingseed = get_config(args, config, 'signingseed') 191 | signingmnemonic = get_config(args, config, 'signingmnemonic') 192 | signinguri = get_config(args, config, 'signinguri') 193 | 194 | ss58_format = get_ss58_address_format(get_config(args, config, 'network')) 195 | 196 | if signingseed is not None: 197 | keypair = Keypair.create_from_seed(signingseed, ss58_format) 198 | elif signingmnemonic is not None: 199 | keypair = Keypair.create_from_mnemonic(signingmnemonic, ss58_format) 200 | elif signinguri is not None: 201 | keypair = Keypair.create_from_uri(signinguri, ss58_format) 202 | else: 203 | keypair = None 204 | 205 | return keypair 206 | 207 | 208 | # 209 | # get_account_info - Get the account info, including nonce and balance, for a given account. 210 | # 211 | def get_account_info(substrate, account): 212 | account_info = substrate.query( 213 | module='System', 214 | storage_function='Account', 215 | params=[account] 216 | ) 217 | 218 | return account_info.value 219 | 220 | 221 | # 222 | # get_existential_deposit - Get the existential_deposit, the minimum amount required to keep an account open. 223 | # 224 | def get_existential_deposit(substrate): 225 | constants = substrate.get_metadata_constants() 226 | existential_deposit = 0 227 | 228 | for c in constants: 229 | if c['constant_name'] == 'ExistentialDeposit': 230 | existential_deposit = c.get('constant_value', 0) 231 | 232 | return existential_deposit 233 | 234 | 235 | # 236 | # format_balance_to_symbol - Formats a balance in the base decimals of the chain 237 | # 238 | def format_balance_to_symbol(substrate, amount, amount_decimals=0): 239 | formatted = amount / 10 ** (substrate.token_decimals - amount_decimals) 240 | formatted = "{:.{}f}".format(formatted, substrate.token_decimals) 241 | 242 | # expected format -> 5.780520362127 KSM 243 | return f"{formatted} {substrate.token_symbol}" 244 | 245 | 246 | # 247 | # get_ss58_address_format - Gets the SS58 address format depending on the network 248 | # 249 | def get_ss58_address_format(network): 250 | network = network.lower() 251 | 252 | if network == "polkadot": return 0 253 | if network == "sr25519": return 1 254 | if network == "kusama": return 2 255 | if network == "ed25519": return 3 256 | if network == "katalchain": return 4 257 | if network == "plasm": return 5 258 | if network == "bifrost": return 6 259 | if network == "edgeware": return 7 260 | if network == "karura": return 8 261 | if network == "reynolds": return 9 262 | if network == "acala": return 10 263 | if network == "laminar": return 11 264 | if network == "polymath": return 12 265 | if network == "substratee": return 13 266 | if network == "totem": return 14 267 | if network == "synesthesia": return 15 268 | if network == "kulupu": return 16 269 | if network == "dark": return 17 270 | if network == "darwinia": return 18 271 | if network == "geek": return 19 272 | if network == "stafi": return 20 273 | if network == "dock-testnet": return 21 274 | if network == "dock-mainnet": return 22 275 | if network == "shift": return 23 276 | if network == "zero": return 24 277 | if network == "alphaville": return 25 278 | if network == "jupiter": return 26 279 | if network == "subsocial": return 28 280 | if network == "cord": return 29 281 | if network == "phala": return 30 282 | if network == "litentry": return 31 283 | if network == "robonomics": return 32 284 | if network == "datahighway": return 33 285 | if network == "ares": return 34 286 | if network == "vln": return 35 287 | if network == "centrifuge": return 36 288 | if network == "nodle": return 37 289 | if network == "kilt": return 38 290 | if network == "poli": return 41 291 | if network == "substrate": return 42 292 | if network == "westend": return 42 293 | if network == "amber": return 42 294 | if network == "secp256k1": return 43 295 | if network == "chainx": return 44 296 | if network == "uniarts": return 45 297 | if network == "reserved46": return 46 298 | if network == "reserved47": return 47 299 | if network == "neatcoin": return 48 300 | if network == "hydradx": return 63 301 | if network == "aventus": return 65 302 | if network == "crust": return 66 303 | if network == "equilibrium": return 67 304 | if network == "sora": return 69 305 | if network == "social-network": return 252 306 | 307 | return 42 308 | 309 | # 310 | # get_type_preset - Gets the type preset for the network 311 | # 312 | def get_type_preset(network): 313 | supported_networks = [ 314 | "polkadot", 315 | "kusama", 316 | "rococo", 317 | "westend", 318 | "statemine", 319 | "statemint", 320 | ] 321 | 322 | if network in supported_networks: 323 | return network 324 | else: 325 | return "default" 326 | --------------------------------------------------------------------------------