├── .nvmrc
├── .dockerignore
├── src
├── utils
│ ├── constants.mjs
│ ├── ChainAsset.mjs
│ ├── Operator.mjs
│ ├── EthSigner.mjs
│ ├── Chain.mjs
│ ├── Validator.mjs
│ ├── Wallet.mjs
│ ├── CosmosDirectory.mjs
│ ├── Helpers.mjs
│ ├── Network.mjs
│ ├── SigningClient.mjs
│ └── QueryClient.mjs
├── networks.local.json.sample
├── autostake
│ ├── Health.mjs
│ ├── index.mjs
│ └── NetworkRunner.mjs
└── networks.json
├── .env.sample
├── docs
└── screenshot.png
├── docker-compose.yml
├── scripts
├── autostake.mjs
└── dryrun.mjs
├── Dockerfile
├── .gitignore
├── package.json
├── LICENSE
├── README.md
└── README_TURKISH.md
/.nvmrc:
--------------------------------------------------------------------------------
1 | 20
2 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | Dockerfile
3 | .git
4 | .vim
5 | .env
6 |
--------------------------------------------------------------------------------
/src/utils/constants.mjs:
--------------------------------------------------------------------------------
1 | export const RESTAKE_USER_AGENT = 'REStake'
2 |
--------------------------------------------------------------------------------
/.env.sample:
--------------------------------------------------------------------------------
1 | MNEMONIC=my hot wallet seed words here that has minimal funds
2 |
--------------------------------------------------------------------------------
/docs/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/eco-stake/restake/HEAD/docs/screenshot.png
--------------------------------------------------------------------------------
/src/networks.local.json.sample:
--------------------------------------------------------------------------------
1 | {
2 | "akash": {
3 | "prettyName": "Akash",
4 | "restUrl": [
5 | "https://rest.validator.com/osmosis"
6 | ]
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | app:
3 | build: .
4 | env_file:
5 | - .env
6 | volumes:
7 | - .:/usr/src/app
8 | - restake_node_modules:/usr/src/app/node_modules
9 |
10 | volumes:
11 | restake_node_modules:
12 |
--------------------------------------------------------------------------------
/src/utils/ChainAsset.mjs:
--------------------------------------------------------------------------------
1 | function ChainAsset(data) {
2 | const { symbol, base, display, image } = data
3 | const decimals = display?.exponent ?? 6
4 |
5 | return {
6 | ...data,
7 | symbol,
8 | base,
9 | display,
10 | decimals,
11 | image
12 | }
13 | }
14 |
15 | export default ChainAsset
--------------------------------------------------------------------------------
/scripts/autostake.mjs:
--------------------------------------------------------------------------------
1 | import 'dotenv/config'
2 | import Autostake from "../src/autostake/index.mjs";
3 |
4 | const mnemonic = process.env.MNEMONIC
5 | const networksOverridePath = process.env.NETWORKS_OVERRIDE_PATH || 'src/networks.local.json'
6 | const autostake = new Autostake(mnemonic);
7 | const networkNames = process.argv.slice(2, process.argv.length)
8 | autostake.run(networkNames, networksOverridePath)
9 |
--------------------------------------------------------------------------------
/scripts/dryrun.mjs:
--------------------------------------------------------------------------------
1 | import 'dotenv/config'
2 | import Autostake from "../src/autostake/index.mjs";
3 |
4 | const mnemonic = process.env.MNEMONIC
5 | const networksOverridePath = process.env.NETWORKS_OVERRIDE_PATH || 'src/networks.local.json'
6 | const autostake = new Autostake(mnemonic, { dryRun: true });
7 | const networkName = process.argv.slice(2, process.argv.length)
8 | autostake.run(networkName, networksOverridePath)
9 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:20-bookworm
2 |
3 | RUN apt-get update \
4 | && apt-get install -y --no-install-recommends python3 build-essential \
5 | && rm -rf /var/lib/apt/lists/*
6 |
7 | ENV NODE_ENV=development
8 |
9 | WORKDIR /usr/src/app
10 |
11 | COPY package*.json ./
12 | RUN npm ci || npm install
13 | COPY . .
14 |
15 | ENV DIRECTORY_PROTOCOL=https
16 | ENV DIRECTORY_DOMAIN=cosmos.directory
17 |
18 | EXPOSE 3000
19 |
20 | CMD ["npm", "run", "autostake"]
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | src/networks.local.json
2 | scripts/*
3 | !scripts/autostake.mjs
4 | !scripts/base.mjs
5 | !scripts/checkAuthz.mjs
6 | !scripts/dryrun.mjs
7 |
8 | # dependencies
9 | /node_modules
10 | /.pnp
11 | .pnp.js
12 |
13 | # testing
14 | /coverage
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | .env
22 | .env.local
23 | .env.development.local
24 | .env.test.local
25 | .env.production.local
26 |
27 | npm-debug.log*
28 |
29 | .vim
30 | .iml
31 |
--------------------------------------------------------------------------------
/src/utils/Operator.mjs:
--------------------------------------------------------------------------------
1 | import _ from 'lodash'
2 |
3 | const Operator = (network, data) => {
4 | const { address } = data
5 | const botAddress = network.data.operator?.address || data.restake.address
6 | const minimumReward = network.data.operator?.minimumReward || data.restake.minimum_reward
7 |
8 | return {
9 | address,
10 | botAddress,
11 | minimumReward,
12 | moniker: data.description?.moniker,
13 | description: data.description,
14 | data,
15 | }
16 | }
17 |
18 | export default Operator;
19 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "restake",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@cosmjs/proto-signing": "^0.30.1",
7 | "@cosmjs/stargate": "^0.30.1",
8 | "@ethersproject/wallet": "^5.7.0",
9 | "@tharsis/address-converter": "^0.1.8",
10 | "axios": "^1.3.4",
11 | "axios-retry": "^3.4.0",
12 | "compare-versions": "^5.0.3",
13 | "cosmjs-types": "^0.7.1",
14 | "dotenv": "^16.0.3",
15 | "lodash": "^4.17.21",
16 | "mathjs": "^11.7.0",
17 | "winston": "^3.13.0"
18 | },
19 | "scripts": {
20 | "dryrun": "node scripts/dryrun.mjs",
21 | "autostake": "node scripts/autostake.mjs"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 ECO Stake
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.
22 |
--------------------------------------------------------------------------------
/src/utils/EthSigner.mjs:
--------------------------------------------------------------------------------
1 | import { ETH } from "@tharsis/address-converter";
2 | import Bech32 from "bech32";
3 |
4 | import * as BytesUtils from "@ethersproject/bytes";
5 | import { keccak256 } from "@ethersproject/keccak256";
6 | import { encodeSecp256k1Signature } from '@cosmjs/amino';
7 | import { makeSignBytes } from "@cosmjs/proto-signing";
8 |
9 | function EthSigner(signer, ethSigner, prefix){
10 | async function signDirect(_address, signDoc){
11 | const signature = await ethSigner
12 | ._signingKey()
13 | .signDigest(keccak256(makeSignBytes(signDoc)));
14 | const splitSignature = BytesUtils.splitSignature(signature);
15 | const result = BytesUtils.arrayify(
16 | BytesUtils.concat([splitSignature.r, splitSignature.s])
17 | );
18 | const pubkey = (await getAccounts())[0].pubkey
19 | return {
20 | signed: signDoc,
21 | signature: encodeSecp256k1Signature(pubkey, result)
22 | }
23 | }
24 |
25 | async function getAddress(){
26 | const ethereumAddress = await ethSigner.getAddress();
27 | const data = ETH.decoder(ethereumAddress);
28 | return Bech32.encode(prefix, Bech32.toWords(data))
29 | }
30 |
31 | function getAccounts(){
32 | return signer.getAccounts()
33 | }
34 |
35 | return {
36 | signer,
37 | ethSigner,
38 | signDirect,
39 | getAddress,
40 | getAccounts
41 | }
42 | }
43 |
44 | export default EthSigner
--------------------------------------------------------------------------------
/src/utils/Chain.mjs:
--------------------------------------------------------------------------------
1 | import { compareVersions, validate } from 'compare-versions';
2 | import ChainAsset from "./ChainAsset.mjs";
3 |
4 | const Chain = (data) => {
5 | const assets = data.assets?.map(el => ChainAsset(el)) || []
6 | const baseAsset = assets[0]
7 | const { cosmos_sdk_version } = data.versions || {}
8 | const sdk46OrLater = validate(cosmos_sdk_version) && compareVersions(cosmos_sdk_version, '0.46') >= 0
9 | const sdkAuthzAminoSupport = sdk46OrLater
10 | const authzSupport = data.authzSupport ?? data.params?.authz
11 | const apiVersions = {
12 | gov: sdk46OrLater ? 'v1' : 'v1beta1',
13 | ...data.apiVersions || {}
14 | }
15 |
16 | return {
17 | ...data,
18 | prettyName: data.prettyName || data.pretty_name,
19 | chainId: data.chainId || data.chain_id,
20 | prefix: data.prefix || data.bech32_prefix,
21 | slip44: data.slip44 || 118,
22 | estimatedApr: data.params?.calculated_apr,
23 | authzSupport,
24 | authzAminoSupport: data.authzAminoSupport ?? sdkAuthzAminoSupport ?? false,
25 | apiVersions,
26 | denom: data.denom || baseAsset?.base?.denom,
27 | display: data.display || baseAsset?.display?.denom,
28 | symbol: data.symbol || baseAsset?.symbol,
29 | decimals: data.decimals || baseAsset?.decimals,
30 | image: data.image || baseAsset?.image,
31 | coinGeckoId: baseAsset?.coingecko_id,
32 | assets,
33 | baseAsset
34 | }
35 | }
36 |
37 | export default Chain;
38 |
--------------------------------------------------------------------------------
/src/utils/Validator.mjs:
--------------------------------------------------------------------------------
1 | import Bech32 from "bech32";
2 |
3 | const Validator = (network, data) => {
4 | const address = data.operator_address
5 | const { delegations } = data
6 | const totalSlashes = data.slashes?.length
7 | const blockPeriod = data.missed_blocks_periods && data.missed_blocks_periods.slice(-1)[0]
8 | const uptime = blockPeriod?.blocks && ((blockPeriod.blocks - blockPeriod.missed) / blockPeriod.blocks)
9 | const missedBlocks = blockPeriod?.missed
10 | const totalTokens = delegations?.total_tokens_display
11 | const totalUsd = delegations?.total_usd
12 | const totalUsers = delegations?.total_count
13 | const commissionRate = data.commission.commission_rates.rate
14 |
15 | function isValidatorOperator(address) {
16 | if (!address || !window.atob) return false;
17 |
18 | const prefix = network.prefix
19 | const validatorOperator = Bech32.encode(prefix, Bech32.decode(data.operator_address).data)
20 | return validatorOperator === address
21 | }
22 |
23 | function getAPR(){
24 | if (!data.active) {
25 | return 0
26 | } else {
27 | return network.chain.estimatedApr * (1 - commissionRate);
28 | }
29 | }
30 |
31 | function getAPY(operator){
32 | const apr = getAPR()
33 | if(!apr) return 0
34 |
35 | const periodPerYear = operator && network.chain.authzSupport ? operator.runsPerDay(network.data.maxPerDay) * 365 : 1;
36 | return (1 + apr / periodPerYear) ** periodPerYear - 1;
37 | }
38 |
39 | return {
40 | ...data,
41 | address,
42 | commissionRate,
43 | totalTokens,
44 | totalUsd,
45 | totalUsers,
46 | totalSlashes,
47 | uptime,
48 | missedBlocks,
49 | isValidatorOperator,
50 | getAPR,
51 | getAPY
52 | }
53 | }
54 |
55 | export default Validator;
56 |
--------------------------------------------------------------------------------
/src/utils/Wallet.mjs:
--------------------------------------------------------------------------------
1 | import SigningClient from "./SigningClient.mjs"
2 |
3 | export const messageTypes = [
4 | '/cosmos.gov.v1beta1.MsgVote',
5 | '/cosmos.gov.v1beta1.MsgDeposit',
6 | '/cosmos.gov.v1beta1.MsgSubmitProposal',
7 | '/cosmos.bank.v1beta1.MsgSend',
8 | '/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward',
9 | '/cosmos.distribution.v1beta1.MsgWithdrawValidatorCommission',
10 | '/cosmos.staking.v1beta1.MsgDelegate',
11 | '/cosmos.staking.v1beta1.MsgUndelegate',
12 | '/cosmos.staking.v1beta1.MsgBeginRedelegate',
13 | '/cosmos.authz.v1beta1.MsgGrant',
14 | '/cosmos.authz.v1beta1.MsgRevoke',
15 | 'Custom'
16 | ]
17 |
18 | class Wallet {
19 | constructor(network, signer, key){
20 | this.network = network
21 | this.signer = signer
22 | this.key = key
23 | this.name = key?.name
24 | this.grants = []
25 | }
26 |
27 | signingClient(){
28 | return SigningClient(this.network, this.signer)
29 | }
30 |
31 | hasPermission(address, action){
32 | if(address === this.address) return true
33 | if(!this.authzSupport()) return false
34 |
35 | let message = messageTypes.find(el => {
36 | return el.split('.').slice(-1)[0].replace('Msg', '') === action
37 | })
38 | message = message || action
39 | return this.grants.some(grant => {
40 | return grant.granter === address &&
41 | grant.authorization["@type"] === "/cosmos.authz.v1beta1.GenericAuthorization" &&
42 | grant.authorization.msg === message
43 | })
44 | }
45 |
46 | authzSupport(){
47 | return this.signer.signDirect || (this.network.authzAminoSupport && this.signer.signAmino)
48 | }
49 |
50 | async getAddress(){
51 | this.address = this.address || await this.getAccountAddress()
52 |
53 | return this.address
54 | }
55 |
56 | async getAccountAddress(){
57 | if(this.signer.getAddress){
58 | return this.signer.getAddress()
59 | }else{
60 | const accounts = await this.getAccounts();
61 | return accounts[0].address;
62 | }
63 | }
64 |
65 | getAccounts(){
66 | return this.signer.getAccounts()
67 | }
68 |
69 | getIsNanoLedger() {
70 | if(!this.key) return false
71 | return this.key.isNanoLedger || this.key.isHardware;
72 | }
73 | }
74 |
75 | export default Wallet
--------------------------------------------------------------------------------
/src/utils/CosmosDirectory.mjs:
--------------------------------------------------------------------------------
1 | import { get } from './Helpers.mjs'
2 |
3 | function CosmosDirectory(testnet){
4 | const protocol = process.env.DIRECTORY_PROTOCOL || 'https'
5 | const mainnetDomain = process.env.DIRECTORY_DOMAIN || 'cosmos.directory'
6 | const testnetDomain = process.env.DIRECTORY_DOMAIN_TESTNET || 'testcosmos.directory'
7 | const domain = testnet ? testnetDomain : mainnetDomain
8 | const rpcBase = `${protocol}://rpc.${domain}`
9 | const restBase = `${protocol}://rest.${domain}`
10 | const chainsUrl = `${protocol}://chains.${domain}`
11 | const validatorsUrl = `${protocol}://validators.${domain}`
12 |
13 | function rpcUrl(name){
14 | return rpcBase + '/' + name
15 | }
16 |
17 | function restUrl(name){
18 | return restBase + '/' + name
19 | }
20 |
21 | function getChains(){
22 | return get(chainsUrl)
23 | .then(res => res.data)
24 | .then(data => Array.isArray(data) ? data : data.chains) // deprecate
25 | .then(data => data.reduce((a, v) => ({ ...a, [v.path]: v }), {}))
26 | }
27 |
28 | function getChainData(name) {
29 | return get([chainsUrl, name].join('/'))
30 | .then(res => res.data.chain)
31 | }
32 |
33 | async function getTokenData(name) {
34 | return get([chainsUrl, name, 'assetlist'].join('/'))
35 | .then(res => res.data)
36 | }
37 |
38 | function getValidators(chainName){
39 | return get(validatorsUrl + '/chains/' + chainName)
40 | .then(res => res.data.validators)
41 | }
42 |
43 | function getRegistryValidator(validatorName) {
44 | return get(validatorsUrl + '/' + validatorName)
45 | .then(res => res.data.validator)
46 | }
47 |
48 | function getOperatorAddresses(){
49 | return get(validatorsUrl)
50 | .then(res => res.data)
51 | .then(data => Array.isArray(data) ? data : data.validators) // deprecate
52 | .then(data => data.reduce((sum, validator) => {
53 | validator.chains.forEach(chain => {
54 | sum[chain.name] = sum[chain.name] || {}
55 | if(chain.restake){
56 | sum[chain.name][chain.address] = chain.restake
57 | }
58 | }, {})
59 | return sum
60 | }, {}))
61 | }
62 |
63 | return {
64 | testnet,
65 | domain,
66 | rpcUrl,
67 | restUrl,
68 | getChains,
69 | getChainData,
70 | getTokenData,
71 | getValidators,
72 | getRegistryValidator,
73 | getOperatorAddresses
74 | }
75 | }
76 |
77 | export default CosmosDirectory
--------------------------------------------------------------------------------
/src/autostake/Health.mjs:
--------------------------------------------------------------------------------
1 | import _ from 'lodash'
2 | import axios from 'axios'
3 | import { createLogger } from '../utils/Helpers.mjs'
4 |
5 | class Health {
6 | constructor(config, opts) {
7 | const { address, apiAddress, uuid, name, apiKey, timeout, gracePeriod } = config || {}
8 | const { dryRun, networkName } = opts || {}
9 | this.address = address || 'https://hc-ping.com'
10 | this.apiAddress = apiAddress || 'https://healthchecks.io'
11 | this.name = name || networkName
12 | this.gracePeriod = gracePeriod || 86400 // default 24 hours
13 | this.timeout = timeout || 86400 // default 24 hours
14 | this.uuid = uuid
15 | this.apiKey = apiKey
16 | this.dryRun = dryRun
17 | this.logs = []
18 | this.getOrCreateHealthCheck()
19 |
20 | if (address) {
21 | // This is necessary as the default provider - hc-ping.com - has a built in ping mechanism
22 | // whereas providing self-hosted addresses do NOT.
23 | // https://healthchecks.selfhosted.com/ping/{uuid} rather than https://hc-ping.com/{uuid}
24 | this.address = this.address + "/ping"
25 | }
26 |
27 | this.logger = createLogger('health')
28 | }
29 |
30 | started(...args) {
31 | this.logger.info(args.join(' '))
32 | if (this.uuid) this.logger.info('Starting health', { path: [this.address, this.uuid].join('/') })
33 | return this.ping('start', [args.join(' ')])
34 | }
35 |
36 | success(...args) {
37 | this.logger.info(args.join(' '))
38 | return this.ping(undefined, [...this.logs, args.join(' ')])
39 | }
40 |
41 | failed(...args) {
42 | this.logger.warn(args.join(' '))
43 | return this.ping('fail', [...this.logs, args.join(' ')])
44 | }
45 |
46 | log(...args) {
47 | this.logger.info(args.join(' '))
48 | this.logs = [...this.logs, args.join(' ')]
49 | }
50 |
51 | addLogs(logs) {
52 | this.logs = this.logs.concat(logs)
53 | }
54 |
55 | async getOrCreateHealthCheck(...args) {
56 | if (!this.apiKey) return;
57 |
58 | let config = {
59 | headers: {
60 | "X-Api-Key": this.apiKey,
61 | }
62 | }
63 |
64 | let data = {
65 | "name": this.name, "channels": "*", "timeout": this.timeout, "grace": this.gracePeriod, "unique": ["name"]
66 | }
67 |
68 | try {
69 | await axios.post([this.apiAddress, 'api/v2/checks/'].join('/'), data, config).then((res) => {
70 | this.uuid = res.data.ping_url.split('/')[4]
71 | });
72 | } catch (error) {
73 | this.logger.error('Health Check creation failed', { error })
74 | }
75 | }
76 |
77 | async sendLog() {
78 | await this.ping('log', this.logs)
79 | this.logs = []
80 | }
81 |
82 | async ping(action, logs) {
83 | if (!this.uuid) return
84 | if (this.dryRun) return this.logger.info('DRYRUN: Skipping health check ping')
85 |
86 | return axios.request({
87 | method: 'POST',
88 | url: _.compact([this.address, this.uuid, action]).join('/'),
89 | data: logs.join("\n")
90 | }).catch(error => {
91 | this.logger.error('Health ping failed', { error })
92 | })
93 | }
94 | }
95 |
96 | export default Health
97 |
--------------------------------------------------------------------------------
/src/utils/Helpers.mjs:
--------------------------------------------------------------------------------
1 | import _ from 'lodash'
2 | import { format, floor, bignumber } from 'mathjs'
3 | import { coin as _coin } from '@cosmjs/stargate'
4 | import axios from 'axios'
5 | import winston from 'winston'
6 |
7 | import { RESTAKE_USER_AGENT } from './constants.mjs'
8 |
9 | export function coin(amount, denom){
10 | return _coin(format(floor(amount), {notation: 'fixed'}), denom)
11 | }
12 |
13 | export function joinString(...args){
14 | return _.compact(args).join(' ')
15 | }
16 |
17 | export function rewardAmount(rewards, denom, type){
18 | if (!rewards)
19 | return 0;
20 | type = type || 'reward'
21 | const reward = rewards && rewards[type]?.find((el) => el.denom === denom);
22 | return reward ? bignumber(reward.amount) : 0;
23 | }
24 |
25 | export function overrideNetworks(networks, overrides){
26 | networks = networks.reduce((a, v) => ({ ...a, [v.name]: v }), {})
27 | const names = [...Object.keys(networks), ...Object.keys(overrides)]
28 | return _.uniq(names).sort().map(name => {
29 | let network = networks[name]
30 | let override = overrides[name]
31 | if(!network || !network.name) network = { name, ...network }
32 | if(!override) return network
33 | override.overriden = true
34 | return _.mergeWith(network, override, (a, b) =>
35 | _.isArray(b) ? b : undefined
36 | );
37 | })
38 | }
39 |
40 | export function buildExecMessage(grantee, messages) {
41 | return {
42 | typeUrl: "/cosmos.authz.v1beta1.MsgExec",
43 | value: {
44 | grantee: grantee,
45 | msgs: messages
46 | }
47 | }
48 | }
49 |
50 | export function buildExecableMessage(type, typeUrl, value, shouldExec){
51 | if (shouldExec) {
52 | return {
53 | typeUrl: typeUrl,
54 | value: type.encode(type.fromPartial(value)).finish()
55 | }
56 | } else {
57 | return {
58 | typeUrl: typeUrl,
59 | value: value
60 | }
61 | }
62 | }
63 |
64 | export function parseGrants(grants, grantee, granter) {
65 | const stakeGrant = grants.find((el) => {
66 | if (
67 | (!el.grantee || el.grantee === grantee) &&
68 | (!el.granter || el.granter === granter) &&
69 | (el.authorization["@type"] ===
70 | "/cosmos.staking.v1beta1.StakeAuthorization" || (
71 | // Handle GenericAuthorization for Ledger
72 | el.authorization["@type"] ===
73 | "/cosmos.authz.v1beta1.GenericAuthorization" &&
74 | el.authorization.msg ===
75 | "/cosmos.staking.v1beta1.MsgDelegate"
76 | ))
77 | ) {
78 | if (el.expiration === null) {
79 | // null expiration is flakey currently
80 | // sometimes it means it's expired, sometimes no expiration set (infinite grant)
81 | // we have to treat as invalid until this is resolved
82 | return false;
83 | } else if (Date.parse(el.expiration) > new Date()) {
84 | return true;
85 | } else {
86 | return false;
87 | }
88 | } else {
89 | return false;
90 | }
91 | })
92 | return {
93 | stakeGrant
94 | };
95 | }
96 |
97 | export function mapAsync(array, callbackfn) {
98 | return Promise.all(array.map(callbackfn));
99 | }
100 |
101 | export function findAsync(array, callbackfn) {
102 | return mapAsync(array, callbackfn).then(findMap => {
103 | return array.find((value, index) => findMap[index]);
104 | });
105 | }
106 |
107 | export function filterAsync(array, callbackfn) {
108 | return mapAsync(array, callbackfn).then(filterMap => {
109 | return array.filter((value, index) => filterMap[index]);
110 | });
111 | }
112 |
113 | export async function mapSync(calls, count, batchCallback) {
114 | const batchCalls = _.chunk(calls, count);
115 | let results = []
116 | let index = 0
117 | for (const batchCall of batchCalls) {
118 | const batchResults = await mapAsync(batchCall, call => call())
119 | results.push(batchResults)
120 | if (batchCallback) await batchCallback(batchResults, index)
121 | index++
122 | }
123 | return results.flat()
124 | }
125 |
126 | export async function executeSync(calls, count) {
127 | const batchCalls = _.chunk(calls, count);
128 | for (const batchCall of batchCalls) {
129 | await mapAsync(batchCall, call => call())
130 | }
131 | }
132 |
133 | export async function get(url, opts) {
134 | const headers = opts?.headers ?? {}
135 |
136 | return axios.get(url, {
137 | ...opts,
138 | headers: {
139 | ...headers,
140 | 'User-Agent': RESTAKE_USER_AGENT,
141 | }
142 | })
143 | }
144 |
145 | export async function post(url, body, opts) {
146 | const headers = opts?.headers ?? {}
147 |
148 | return axios.post(url, body, {
149 | ...opts,
150 | headers: {
151 | ...headers,
152 | 'User-Agent': RESTAKE_USER_AGENT,
153 | }
154 | })
155 | }
156 |
157 | export function createLogger(module) {
158 | return winston.createLogger({
159 | level: 'debug',
160 | defaultMeta: { module },
161 | format: winston.format.combine(
162 | winston.format.colorize(),
163 | winston.format.prettyPrint(),
164 | winston.format.timestamp(),
165 | winston.format.splat(),
166 | winston.format.printf(({
167 | timestamp,
168 | level,
169 | message,
170 | label = '',
171 | ...meta
172 | }) => {
173 | const metaFormatted = Object.entries(meta)
174 | .map(([key, value]) => `${key}=${value}`)
175 | .join(' ')
176 | return `[${timestamp}] ${level}: ${message} ${metaFormatted}`
177 | })
178 | ),
179 | transports: [
180 | new winston.transports.Console()
181 | ],
182 | });
183 | }
184 |
--------------------------------------------------------------------------------
/src/utils/Network.mjs:
--------------------------------------------------------------------------------
1 | import _ from 'lodash'
2 | import { multiply, pow, format, bignumber } from 'mathjs'
3 | import {
4 | GasPrice,
5 | } from "@cosmjs/stargate";
6 | import QueryClient from './QueryClient.mjs'
7 | import Validator from './Validator.mjs'
8 | import Operator from './Operator.mjs'
9 | import Chain from './Chain.mjs'
10 | import CosmosDirectory from './CosmosDirectory.mjs'
11 |
12 | class Network {
13 | constructor(data, operatorAddresses) {
14 | this.data = data
15 | this.enabled = data.enabled
16 | this.experimental = data.experimental
17 | this.operatorAddresses = operatorAddresses || {}
18 | this.operatorCount = data.operators?.length || this.estimateOperatorCount()
19 | this.name = data.path || data.name
20 | this.path = data.path || data.name
21 | this.image = data.image
22 | this.prettyName = data.prettyName || data.pretty_name
23 | this.default = data.default
24 | this.testnet = data.testnet || data.network_type === 'testnet'
25 | this.setChain(this.data)
26 |
27 | this.directory = CosmosDirectory(this.testnet)
28 | this.rpcUrl = this.directory.rpcUrl(this.name) // only used for Keplr suggestChain
29 | this.restUrl = data.restUrl || this.directory.restUrl(this.name)
30 |
31 | this.usingDirectory = !![this.restUrl].find(el => {
32 | const match = el => el.match("cosmos.directory")
33 | if (Array.isArray(el)) {
34 | return el.find(match)
35 | } else {
36 | return match(el)
37 | }
38 | })
39 | this.online = !this.usingDirectory || this.connectedDirectory()
40 | }
41 |
42 | connectedDirectory() {
43 | const proxy_status = this.chain ? this.chain['proxy_status'] : this.data['proxy_status']
44 | return proxy_status && ['rest'].every(type => proxy_status[type])
45 | }
46 |
47 | estimateOperatorCount() {
48 | if(!this.operatorAddresses) return 0
49 | return Object.keys(this.operatorAddresses).filter(el => this.allowOperator(el)).length
50 | }
51 |
52 | allowOperator(address){
53 | const allow = this.data.allowOperators
54 | const block = this.data.blockOperators
55 | if(allow && !allow.includes(address)) return false
56 | if(block && block.includes(address)) return false
57 | return true
58 | }
59 |
60 | async load() {
61 | const chainData = await this.directory.getChainData(this.data.name);
62 | this.setChain({...this.data, ...chainData})
63 | this.validators = (await this.directory.getValidators(this.name)).map(data => {
64 | return Validator(this, data)
65 | })
66 | this.operators = (this.data.operators || this.validators.filter(el => el.restake && this.allowOperator(el.operator_address))).map(data => {
67 | return Operator(this, data)
68 | })
69 | this.operatorCount = this.operators.length
70 | }
71 |
72 | async setChain(data){
73 | this.chain = Chain(data)
74 | this.prettyName = this.chain.prettyName
75 | this.chainId = this.chain.chainId
76 | this.prefix = this.chain.prefix
77 | this.slip44 = this.chain.slip44
78 | this.assets = this.chain.assets
79 | this.baseAsset = this.chain.baseAsset
80 | this.denom = this.chain.denom
81 | this.display = this.chain.display
82 | this.symbol = this.chain.symbol
83 | this.decimals = this.chain.decimals
84 | this.image = this.chain.image
85 | this.coinGeckoId = this.chain.coinGeckoId
86 | this.estimatedApr = this.chain.estimatedApr
87 | this.apyEnabled = data.apyEnabled !== false && !!this.estimatedApr && this.estimatedApr > 0
88 | this.ledgerSupport = this.chain.ledgerSupport ?? true
89 | this.authzSupport = this.chain.authzSupport
90 | this.authzAminoSupport = this.chain.authzAminoSupport
91 | this.defaultGasPrice = this.decimals && format(bignumber(multiply(0.000000025, pow(10, this.decimals))), { notation: 'fixed', precision: 4}) + this.denom
92 | this.gasPrice = this.data.gasPrice || this.defaultGasPrice
93 | if(this.gasPrice){
94 | const gasPrice = GasPrice.fromString(this.gasPrice)
95 | this.gasPriceAmount = gasPrice.amount.toString()
96 | this.gasPriceDenom = gasPrice.denom
97 | this.gasPriceStep = this.data.gasPriceStep || {
98 | "low": multiply(this.gasPriceAmount, 0.5),
99 | "average": multiply(this.gasPriceAmount, 1),
100 | "high": multiply(this.gasPriceAmount, 2)
101 | }
102 | }
103 | this.gasPricePrefer = this.data.gasPricePrefer
104 | this.gasModifier = this.data.gasModifier || 1.5
105 | this.txTimeout = this.data.txTimeout || 60_000
106 | }
107 |
108 | async connect(opts) {
109 | try {
110 | this.queryClient = await QueryClient(this.chain.chainId, this.restUrl, {
111 | connectTimeout: opts?.timeout,
112 | apiVersions: this.chain.apiVersions
113 | })
114 | this.restUrl = this.queryClient.restUrl
115 | this.connected = this.queryClient.connected && (!this.usingDirectory || this.connectedDirectory())
116 | } catch (error) {
117 | console.log(error)
118 | this.connected = false
119 | }
120 | }
121 |
122 | async getApy(validators, operators){
123 | let validatorApy = {};
124 | for (const [address, validator] of Object.entries(validators)) {
125 | const operator = operators.find((el) => el.address === address)
126 | validatorApy[address] = validator.getAPY(operator)
127 | }
128 | return validatorApy;
129 | }
130 |
131 | getOperator(operatorAddress) {
132 | return this.operators.find(elem => elem.address === operatorAddress)
133 | }
134 |
135 | getOperatorByBotAddress(botAddress) {
136 | return this.operators.find(elem => elem.botAddress === botAddress)
137 | }
138 |
139 | getOperators() {
140 | return this.sortOperators()
141 | }
142 |
143 | sortOperators() {
144 | const random = _.shuffle(this.operators)
145 | if (this.data.ownerAddress) {
146 | return _.sortBy(random, ({ address }) => address === this.data.ownerAddress ? 0 : 1)
147 | }
148 | return random
149 | }
150 |
151 | getValidators(opts) {
152 | opts = opts || {}
153 | return (this.validators || []).filter(validator => {
154 | if (opts.status)
155 | return validator.status === opts.status
156 | return true
157 | }).reduce(
158 | (a, v) => ({ ...a, [v.operator_address]: v }),
159 | {}
160 | )
161 | }
162 | }
163 |
164 | export default Network;
165 |
--------------------------------------------------------------------------------
/src/networks.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "name": "osmosis",
4 | "gasPrice": "0.0025uosmo",
5 | "ownerAddress": "osmovaloper1u5v0m74mql5nzfx2yh43s2tke4mvzghr6m2n5t",
6 | "default": true,
7 | "maxPerDay": 1,
8 | "apiVersions": {
9 | "gov": "v1beta1"
10 | }
11 | },
12 | {
13 | "name": "juno",
14 | "gasPrice": "0.0750ujuno",
15 | "ownerAddress": "junovaloper19ur8r2l25qhsy9xvej5zgpuc5cpn6syydmwtrt",
16 | "autostake": {
17 | "batchTxs": 25
18 | }
19 | },
20 | {
21 | "name": "cosmoshub",
22 | "ownerAddress": "cosmosvaloper1a37ze3yrr2y9nn98l6frhjskmufvd40cpyd0gq",
23 | "gasPrice": "0.0025uatom"
24 | },
25 | {
26 | "name": "akash",
27 | "ownerAddress": "akashvaloper1xgnd8aach3vawsl38snpydkng2nv8a4kqgs8hf"
28 | },
29 | {
30 | "name": "chihuahua",
31 | "ownerAddress": "chihuahuavaloper19vwcee000fhazmpt4ultvnnkhfh23ppwxll8zz",
32 | "gasPrice": "500uhuahua",
33 | "gasPricePrefer": "500uhuahua"
34 | },
35 | {
36 | "name": "gravitybridge",
37 | "gasPrice": "0.025ugraviton"
38 | },
39 | {
40 | "name": "regen",
41 | "gasPrice": "0.03uregen",
42 | "ownerAddress": "regenvaloper1c4y3j05qx652rnxm5mg4yesqdkmhz2f6dl7hhk",
43 | "autostake": {
44 | "batchTxs": 5
45 | }
46 | },
47 | {
48 | "name": "terra",
49 | "gasPrice": "28.325uluna"
50 | },
51 | {
52 | "name": "terra2",
53 | "gasPrice": "0.015uluna"
54 | },
55 | {
56 | "name": "sentinel",
57 | "gasPrice": "0.1udvpn"
58 | },
59 | {
60 | "name": "dig",
61 | "gasPrice": "0.01udig",
62 | "ownerAddress": "digvaloper136avwnuvvy94dqmtnaue2nfvjes8xr37h9rzay"
63 | },
64 | {
65 | "name": "bitcanna",
66 | "gasPrice": "0.001ubcna"
67 | },
68 | {
69 | "name": "emoney",
70 | "gasPrice": "0.08ungm"
71 | },
72 | {
73 | "name": "kava",
74 | "gasPrice": "0.001ukava"
75 | },
76 | {
77 | "name": "desmos",
78 | "gasPrice": "0.001udsm",
79 | "authzSupport": true,
80 | "authzAminoSupport": true
81 | },
82 | {
83 | "name": "cryptoorgchain",
84 | "gasPrice": "0.025basecro",
85 | "ownerAddress": "crocncl10mfs428fyntu296dgh5fmhvdzrr2stlaekcrp9"
86 | },
87 | {
88 | "name": "evmos",
89 | "txTimeout": 120000,
90 | "ownerAddress": "evmosvaloper1umk407eed7af6anvut6llg2zevnf0dn0feqqny",
91 | "maxPerDay": 1,
92 | "authzAminoSupport": false
93 | },
94 | {
95 | "name": "sifchain",
96 | "gasPrice": "1500000000000rowan",
97 | "ownerAddress": "sifvaloper19t5nk5ceq5ga75epwdqhnupwg0v9339p096ydz"
98 | },
99 | {
100 | "name": "lumnetwork"
101 | },
102 | {
103 | "name": "stargaze"
104 | },
105 | {
106 | "name": "comdex",
107 | "ownerAddress": "comdexvaloper17f70yjkvmvld379904jaddx9h0f74n32pjtmp6"
108 | },
109 | {
110 | "name": "cheqd",
111 | "gasPrice": "25ncheq"
112 | },
113 | {
114 | "name": "umee",
115 | "gasPrice": "0.1uumee"
116 | },
117 | {
118 | "name": "bitsong"
119 | },
120 | {
121 | "name": "persistence"
122 | },
123 | {
124 | "name": "agoric"
125 | },
126 | {
127 | "name": "impacthub",
128 | "gasPrice": "0.025uixo",
129 | "gasPricePrefer": "0.1uixo"
130 | },
131 | {
132 | "name": "kichain",
133 | "gasPrice": "0.025uxki"
134 | },
135 | {
136 | "name": "sommelier"
137 | },
138 | {
139 | "name": "konstellation",
140 | "image": "https://raw.githubusercontent.com/Konstellation/DARC_token/main/darctoken.svg"
141 | },
142 | {
143 | "name": "fetchhub",
144 | "gasPriceStep": {
145 | "low": 0.025,
146 | "average": 0.025,
147 | "high": 0.035
148 | }
149 | },
150 | {
151 | "name": "cerberus",
152 | "gasPrice": "0.025ucrbrus",
153 | "autostake": {
154 | "batchTxs": 100
155 | },
156 | "ownerAddress": "cerberusvaloper1tat2cy3f9djtq9z7ly262sqngcarvaktr0w78f"
157 | },
158 | {
159 | "name": "secretnetwork",
160 | "gasPrice": "0.025uscrt",
161 | "gasPricePrefer": "0.05uscrt",
162 | "authzAminoSupport": true
163 | },
164 | {
165 | "name": "bostrom",
166 | "gasPrice": "0boot"
167 | },
168 | {
169 | "name": "starname",
170 | "gasPrice": "10uvoi"
171 | },
172 | {
173 | "name": "rizon",
174 | "gasPrice": "0.0001uatolo"
175 | },
176 | {
177 | "name": "decentr",
178 | "gasPrice": "0.025udec"
179 | },
180 | {
181 | "name": "assetmantle",
182 | "gasPrice": "0.025umntl",
183 | "gasPriceStep": {
184 | "low": 0,
185 | "average": 0.025,
186 | "high": 0.04
187 | },
188 | "ownerAddress": "mantlevaloper1fqs7gakxdmujtk0qufdzth5pfyspus3yx394zd"
189 | },
190 | {
191 | "name": "crescent",
192 | "gasPrice": "0.025ucre",
193 | "gasPriceStep": {
194 | "low": 0,
195 | "average": 0.025,
196 | "high": 0.04
197 | }
198 | },
199 | {
200 | "name": "meme",
201 | "gasPrice": "0.025umeme"
202 | },
203 | {
204 | "name": "cronos",
205 | "enabled": false
206 | },
207 | {
208 | "name": "harpoon",
209 | "ownerAddress": "kujiravaloper1vue5lawr3s0au9zj0aqeft5aknx6cjq6w5ghca",
210 | "testnet": true
211 | },
212 | {
213 | "name": "kujira",
214 | "ownerAddress": "kujiravaloper1vue5lawr3s0au9zj0aqeft5aknx6cjq6w5ghca"
215 | },
216 | {
217 | "name": "genesisl1",
218 | "txTimeout": 120000,
219 | "gasPrice": "51000000000el1"
220 | },
221 | {
222 | "name": "tgrade",
223 | "enabled": false
224 | },
225 | {
226 | "name": "aioz",
227 | "enabled": false
228 | },
229 | {
230 | "name": "echelon",
231 | "keplrFeatures": ["ibc-transfer", "ibc-go", "eth-address-gen", "eth-key-sign"]
232 | },
233 | {
234 | "name": "kichaintestnet",
235 | "testnet": true
236 | },
237 | {
238 | "name": "likecoin",
239 | "gasPrice": "10000nanolike"
240 | },
241 | {
242 | "name": "passage",
243 | "ownerAddress": "pasgvaloper196rujtjehu0dfc7y85lkcaps6tel76g3l9knjy",
244 | "gasPrice": "12.5upasg"
245 | },
246 | {
247 | "name": "stride",
248 | "ownerAddress": "stridevaloper1x2kta40h5rnymtjn6ys7vk2d87xu7y6zfu9j3r",
249 | "authzSupport": false,
250 | "gasPriceStep": {
251 | "low": 0,
252 | "average": 0.025,
253 | "high": 0.04
254 | }
255 | },
256 | {
257 | "name": "pulsar",
258 | "testnet": true
259 | },
260 | {
261 | "name": "teritori",
262 | "ownerAddress": "torivaloper1d5u07lhelk6lal44a0myvufurvsqk5d499h9hz",
263 | "gasPriceStep": {
264 | "low": 0,
265 | "average": 0.025,
266 | "high": 0.04
267 | }
268 | },
269 | {
270 | "name": "rebus"
271 | },
272 | {
273 | "name": "jackal",
274 | "ownerAddress": "jklvaloper1rdyunvvqg2l3723hlyjvvsnkd4vg338uar8q2s"
275 | },
276 | {
277 | "name": "oraichain",
278 | "gasPrice": "0.0000025orai"
279 | },
280 | {
281 | "name": "acrechain"
282 | },
283 | {
284 | "name": "mars",
285 | "ownerAddress": "marsvaloper1hvtaqw9mlwc0a4cdx6g3klk8acfc6z3yazzk8a",
286 | "gasPrice": "0umars"
287 | },
288 | {
289 | "name": "planq",
290 | "gasPrice": "30000000000aplanq"
291 | },
292 | {
293 | "name": "injective",
294 | "ownerAddress": "injvaloper1vqz7mgm47xhx25xu5g9qagnz48naks6pk6fmg2"
295 | },
296 | {
297 | "name": "xpla",
298 | "gasPrice": "850000000000axpla"
299 | },
300 | {
301 | "name": "kyve",
302 | "ownerAddress": "kyvevaloper1egqphd8yjdfv84fl825grwgna0pf2emagdmnz8",
303 | "gasPrice": "2ukyve"
304 | },
305 | {
306 | "name": "quicksilver",
307 | "gasPrice": "0.0001uqck",
308 | "gasPriceStep": {
309 | "low": 0.0001,
310 | "average": 0.0001,
311 | "high": 0.00025
312 | }
313 | },
314 | {
315 | "name": "chain4energy"
316 | },
317 | {
318 | "name": "coreum",
319 | "ownerAddress": "corevaloper1py9v5f7e4aaka55lwtthk30scxhnqfa6agwxt8",
320 | "gasPrice": "0.0625ucore"
321 | },
322 | {
323 | "name": "source",
324 | "gasPrice": "0.05usource"
325 | },
326 | {
327 | "name": "nolus",
328 | "gasPrice": "0.0025unls"
329 | },
330 | {
331 | "name": "realio",
332 | "gasPrice": "1000000000ario"
333 | },
334 | {
335 | "name": "dungeon",
336 | "gasPrice": "0.05udgn"
337 | },
338 | {
339 | "name": "atomone",
340 | "gasPrice": "0.225uphoton"
341 | }
342 | ]
343 |
--------------------------------------------------------------------------------
/src/autostake/index.mjs:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import _ from 'lodash'
3 |
4 | import { DirectSecp256k1HdWallet } from "@cosmjs/proto-signing";
5 | import { Slip10RawIndex, pathToString } from "@cosmjs/crypto";
6 |
7 | import { Wallet as EthWallet } from "@ethersproject/wallet";
8 |
9 | import { MsgExec } from "cosmjs-types/cosmos/authz/v1beta1/tx.js";
10 |
11 | import Network from '../utils/Network.mjs'
12 | import Wallet from '../utils/Wallet.mjs';
13 | import NetworkRunner from './NetworkRunner.mjs';
14 | import Health from './Health.mjs';
15 | import { executeSync, overrideNetworks, createLogger } from '../utils/Helpers.mjs'
16 | import EthSigner from '../utils/EthSigner.mjs';
17 |
18 | export default function Autostake(mnemonic, opts) {
19 | const mainLogger = createLogger('autostake')
20 |
21 | opts = opts || {}
22 | let failed = false
23 |
24 | if (!mnemonic) {
25 | mainLogger.error('Please provide a MNEMONIC environment variable')
26 | process.exit()
27 | }
28 |
29 | async function run(networkNames, networksOverridePath) {
30 | const networks = getNetworksData(networksOverridePath)
31 | for (const name of networkNames) {
32 | if (name && !networks.map(el => el.name).includes(name)){
33 | // Assume network will be found in Chain Registry
34 | networks.push({ name, path: name })
35 | }
36 | }
37 | const calls = networks.map(data => {
38 | return async () => {
39 | if (networkNames && networkNames.length && !networkNames.includes(data.name)) return
40 | if (data.enabled === false) return
41 |
42 | const health = new Health(data.healthCheck, { dryRun: opts.dryRun, networkName: data.name })
43 | health.started('⚛')
44 | const results = await runWithRetry(data, health)
45 | const network = results[0]?.networkRunner?.network
46 | const { success, skipped } = results[results.length - 1] || {}
47 | if(!skipped && !failed){
48 | health.log(`Autostake ${success ? 'completed' : 'failed'} after ${results.length} attempt(s)`)
49 | results.forEach(({networkRunner, error}, index) => {
50 | health.log(`Attempt ${index + 1}:`)
51 | logSummary(health, networkRunner, error)
52 | })
53 | }
54 | if (success || skipped) {
55 | return health.success(`Autostake finished for ${network?.prettyName || data.name}`)
56 | } else {
57 | return health.failed(`Autostake failed for ${network?.prettyName || data.name}`)
58 | }
59 | }
60 | })
61 | await executeSync(calls, 1)
62 | }
63 |
64 | async function runWithRetry(data, health, retries, runners) {
65 | retries = retries || 0
66 | runners = runners || []
67 | const maxRetries = data.autostake?.retries ?? 2
68 | let { failedAddresses } = runners[runners.length - 1] || {}
69 | let networkRunner, error
70 | try {
71 | networkRunner = await getNetworkRunner(data)
72 | if (!networkRunner){
73 | runners.push({ skipped: true })
74 | return runners
75 | }
76 |
77 | await networkRunner.run(failedAddresses?.length && failedAddresses)
78 | if (!networkRunner.didSucceed()) {
79 | error = networkRunner.error?.message
80 | failedAddresses = networkRunner.failedAddresses()
81 | }
82 | } catch (e) {
83 | error = e.message
84 | }
85 | runners.push({ success: networkRunner?.didSucceed(), networkRunner, error, failedAddresses })
86 | if (!networkRunner?.didSucceed() && !networkRunner?.forceFail && retries < maxRetries && !failed) {
87 | await logResults(health, networkRunner, error, `Failed attempt ${retries + 1}/${maxRetries + 1}, retrying in 30 seconds...`)
88 | await new Promise(r => setTimeout(r, 30 * 1000));
89 | return await runWithRetry(data, health, retries + 1, runners)
90 | }
91 | await logResults(health, networkRunner, error)
92 | return runners
93 | }
94 |
95 | function logResults(health, networkRunner, error, message) {
96 | if(networkRunner){
97 | health.addLogs(networkRunner.queryErrors())
98 | }
99 | logSummary(health, networkRunner, error)
100 | if (message) health.log(message)
101 | return health.sendLog()
102 | }
103 |
104 | function logSummary(health, networkRunner, error) {
105 | if(networkRunner){
106 | const { results } = networkRunner
107 | const errors = results.filter(result => result.error)
108 | health.log(`Sent ${results.length - errors.length}/${results.length} transactions`)
109 | for (let [index, result] of results.entries()) {
110 | health.log(`TX ${index + 1}:`, result.message);
111 | }
112 | }
113 | if (error) health.log(`Failed with error: ${error}`)
114 | }
115 |
116 | async function getNetworkRunner(data) {
117 | const network = new Network(data)
118 | let config = { ...opts }
119 | try {
120 | await network.load()
121 | } catch(e) {
122 | if(e.response.status === 404){
123 | failed = true
124 | throw new Error(`${network.name} not found in Chain Registry`)
125 | }
126 | throw new Error(`Unable to load network data for ${network.name}`)
127 | }
128 |
129 | const logger = mainLogger.child({ chain: network.name })
130 |
131 | logger.info('Loaded chain', { prettyName: network.prettyName })
132 |
133 | const { signer, slip44 } = await getSigner(network)
134 | const wallet = new Wallet(network, signer)
135 | const botAddress = await wallet.getAddress()
136 |
137 | logger.info('Bot address', { address: botAddress })
138 |
139 | if (network.slip44 && network.slip44 !== slip44) {
140 | logger.warn("!! You are not using the preferred derivation path !!")
141 | logger.warn("!! You should switch to the correct path unless you have grants. Check the README !!")
142 | }
143 |
144 | const operator = network.getOperatorByBotAddress(botAddress)
145 | if (!operator) {
146 | logger.info('Not an operator')
147 | return
148 | }
149 |
150 | if (!network.authzSupport) {
151 | logger.info('No Authz support')
152 | return
153 | }
154 |
155 | await network.connect({ timeout: config.delegationsTimeout || 20000 })
156 |
157 | const { restUrl, usingDirectory } = network
158 |
159 | if (!restUrl) throw new Error('Could not connect to REST API')
160 |
161 | logger.info('Using REST URL', { url: restUrl })
162 |
163 | if (usingDirectory) {
164 | logger.warn('You are using public nodes, they may not be reliable. Check the README to use your own')
165 | logger.warn('Delaying briefly and adjusting config to reduce load...')
166 | config = {...config, batchPageSize: 50, batchQueries: 10, queryThrottle: 2500}
167 | await new Promise(r => setTimeout(r, (Math.random() * 31) * 1000));
168 | }
169 |
170 | const signingClient = wallet.signingClient()
171 | signingClient.registry.register("/cosmos.authz.v1beta1.MsgExec", MsgExec)
172 |
173 | return new NetworkRunner(
174 | network,
175 | operator,
176 | signingClient,
177 | config
178 | )
179 | }
180 |
181 | async function getSigner(network) {
182 | const logger = mainLogger.child({ chain: network.name })
183 |
184 | let slip44
185 | if (network.data.autostake?.correctSlip44 || network.slip44 === 60) {
186 | if (network.slip44 === 60) logger.info('Found ETH coin type')
187 | slip44 = network.slip44 || 118
188 | } else {
189 | slip44 = network.data.autostake?.slip44 || 118
190 | }
191 | let hdPath = [
192 | Slip10RawIndex.hardened(44),
193 | Slip10RawIndex.hardened(slip44),
194 | Slip10RawIndex.hardened(0),
195 | Slip10RawIndex.normal(0),
196 | Slip10RawIndex.normal(0),
197 | ];
198 | slip44 != 118 && logger.info('Using HD Path', { path: pathToString(hdPath) })
199 |
200 | let signer = await DirectSecp256k1HdWallet.fromMnemonic(mnemonic, {
201 | prefix: network.prefix,
202 | hdPaths: [hdPath]
203 | });
204 |
205 | if (slip44 === 60) {
206 | const ethSigner = EthWallet.fromMnemonic(mnemonic);
207 | signer = EthSigner(signer, ethSigner, network.prefix)
208 | }
209 |
210 | return { signer, slip44 }
211 | }
212 |
213 | function getNetworksData(networksOverridePath) {
214 | const networksData = fs.readFileSync('src/networks.json');
215 | const networks = JSON.parse(networksData);
216 | try {
217 | const overridesData = fs.readFileSync(networksOverridePath);
218 | const overrides = overridesData && JSON.parse(overridesData) || {}
219 | return overrideNetworks(networks, overrides)
220 | } catch (error) {
221 | mainLogger.error('Failed to parse networks.local.json, check JSON is valid', { message: error.message })
222 | return networks
223 | }
224 | }
225 |
226 | return {
227 | run
228 | }
229 | }
230 |
--------------------------------------------------------------------------------
/src/utils/SigningClient.mjs:
--------------------------------------------------------------------------------
1 | import _ from 'lodash'
2 | import { multiply, ceil, bignumber } from 'mathjs'
3 | import Long from "long";
4 |
5 | import {
6 | defaultRegistryTypes as defaultStargateTypes,
7 | assertIsDeliverTxSuccess,
8 | GasPrice
9 | } from "@cosmjs/stargate";
10 | import { sleep } from "@cosmjs/utils";
11 | import { makeSignDoc, Registry } from "@cosmjs/proto-signing";
12 | import { toBase64, fromBase64 } from '@cosmjs/encoding'
13 | import { PubKey } from "cosmjs-types/cosmos/crypto/secp256k1/keys.js";
14 | import { SignMode } from "cosmjs-types/cosmos/tx/signing/v1beta1/signing.js";
15 | import { AuthInfo, Fee, TxBody, TxRaw } from "cosmjs-types/cosmos/tx/v1beta1/tx.js";
16 |
17 | import { coin, get, post } from './Helpers.mjs'
18 |
19 | function SigningClient(network, signer) {
20 |
21 | const defaultGasPrice = network.gasPricePrefer || network.gasPrice
22 | const { restUrl, gasModifier: defaultGasModifier, slip44: coinType, chainId } = network
23 |
24 | const registry = new Registry(defaultStargateTypes);
25 |
26 | function getAccount(address) {
27 | return get(restUrl + "/cosmos/auth/v1beta1/accounts/" + address)
28 | .then((res) => res.data.account)
29 | .then((value) => {
30 | // see https://github.com/chainapsis/keplr-wallet/blob/7ca025d32db7873b7a870e69a4a42b525e379132/packages/cosmos/src/account/index.ts#L73
31 | // If the chain modifies the account type, handle the case where the account type embeds the base account.
32 | // (Actually, the only existent case is ethermint, and this is the line for handling ethermint)
33 | const baseAccount =
34 | value.BaseAccount || value.baseAccount || value.base_account;
35 | if (baseAccount) {
36 | value = baseAccount;
37 | }
38 |
39 | // If the account is the vesting account that embeds the base vesting account,
40 | // the actual base account exists under the base vesting account.
41 | // But, this can be different according to the version of cosmos-sdk.
42 | // So, anyway, try to parse it by some ways...
43 | const baseVestingAccount =
44 | value.BaseVestingAccount ||
45 | value.baseVestingAccount ||
46 | value.base_vesting_account;
47 | if (baseVestingAccount) {
48 | value = baseVestingAccount;
49 |
50 | const baseAccount =
51 | value.BaseAccount || value.baseAccount || value.base_account;
52 | if (baseAccount) {
53 | value = baseAccount;
54 | }
55 | }
56 |
57 | // Handle nested account like Desmos
58 | const nestedAccount = value.account
59 | if(nestedAccount){
60 | value = nestedAccount
61 | }
62 |
63 | return value
64 | })
65 | .catch((error) => {
66 | if(error.response?.status === 404){
67 | throw new Error('Account does not exist on chain')
68 | }else{
69 | throw error
70 | }
71 | })
72 | };
73 |
74 | // vendored to handle large integers
75 | // https://github.com/cosmos/cosmjs/blob/0f0c9d8a754cbf01e17acf51d3f2dbdeaae60757/packages/stargate/src/fee.ts
76 | function calculateFee(gasLimit, gasPrice) {
77 | const processedGasPrice = typeof gasPrice === "string" ? GasPrice.fromString(gasPrice) : gasPrice;
78 | const { denom, amount: gasPriceAmount } = processedGasPrice;
79 | const amount = ceil(bignumber(multiply(bignumber(gasPriceAmount.toString()), bignumber(gasLimit.toString()))));
80 | return {
81 | amount: [coin(amount, denom)],
82 | gas: gasLimit.toString()
83 | };
84 | }
85 |
86 | function getFee(gas, gasPrice) {
87 | if (!gas)
88 | gas = 200000;
89 | return calculateFee(gas, gasPrice || defaultGasPrice);
90 | }
91 |
92 | async function signAndBroadcastWithoutBalanceCheck(address, msgs, gas, memo, gasPrice) {
93 | try {
94 | return await signAndBroadcast(address, msgs, gas, memo, gasPrice)
95 | } finally {
96 | }
97 | }
98 |
99 | async function signAndBroadcast(address, messages, gas, memo, gasPrice) {
100 | if (!gas)
101 | gas = await simulate(address, messages, memo);
102 | const fee = getFee(gas, gasPrice);
103 | const txBody = await sign(address, messages, memo, fee)
104 | return broadcast(txBody)
105 | }
106 |
107 | async function broadcast(txBody){
108 | const timeoutMs = network.txTimeout || 60_000
109 | const pollIntervalMs = 3_000
110 | let timedOut = false
111 | const txPollTimeout = setTimeout(() => {
112 | timedOut = true;
113 | }, timeoutMs);
114 |
115 | const pollForTx = async (txId) => {
116 | if (timedOut) {
117 | throw new Error(
118 | `Transaction with ID ${txId} was submitted but was not yet found on the chain. You might want to check later. There was a wait of ${timeoutMs / 1000} seconds.`
119 | );
120 | }
121 | await sleep(pollIntervalMs);
122 | try {
123 | const response = await get(restUrl + '/cosmos/tx/v1beta1/txs/' + txId);
124 | const result = parseTxResult(response.data.tx_response)
125 | return result
126 | } catch {
127 | return pollForTx(txId);
128 | }
129 | };
130 |
131 | const response = await post(restUrl + '/cosmos/tx/v1beta1/txs', {
132 | tx_bytes: toBase64(TxRaw.encode(txBody).finish()),
133 | mode: "BROADCAST_MODE_SYNC"
134 | })
135 | const result = parseTxResult(response.data.tx_response)
136 | assertIsDeliverTxSuccess(result)
137 | return pollForTx(result.transactionHash).then(
138 | (value) => {
139 | clearTimeout(txPollTimeout);
140 | assertIsDeliverTxSuccess(value)
141 | return value
142 | },
143 | (error) => {
144 | clearTimeout(txPollTimeout);
145 | return error
146 | },
147 | )
148 | }
149 |
150 | async function sign(address, messages, memo, fee){
151 | const account = await getAccount(address)
152 | const { account_number: accountNumber, sequence } = account
153 | const txBodyBytes = makeBodyBytes(messages, memo)
154 | // Sign using standard protobuf messages
155 | const authInfoBytes = await makeAuthInfoBytes(account, {
156 | amount: fee.amount,
157 | gasLimit: fee.gas,
158 | }, SignMode.SIGN_MODE_DIRECT)
159 | const signDoc = makeSignDoc(txBodyBytes, authInfoBytes, chainId, accountNumber);
160 | const { signature, signed } = await signer.signDirect(address, signDoc);
161 | return {
162 | bodyBytes: signed.bodyBytes,
163 | authInfoBytes: signed.authInfoBytes,
164 | signatures: [fromBase64(signature.signature)],
165 | }
166 | }
167 |
168 | async function simulate(address, messages, memo, modifier) {
169 | const account = await getAccount(address)
170 | const fee = getFee(100_000)
171 | const txBody = {
172 | bodyBytes: makeBodyBytes(messages, memo),
173 | authInfoBytes: await makeAuthInfoBytes(account, {
174 | amount: fee.amount,
175 | gasLimit: fee.gas,
176 | }, SignMode.SIGN_MODE_UNSPECIFIED),
177 | signatures: [new Uint8Array()],
178 | }
179 |
180 | try {
181 | const estimate = await post(restUrl + '/cosmos/tx/v1beta1/simulate', {
182 | tx_bytes: toBase64(TxRaw.encode(txBody).finish()),
183 | }).then(el => el.data.gas_info.gas_used)
184 | return (parseInt(estimate * (modifier || defaultGasModifier)));
185 | } catch (error) {
186 | throw new Error(error.response?.data?.message || error.message)
187 | }
188 | }
189 |
190 | function parseTxResult(result){
191 | return {
192 | code: result.code,
193 | height: result.height,
194 | rawLog: result.raw_log,
195 | transactionHash: result.txhash,
196 | gasUsed: result.gas_used,
197 | gasWanted: result.gas_wanted,
198 | }
199 | }
200 |
201 | function makeBodyBytes(messages, memo){
202 | const anyMsgs = messages.map((m) => registry.encodeAsAny(m));
203 | return TxBody.encode(
204 | TxBody.fromPartial({
205 | messages: anyMsgs,
206 | memo: memo,
207 | })
208 | ).finish()
209 | }
210 |
211 | async function makeAuthInfoBytes(account, fee, mode){
212 | const { sequence } = account
213 | const accountFromSigner = (await signer.getAccounts())[0]
214 | if (!accountFromSigner) {
215 | throw new Error("Failed to retrieve account from signer");
216 | }
217 | const signerPubkey = accountFromSigner.pubkey;
218 | return AuthInfo.encode({
219 | signerInfos: [
220 | {
221 | publicKey: {
222 | typeUrl: pubkeyTypeUrl(account.pub_key),
223 | value: PubKey.encode({
224 | key: signerPubkey,
225 | }).finish(),
226 | },
227 | sequence: Long.fromNumber(sequence, true),
228 | modeInfo: { single: { mode: mode } },
229 | },
230 | ],
231 | fee: Fee.fromPartial(fee),
232 | }).finish()
233 | }
234 |
235 | function pubkeyTypeUrl(pub_key){
236 | if(pub_key && pub_key['@type']) return pub_key['@type']
237 |
238 | if(network.path === 'injective'){
239 | return '/injective.crypto.v1beta1.ethsecp256k1.PubKey'
240 | }
241 |
242 | if(coinType === 60){
243 | return '/ethermint.crypto.v1.ethsecp256k1.PubKey'
244 | }
245 | return '/cosmos.crypto.secp256k1.PubKey'
246 | }
247 |
248 | return {
249 | signer,
250 | registry,
251 | getFee,
252 | simulate,
253 | signAndBroadcast,
254 | signAndBroadcastWithoutBalanceCheck
255 | };
256 | }
257 |
258 | export default SigningClient;
259 |
--------------------------------------------------------------------------------
/src/utils/QueryClient.mjs:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import axiosRetry from 'axios-retry';
3 | import _ from "lodash";
4 |
5 | import { get } from './Helpers.mjs';
6 |
7 | const QueryClient = async (chainId, restUrls, opts) => {
8 | const config = _.merge({
9 | connectTimeout: 10000,
10 | }, opts)
11 | const restUrl = await findAvailableUrl(restUrls, "rest", { timeout: config.connectTimeout })
12 |
13 | function getAllValidators(pageSize, opts, pageCallback) {
14 | return getAllPages((nextKey) => {
15 | return getValidators(pageSize, opts, nextKey);
16 | }, pageCallback).then((pages) => {
17 | const validators = _.shuffle(pages.map((el) => el.validators).flat());
18 | return validators.reduce(
19 | (a, v) => ({ ...a, [v.operator_address]: v }),
20 | {}
21 | );
22 | });
23 | }
24 |
25 | function getValidators(pageSize, opts, nextKey) {
26 | opts = opts || {};
27 | const searchParams = new URLSearchParams();
28 | if (opts.status)
29 | searchParams.append("status", opts.status);
30 | if (pageSize)
31 | searchParams.append("pagination.limit", pageSize);
32 | if (nextKey)
33 | searchParams.append("pagination.key", nextKey);
34 | return get(
35 | apiUrl('staking', `validators?${searchParams.toString()}`), {
36 | timeout: opts.timeout || 10000,
37 | })
38 | .then((res) => res.data);
39 | }
40 |
41 | function getAllValidatorDelegations(validatorAddress,
42 | pageSize,
43 | opts,
44 | pageCallback) {
45 | return getAllPages((nextKey) => {
46 | return getValidatorDelegations(validatorAddress, pageSize, opts, nextKey);
47 | }, pageCallback).then((pages) => {
48 | return _.compact(pages.map((el) => {
49 | return el.delegation_responses
50 | }).flat());
51 | });
52 | }
53 |
54 | function getValidatorDelegations(validatorAddress, pageSize, opts, nextKey) {
55 | const searchParams = new URLSearchParams();
56 | if (pageSize)
57 | searchParams.append("pagination.limit", pageSize);
58 | if (nextKey)
59 | searchParams.append("pagination.key", nextKey);
60 |
61 | return get(apiUrl('staking', `validators/${validatorAddress}/delegations?${searchParams.toString()}`), opts)
62 | .then((res) => res.data);
63 | }
64 |
65 | function getBalance(address, denom, opts) {
66 | return get(apiUrl('bank', `balances/${address}`), opts)
67 | .then((res) => res.data)
68 | .then((result) => {
69 | if (!denom)
70 | return result.balances;
71 |
72 | const balance = result.balances?.find(
73 | (element) => element.denom === denom
74 | ) || { denom: denom, amount: 0 };
75 | return balance;
76 | });
77 | }
78 |
79 | function getDelegations(address) {
80 | return get(apiUrl('staking', `delegations/${address}`))
81 | .then((res) => res.data)
82 | .then((result) => {
83 | const delegations = result.delegation_responses.reduce(
84 | (a, v) => ({ ...a, [v.delegation.validator_address]: v }),
85 | {}
86 | );
87 | return delegations;
88 | });
89 | }
90 |
91 | function getRewards(address, opts) {
92 | return get(apiUrl('distribution', `delegators/${address}/rewards`), opts)
93 | .then((res) => res.data)
94 | .then((result) => {
95 | const rewards = result.rewards.reduce(
96 | (a, v) => ({ ...a, [v.validator_address]: v }),
97 | {}
98 | );
99 | return rewards;
100 | });
101 | }
102 |
103 | function getCommission(validatorAddress, opts) {
104 | return get(apiUrl('distribution', `validators/${validatorAddress}/commission`), opts)
105 | .then((res) => res.data)
106 | .then((result) => {
107 | return result.commission;
108 | });
109 | }
110 |
111 | function getProposals(opts) {
112 | const { pageSize } = opts || {};
113 | return getAllPages((nextKey) => {
114 | const searchParams = new URLSearchParams();
115 | searchParams.append("pagination.limit", pageSize || 100);
116 | if (nextKey)
117 | searchParams.append("pagination.key", nextKey);
118 |
119 | return get(apiUrl('gov', `proposals?${searchParams.toString()}`), opts)
120 | .then((res) => res.data);
121 | }).then((pages) => {
122 | return pages.map(el => el.proposals).flat();
123 | });
124 | }
125 |
126 | function getProposalTally(proposal_id, opts) {
127 | return get(apiUrl('gov', `proposals/${proposal_id}/tally`), opts)
128 | .then((res) => res.data);
129 | }
130 |
131 | function getProposalVote(proposal_id, address, opts) {
132 | return get(apiUrl('gov', `proposals/${proposal_id}/votes/${address}`), opts)
133 | .then((res) => res.data);
134 | }
135 |
136 | function getGranteeGrants(grantee, opts, pageCallback) {
137 | const { pageSize } = opts || {};
138 | return getAllPages((nextKey) => {
139 | const searchParams = new URLSearchParams();
140 | searchParams.append("pagination.limit", pageSize || 100);
141 | if (nextKey)
142 | searchParams.append("pagination.key", nextKey);
143 |
144 | return get(apiUrl('authz', `grants/grantee/${grantee}?${searchParams.toString()}`), opts)
145 | .then((res) => res.data);
146 | }, pageCallback).then((pages) => {
147 | return pages.map(el => el.grants).flat();
148 | });
149 | }
150 |
151 | function getGranterGrants(granter, opts, pageCallback) {
152 | const { pageSize } = opts || {};
153 | return getAllPages((nextKey) => {
154 | const searchParams = new URLSearchParams();
155 | searchParams.append("pagination.limit", pageSize || 100);
156 | if (nextKey)
157 | searchParams.append("pagination.key", nextKey);
158 |
159 | return get(apiUrl('authz', `grants/granter/${granter}?${searchParams.toString()}`), opts)
160 | .then((res) => res.data);
161 | }, pageCallback).then((pages) => {
162 | return pages.map(el => el.grants).flat();
163 | });
164 | }
165 |
166 | function getGrants(grantee, granter, opts) {
167 | const searchParams = new URLSearchParams();
168 | if (grantee)
169 | searchParams.append("grantee", grantee);
170 | if (granter)
171 | searchParams.append("granter", granter);
172 | return get(apiUrl('authz', `grants?${searchParams.toString()}`), opts)
173 | .then((res) => res.data)
174 | .then((result) => {
175 | return result.grants;
176 | });
177 | }
178 |
179 | function getWithdrawAddress(address, opts) {
180 | return get(apiUrl('distribution', `delegators/${address}/withdraw_address`))
181 | .then((res) => res.data)
182 | .then((result) => {
183 | return result.withdraw_address;
184 | });
185 | }
186 |
187 | function getTransactions(params, _opts) {
188 | const { pageSize, order, retries, ...opts } = _opts;
189 | const searchParams = new URLSearchParams();
190 | params.forEach(({ key, value }) => {
191 | searchParams.append(key, value);
192 | });
193 | if (pageSize)
194 | searchParams.append('pagination.limit', pageSize);
195 | if (order)
196 | searchParams.append('order_by', order);
197 | const client = axios.create({
198 | baseURL: restUrl,
199 | headers: { 'User-Agent': RESTAKE_USER_AGENT }
200 | });
201 | axiosRetry(client, { retries: retries || 0, shouldResetTimeout: true, retryCondition: (e) => true });
202 | return client.get(apiPath('tx', `txs?${searchParams.toString()}`), opts).then((res) => res.data);
203 | }
204 |
205 | async function getAllPages(getPage, pageCallback) {
206 | let pages = [];
207 | let nextKey, error;
208 | do {
209 | const result = await getPage(nextKey);
210 | pages.push(result);
211 | nextKey = result.pagination?.next_key;
212 | if (pageCallback)
213 | await pageCallback(pages);
214 | } while (nextKey);
215 | return pages;
216 | }
217 |
218 | async function findAvailableUrl(urls, type, opts) {
219 | if(!urls) return
220 |
221 | if (!Array.isArray(urls)) {
222 | if (urls.match('cosmos.directory')) {
223 | return urls // cosmos.directory health checks already
224 | } else {
225 | urls = [urls]
226 | }
227 | }
228 | const path = type === "rest" ? apiPath('base/tendermint', 'blocks/latest') : "/block";
229 | return Promise.any(urls.map(async (url) => {
230 | url = url.replace(/\/$/, '')
231 | try {
232 | let data = await getLatestBlock(url, type, path, opts)
233 | if (type === "rpc") data = data.result;
234 | if (data.block?.header?.chain_id === chainId) {
235 | return url;
236 | }
237 | } catch { }
238 | }));
239 | }
240 |
241 | async function getLatestBlock(url, type, path, opts){
242 | const { timeout } = opts || {}
243 | try {
244 | return await get(url + path, { timeout })
245 | .then((res) => res.data)
246 | } catch (error) {
247 | const fallback = type === 'rest' && '/blocks/latest'
248 | if (fallback && fallback !== path && error.response?.status === 501) {
249 | return getLatestBlock(url, type, fallback, opts)
250 | }
251 | throw(error)
252 | }
253 | }
254 |
255 | function apiUrl(type, path){
256 | const normalizedRestUrl = restUrl.replace(/\/$/, '')
257 | return normalizedRestUrl + apiPath(type, path)
258 | }
259 |
260 | function apiPath(type, path){
261 | const versions = config.apiVersions || {}
262 | const version = versions[type] || 'v1beta1'
263 | const normalizedType = type.replace(/^\/+/, '')
264 | const normalizedPath = path.replace(/^\/+/, '')
265 | return `/cosmos/${normalizedType}/${version}/${normalizedPath}`
266 | }
267 |
268 | return {
269 | connected: !!restUrl,
270 | restUrl,
271 | getAllValidators,
272 | getValidators,
273 | getAllValidatorDelegations,
274 | getValidatorDelegations,
275 | getBalance,
276 | getDelegations,
277 | getRewards,
278 | getCommission,
279 | getProposals,
280 | getProposalTally,
281 | getProposalVote,
282 | getGrants,
283 | getGranteeGrants,
284 | getGranterGrants,
285 | getWithdrawAddress,
286 | getTransactions
287 | };
288 | };
289 |
290 | export default QueryClient;
291 |
--------------------------------------------------------------------------------
/src/autostake/NetworkRunner.mjs:
--------------------------------------------------------------------------------
1 | import _ from 'lodash'
2 |
3 | import { add, subtract, bignumber, floor, smaller, smallerEq } from 'mathjs'
4 |
5 | import { MsgDelegate } from "cosmjs-types/cosmos/staking/v1beta1/tx.js";
6 | import { coin, mapSync, executeSync, parseGrants, createLogger } from '../utils/Helpers.mjs'
7 |
8 | export default class NetworkRunner {
9 | constructor(network, operator, signingClient, opts) {
10 | this.network = network
11 | this.operator = operator
12 | this.signingClient = signingClient
13 | this.queryClient = network.queryClient
14 | this.opts = _.merge({
15 | batchPageSize: 100,
16 | batchQueries: 25,
17 | batchTxs: 50,
18 | delegationsTimeout: 20000,
19 | queryTimeout: network.data.autostake?.delegatorTimeout || 5000, // deprecate delegatorTimeout
20 | queryThrottle: 100,
21 | gasModifier: 1.1,
22 | }, network.data.autostake, opts)
23 | this.batch = {}
24 | this.messages = []
25 | this.processed = {}
26 | this.errors = {}
27 | this.results = []
28 |
29 | this.logger = createLogger('network_runner').child({ chain: network.name })
30 | }
31 |
32 | didSucceed() {
33 | return !this.allErrors().length && !this.error
34 | }
35 |
36 | allErrors() {
37 | return [
38 | ...this.queryErrors(),
39 | ...this.results.filter(el => el.error).map(el => el.message)
40 | ]
41 | }
42 |
43 | queryErrors() {
44 | return Object.entries(this.errors).map(([address, error]) => {
45 | return [address, error].join(': ')
46 | })
47 | }
48 |
49 | failedAddresses() {
50 | return [
51 | ...Object.keys(this.errors),
52 | ...this.results.filter(el => el.error).map(el => el.addresses).flat()
53 | ]
54 | }
55 |
56 | setError(address, error) {
57 | this.logger.error(error, { address })
58 | this.errors[address] = error
59 | }
60 |
61 | async run(addresses) {
62 | try {
63 | this.logger.info('Running with options', this.opts)
64 | this.balance = await this.getBalance() || 0
65 |
66 | let grantedAddresses = await this.getAddressesFromGrants(addresses)
67 | if(grantedAddresses === false){
68 | this.logger.warn('All grants query not supported, falling back to checking delegators...')
69 | grantedAddresses = await this.getAddressesFromDelegators(addresses)
70 | }
71 |
72 | this.logger.info('Found addresses with valid grants...', { count: grantedAddresses.length})
73 | if (grantedAddresses.length) {
74 | await this.autostake(grantedAddresses)
75 | }
76 | return true
77 | } catch (error) {
78 | this.error = error
79 | return false
80 | }
81 | }
82 |
83 | getBalance() {
84 | let timeout = this.opts.delegationsTimeout
85 | const denom = this.network.gasPriceDenom || this.network.denom
86 | return this.queryClient.getBalance(this.operator.botAddress, denom, { timeout })
87 | .then(
88 | (balance) => {
89 | this.logger.info('Fetched bot balance', balance)
90 | return balance.amount
91 | },
92 | (error) => {
93 | throw new Error(`Failed to get balance: ${error.message || error}`)
94 | }
95 | )
96 | }
97 |
98 | async getAddressesFromGrants(addresses) {
99 | const { botAddress, address } = this.operator
100 | let timeout = this.opts.delegationsTimeout
101 | let pageSize = this.opts.batchPageSize
102 | let allGrants
103 | try {
104 | this.logger.info('Finding all grants...')
105 | allGrants = await this.queryClient.getGranteeGrants(botAddress, { timeout, pageSize }, (pages) => {
106 | this.logger.info('...batch', { length: pages.length })
107 | return this.throttleQuery()
108 | })
109 | } catch (error) {
110 | if(error.response?.status === 501){
111 | return false
112 | }else{
113 | throw new Error(`Failed to load grants: ${error.response?.status}`)
114 | }
115 | }
116 | if (addresses){
117 | this.logger.info('Checking addresses for grants...', { length: addresses.length })
118 | } else {
119 | addresses = allGrants.map(grant => grant.granter)
120 | }
121 | let addressGrants = _.uniq(addresses).map(item => {
122 | return this.parseGrantResponse(allGrants, botAddress, item, address)
123 | })
124 | return _.compact(addressGrants.flat())
125 | }
126 |
127 | async getAddressesFromDelegators(addresses) {
128 | if (!addresses) {
129 | this.logger.info('Finding delegators...')
130 | addresses = await this.getDelegations().then(delegations => {
131 | return delegations.map(delegation => {
132 | if (delegation.balance.amount === 0) return
133 |
134 | return delegation.delegation.delegator_address
135 | })
136 | })
137 | this.logger.info('Checking delegators for grants...', { length: addresses.length })
138 | } else {
139 | this.logger.info('Checking addresses for grants...', { length: addresses.length })
140 | }
141 | let grantCalls = _.uniq(addresses).map(item => {
142 | return async () => {
143 | try {
144 | return await this.getGrantsIndividually(item)
145 | } catch (error) {
146 | this.setError(item, `Failed to get grants: ${error.message}`)
147 | }
148 | }
149 | })
150 | let grantedAddresses = await mapSync(grantCalls, this.opts.batchQueries, (batch, index) => {
151 | this.logger.info('...batch', { count: index + 1 })
152 | return this.throttleQuery()
153 | })
154 | return _.compact(grantedAddresses.flat())
155 | }
156 |
157 | getDelegations() {
158 | let timeout = this.opts.delegationsTimeout
159 | let pageSize = this.opts.batchPageSize
160 | return this.queryClient.getAllValidatorDelegations(this.operator.address, pageSize, { timeout }, (pages) => {
161 | this.logger.info('...batch', { count: pages.length })
162 | return this.throttleQuery()
163 | }).catch(error => {
164 | throw new Error(`Failed to get delegations: ${error.message || error}`)
165 | })
166 | }
167 |
168 | getGrantsIndividually(delegatorAddress) {
169 | const { botAddress, address } = this.operator
170 | let timeout = this.opts.queryTimeout
171 | return this.queryClient.getGrants(botAddress, delegatorAddress, { timeout })
172 | .then(
173 | (result) => {
174 | return this.parseGrantResponse(result, botAddress, delegatorAddress, address)
175 | },
176 | (error) => {
177 | this.setError(delegatorAddress, `ERROR failed to get grants: ${error.message || error}`)
178 | }
179 | )
180 | }
181 |
182 | parseGrantResponse(grants, botAddress, delegatorAddress, validatorAddress) {
183 | const result = parseGrants(grants, botAddress, delegatorAddress)
184 | let grantValidators, maxTokens
185 | if (result.stakeGrant) {
186 | if (result.stakeGrant.authorization['@type'] === "/cosmos.authz.v1beta1.GenericAuthorization") {
187 | grantValidators = [validatorAddress];
188 | } else {
189 | const { allow_list, deny_list } = result.stakeGrant.authorization
190 | if(allow_list?.address){
191 | grantValidators = allow_list?.address || []
192 | }else if(deny_list?.address){
193 | grantValidators = deny_list.address.includes(validatorAddress) || deny_list.address.includes('') ? [] : [validatorAddress]
194 | }else{
195 | grantValidators = []
196 | }
197 | if (!grantValidators.includes(validatorAddress)) {
198 | this.logger.info('Not autostaking for this validator, skipping', { delegatorAddress })
199 | return
200 | }
201 | maxTokens = result.stakeGrant.authorization.max_tokens
202 | }
203 |
204 | const grant = {
205 | maxTokens: maxTokens && bignumber(maxTokens.amount),
206 | validators: grantValidators,
207 | }
208 | return { address: delegatorAddress, grant: grant }
209 | }
210 | }
211 |
212 | async getAutostakeMessage(grantAddress) {
213 | const { address, grant } = grantAddress
214 |
215 | let timeout = this.opts.queryTimeout
216 | let withdrawAddress, totalRewards
217 | try {
218 | withdrawAddress = await this.queryClient.getWithdrawAddress(address, { timeout })
219 | } catch (error) {
220 | this.setError(address, `ERROR failed to get withdraw address: ${error.message || error}`)
221 | return
222 | }
223 | if (withdrawAddress && withdrawAddress !== address) {
224 | this.logger.info('Wallet has a different withdraw address', {
225 | withdrawAddress,
226 | address
227 | })
228 | return
229 | }
230 |
231 | try {
232 | totalRewards = await this.totalRewards(address)
233 | } catch (error) {
234 | this.setError(address, `ERROR failed to get rewards: ${error.message || error}`)
235 | return
236 | }
237 | if (totalRewards === undefined) return
238 |
239 | let autostakeAmount = floor(totalRewards)
240 |
241 | if (smaller(bignumber(autostakeAmount), bignumber(this.operator.minimumReward))) {
242 | this.logger.info('Reward is too low, skipping', {
243 | address,
244 | amount: autostakeAmount,
245 | denom: this.network.denom,
246 | })
247 | return
248 | }
249 |
250 | if (grant.maxTokens) {
251 | if (smallerEq(grant.maxTokens, 0)) {
252 | this.logger.info('Grant balance is empty, skipping', {
253 | address,
254 | maxTokens: grant.maxToken,
255 | denom: this.network.denom,
256 | })
257 | return
258 | }
259 | if (smaller(grant.maxTokens, autostakeAmount)) {
260 | autostakeAmount = grant.maxTokens
261 | this.logger.info('Grant balance is too low, using remaining', {
262 | address,
263 | maxTokens: grant.maxToken,
264 | denom: this.network.denom,
265 | })
266 | }
267 | }
268 |
269 | this.logger.info('Can autostake', {
270 | address,
271 | amount: autostakeAmount,
272 | denom: this.network.denom,
273 | })
274 |
275 | return this.buildRestakeMessage(address, this.operator.address, autostakeAmount, this.network.denom)
276 | }
277 |
278 | async autostake(grantedAddresses) {
279 | let batchSize = this.opts.batchTxs
280 | this.logger.info('Calculating and autostaking in batches', { batchSize })
281 |
282 | const calls = grantedAddresses.map((item, index) => {
283 | return async () => {
284 | let messages
285 | try {
286 | messages = await this.getAutostakeMessage(item)
287 | } catch (error) {
288 | this.setError(item.address, `ERROR Failed to get autostake message ${error.message}`)
289 | }
290 | this.processed[item.address] = true
291 |
292 | await this.sendInBatches(item.address, messages, batchSize, grantedAddresses.length)
293 | }
294 | })
295 | await executeSync(calls, this.opts.batchQueries)
296 |
297 | this.results = await Promise.all(this.messages)
298 | }
299 |
300 | async sendInBatches(address, messages, batchSize, total) {
301 | if (messages) {
302 | this.batch[address] = messages
303 | }
304 |
305 | const addresses = Object.keys(this.batch)
306 | const finished = (Object.keys(this.processed).length >= total && addresses.length > 0)
307 | if (addresses.length >= batchSize || finished) {
308 | const batch = Object.values(this.batch).flat()
309 | this.batch = {}
310 |
311 | const messages = [...this.messages]
312 | const promise = messages[messages.length - 1] || Promise.resolve()
313 | const sendTx = promise.then(() => {
314 | this.logger.info('Sending batch', { batch: messages.length + 1 })
315 | return this.sendMessages(addresses, batch)
316 | })
317 | this.messages.push(sendTx)
318 | return sendTx
319 | }
320 | }
321 |
322 | async sendMessages(addresses, messages) {
323 | try {
324 | const execMsg = this.buildExecMessage(this.operator.botAddress, messages)
325 | const memo = 'REStaked by ' + this.operator.moniker
326 | const gasModifier = this.opts.gasModifier
327 | const gas = await this.signingClient.simulate(this.operator.botAddress, [execMsg], memo, gasModifier);
328 | const fee = this.signingClient.getFee(gas).amount[0]
329 | if (smaller(bignumber(this.balance), bignumber(fee.amount))) {
330 | this.forceFail = true
331 | throw new Error(`Bot balance is too low (${this.balance}/${fee.amount}${fee.denom})`)
332 | }
333 |
334 | if (this.opts.dryRun) {
335 | const message = `DRYRUN: Would send ${messages.length} messages using ${gas} gas`
336 | this.logger.info('DRYRUN: Would send messages using gas', {
337 | messages: messages.length,
338 | gas,
339 | })
340 | this.balance = subtract(bignumber(this.balance), bignumber(fee.amount))
341 | return { message, addresses }
342 | } else {
343 | return await this.signingClient.signAndBroadcast(this.operator.botAddress, [execMsg], gas, memo).then((response) => {
344 | const message = `Sent ${messages.length} messages - ${response.transactionHash}`
345 | this.logger.info('Sent messages', {
346 | transactionHash: response.transactionHash,
347 | length: messages.length,
348 | })
349 | this.balance = subtract(bignumber(this.balance), bignumber(fee.amount))
350 | return { message, addresses }
351 | }, (error) => {
352 | const message = `Failed ${messages.length} messages - ${error.message}`
353 | this.logger.info('Failed messages', {
354 | error: error.message,
355 | length: messages.length,
356 | })
357 | return { message, addresses, error }
358 | })
359 | }
360 | } catch (error) {
361 | const message = `Failed ${messages.length} messages: ${error.message}`
362 | this.logger.info('Failed messages', {
363 | error: error.message,
364 | length: messages.length,
365 | })
366 | return { message, addresses, error }
367 | }
368 | }
369 |
370 | async throttleQuery(){
371 | if(!this.opts.queryThrottle) return
372 |
373 | await new Promise(r => setTimeout(r, this.opts.queryThrottle));
374 | }
375 |
376 | buildExecMessage(botAddress, messages) {
377 | return {
378 | typeUrl: "/cosmos.authz.v1beta1.MsgExec",
379 | value: {
380 | grantee: botAddress,
381 | msgs: messages
382 | }
383 | }
384 | }
385 |
386 | buildRestakeMessage(address, validatorAddress, amount, denom) {
387 | return [{
388 | typeUrl: "/cosmos.staking.v1beta1.MsgDelegate",
389 | value: MsgDelegate.encode(MsgDelegate.fromPartial({
390 | delegatorAddress: address,
391 | validatorAddress: validatorAddress,
392 | amount: coin(amount, denom)
393 | })).finish()
394 | }]
395 | }
396 |
397 | totalRewards(address) {
398 | let timeout = this.opts.queryTimeout
399 | return this.queryClient.getRewards(address, { timeout })
400 | .then(
401 | (rewards) => {
402 | const total = Object.values(rewards).reduce((sum, item) => {
403 | const reward = item.reward.find(el => el.denom === this.network.denom)
404 | if (reward && item.validator_address === this.operator.address) {
405 | return add(sum, bignumber(reward.amount))
406 | }
407 | return sum
408 | }, 0)
409 | return total
410 | }
411 | )
412 | }
413 | }
414 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # [REStake](https://restake.app)
2 |
3 | REStake allows delegators to grant permission for a validator to compound their rewards, and provides a script validators can run to find their granted delegators and send the compounding transactions automatically.
4 |
5 | REStake is also a convenient staking tool, allowing you to claim and compound your rewards individually or in bulk. This can save transaction fees and time, and many more features are planned.
6 |
7 | [](https://restake.app)
8 |
9 | Try it out at [restake.app](https://restake.app).
10 |
11 | The REStake UI has it's own dedicated repository at [eco-stake/restake-ui](https://github.com/eco-stake/restake-ui).
12 |
13 | ## How it works / Authz
14 |
15 | Authz is a new feature for Tendermint chains which lets you grant permission to another wallet to carry out certain transactions for you. These transactions are sent by the grantee on behalf of the granter, meaning the validator will send and pay for the TX, but actions will affect your wallet (such as claiming rewards).
16 |
17 | REStake specifically lets you grant a validator permission to send `Delegate` transactions for their validator only. The validator cannot send any other transaction types, and has no other access to your wallet. You authorize this using Keplr as normal. REStake no longer requires a `Withdraw` permission to autostake.
18 |
19 | A script is also provided which allows a validator to automatically search their delegators, check each for the required grants, and if applicable carry out the claim and delegate transactions on their behalf in a single transaction. This script should be run daily, and the time you will run it can be specified when you [add your operator](#become-an-operator).
20 |
21 | ## Limitations
22 |
23 | - Authz is also not fully supported yet. Many chains are yet to update. The REStake UI will fall back to being a manual staking app with useful manual compounding features.
24 | - Currently REStake needs the browser extension version of Keplr, but WalletConnect and Keplr iOS functionality will be added ASAP.
25 | - REStake requires Nodejs version 20.x or later, it will not work with earlier versions.
26 |
27 | ## Become an operator
28 |
29 | Becoming an operator is pretty easy, the overall process is as follows:
30 |
31 | 1. Setup your bot wallet
32 | 2. Setup the autostaking script
33 | 3. Setup a cron or timer to run the script on a schedule
34 | 4. Submit your operator to Validator Registry
35 |
36 | ### Setup a hot wallet
37 |
38 | Generate a new hot wallet you will use to automatically carry out the staking transactions. The mnemonic will need to be provided to the script so **use a dedicated wallet and only keep enough funds for transaction fees**. The ONLY mnemonic required here is for the hot wallet, do not put your validator operator mnemonic anywhere.
39 |
40 | You only need a single mnemonic for multiple Cosmos chains, and the script will check each network in the [networks.json](./src/networks.json) file for a matching bot address.
41 |
42 | #### Derivation Paths (IMPORTANT)
43 |
44 | Right now, the REStake autostaking script uses the standard 118 derivation path by default. Some networks prefer a different path and apps like Keplr will honour this. **The address the autostake script uses might not match Keplr**.
45 |
46 | As there are existing operators using the 118 path, operators will need to opt in to the correct path when they want to upgrade. **New operators should use the correct path before they get grants**.
47 |
48 | The correct path can be set in one of two ways using a [config override](#customise-restake-and-use-your-own-node) file. `"correctSlip44": true` will use the slip44 defined in the Chain Registry. Alternatively set a specific path using `"slip44": 69`. You should use `"correctSlip44": true` if possible.
49 |
50 | ```jsonc
51 | {
52 | "desmos": {
53 | "prettyName": "Desmos 852",
54 | "autostake": {
55 | "correctSlip44": true
56 | }
57 | }
58 | }
59 | ```
60 |
61 | In the future, `correctSlip44` will become the default and you will need to set `slip44` explicitly if you want to use the 118 path.
62 |
63 | ### Setup the autostaking script
64 |
65 | You can run the autostaking script using `docker-compose` or using `npm` directly. In both cases you will need to provide your mnemonic in a `MNEMONIC` environment variable.
66 |
67 | #### Instructions for Docker Compose
68 |
69 | ##### Install Docker and Docker Compose
70 |
71 | Best bet is to follow the Docker official guides. Install Docker first, then Docker Compose. In recent versions, Docker and Docker Compose may combined into a single installation.
72 |
73 | Docker: [docs.docker.com/get-docker](https://docs.docker.com/get-docker/)
74 |
75 | Docker Compose: [docs.docker.com/compose/install](https://docs.docker.com/compose/install/)
76 |
77 | ##### Clone the repository and setup .env
78 |
79 | Clone the repository and copy the sample `.env` file ready for your mnemonic.
80 |
81 | ```bash
82 | git clone https://github.com/eco-stake/restake
83 | cd restake
84 | cp .env.sample .env
85 | ```
86 |
87 | **Populate your new .env file with your mnemonic.**
88 |
89 | #### Instructions for NPM
90 |
91 | ##### Install nodejs@v20
92 |
93 | ```bash
94 | curl -sL https://deb.nodesource.com/setup_20.x -o /tmp/nodesource_setup.sh
95 | # read the script file and when you're sure it's safe run it
96 | chmod +x /tmp/nodesource_setup.sh
97 | /tmp/nodesource_setup.sh
98 | apt install nodejs -y
99 | node --version
100 | > v20.9.0
101 | npm --version
102 | > 10.1.0
103 | ```
104 |
105 | ##### Clone the repository and build it
106 |
107 | ```bash
108 | git clone https://github.com/eco-stake/restake
109 | cd restake
110 | npm install
111 | cp .env.sample .env
112 | ```
113 |
114 | **Populate your new .env file with your mnemonic.**
115 |
116 | #### Updating your local version
117 |
118 | REStake is MVP. Very MVP. Updates are happening all the time and there are bugs that still need fixing. Make sure you update often.
119 |
120 | Update your local repository and pre-build your Docker containers with the following commands:
121 |
122 | ```bash
123 | git pull
124 | docker-compose run --rm app npm install
125 | docker-compose build --no-cache
126 | ```
127 |
128 | or with NPM:
129 |
130 | ```bash
131 | git pull
132 | npm install
133 | ```
134 |
135 | #### Running the script
136 |
137 | Running the autostake script manually is then simple.
138 |
139 | **If you use Docker then you should prefix the below commands with `docker-compose run --rm app`.**
140 |
141 | Note you might need `sudo` depending on your docker install, and some docker versions utilize `docker compose` instead of `docker-compose`. If you run into issues, try substituting `docker compose`.
142 |
143 | ```bash
144 | docker-compose run --rm app npm run autostake
145 | ```
146 |
147 | Alternatively if you use NPM you can ignore the `docker-compose run --rm app` prefix:
148 |
149 | ```bash
150 | npm run autostake
151 | ```
152 |
153 | Pass network names to restrict the script to certain networks.
154 |
155 | ```bash
156 | npm run autostake osmosis akash regen
157 | ```
158 |
159 | A _Dry Run_ script is also included, which runs the normal autostake script but skips sending the final TXs, and skips any health check pings.
160 |
161 | ```bash
162 | npm run dryrun osmosis
163 | ```
164 |
165 | **You should expect to see a warning that you are 'not an operator' until your REStake operator information is submitted in [Submitting your operator](#submitting-your-operator)**
166 |
167 | ### Customise REStake and use your own node
168 |
169 | You will likely want to customise your networks config, e.g. to set your own node URLs to ensure your autocompounding script completes successfully.
170 |
171 | Create a `src/networks.local.json` file and specify the networks you want to override. Alternatively, set the `NETWORKS_OVERRIDE_PATH` environment variable containing the filepath. The below is just an example, **you should only override a config if you need to**.
172 |
173 | ```json
174 | {
175 | "osmosis": {
176 | "prettyName": "Osmosis",
177 | "restUrl": [
178 | "https://rest.cosmos.directory/osmosis"
179 | ],
180 | "gasPrice": "0.0025uosmo",
181 | "autostake": {
182 | "retries": 3,
183 | "batchPageSize": 100,
184 | "batchQueries": 25,
185 | "batchTxs": 50,
186 | "delegationsTimeout": 20000,
187 | "queryTimeout": 5000,
188 | "queryThrottle": 100,
189 | "gasModifier": 1.1
190 | },
191 | "operator": {
192 | "address": "OVERRIDE_BOT_ADDRESS",
193 | "minimumReward": "OVERRIDE_BOT_MINIMUM_REWARD"
194 | },
195 | "healthCheck": {
196 | "uuid": "XXXXX-XXX-XXXX"
197 | }
198 | },
199 | "desmos": {
200 | "prettyName": "Desmos 118",
201 | "autostake": {
202 | "correctSlip44": true
203 | }
204 | },
205 | "cosmoshub": {
206 | "enabled": false
207 | }
208 | }
209 | ```
210 |
211 | Any values you specify will override the `networks.json` file. These are examples, you can override as much or little as you need.
212 |
213 | Arrays will be replaced and not merged. The file is `.gitignore`'d so it won't affect upstream updates.
214 |
215 | Note that REStake requires a node with indexing enabled and minimum gas prices matching the `networks.json` gas price (or your local override).
216 |
217 | ### Setting up cron/timers to run the script on a schedule
218 |
219 | You should setup your script to run at the same time each day. 2 methods are described below; using `crontab` or using `systemd-timer`.
220 |
221 | In both cases, ensure your system time is correct and you know what time the script will run in UTC, as that will be required later. Both examples below are for 21:00.
222 |
223 | Don't forget to [update often](#updating-your-local-version)!
224 |
225 | #### Using `crontab`
226 |
227 | Note: A helpful calculator for determining your REStake timer for `crontab` can be found here:
● restake.timer - Restake bot timer 317 | Loaded: loaded (/etc/systemd/system/restake.timer; enabled; vendor preset: enabled) 318 | Active: active (waiting) since Sun 2022-03-06 22:29:48 UTC; 2 days ago 319 | Trigger: Wed 2022-03-09 21:00:00 UTC; 7h left 320 | Triggers: ● restake.service 321 |322 | 323 | `$ systemctl status restake.service` 324 |
● restake.service - stakebot service with docker compose 325 | Loaded: loaded (/etc/systemd/system/restake.service; enabled; vendor preset: enabled) 326 | Active: inactive (dead) since Tue 2022-03-08 21:00:22 UTC; 16h ago 327 | TriggeredBy: ● restake.timer 328 | Process: 86925 ExecStart=/usr/bin/docker-compose run --rm app npm run autostake (code=exited, status=0/SUCCESS) 329 | Main PID: 86925 (code=exited, status=0/SUCCESS) 330 |331 | 332 | ### Monitoring 333 | 334 | The REStake autostaking script can integrate with [healthchecks.io](https://healthchecks.io/) to report the script status for each network. [healthchecks.io](https://healthchecks.io/) can then integrate with many notification platforms like email, Discord and Slack to make sure you know about any failures. 335 | 336 | Once configured, REStake will ping [healthchecks.io](https://healthchecks.io/) when the script starts, succeeds, or fails. It will include relevant error information in the check log and is simple to configure. 337 | 338 | Setup a Check for each network you run the script for, and configure the expected schedule. E.g. add a check for Osmosis every 12 hours, Akash every 1 hour etc. Set a timeout in the region of 5 minutes, or slightly longer than you expect the script to run. 339 | 340 | Add your Check UUID to the relevant network in your `networks.local.json` config as below. You can also optionally set the `address` attribute if you want to [self-host the healthchecks.io platform](https://healthchecks.io/docs/self_hosted/). 341 | 342 | ```JSON 343 | { 344 | "osmosis": { 345 | "healthCheck": { 346 | "uuid": "77f02efd-c521-46cb-70g8-fa5v275au873" 347 | } 348 | } 349 | } 350 | ``` 351 | 352 | If you wish for your health checks to be created automatically, you can provide an [API key](https://healthchecks.io/docs/api/) to manage it for you. 353 | The default behavior is for the check to be named after the network, but this can be overridden with the `name` property. 354 | 355 | **Note that the `uuid` is not necessary when using an `apiKey` to create checks automatically.** 356 | 357 | ```JSON 358 | { 359 | "cheqd": { 360 | "healthCheck": { 361 | "apiKey": "12_CanM1Q3T72uGH4kc32G14BdA4Emc4y", 362 | "name": "cheqd every 12 hours", // optional, defaults to the network name 363 | "timeout": 43200, // optional, expected seconds between each run 364 | "gracePeriod": 86400, // optional, grace period seconds before it notifies 365 | } 366 | } 367 | } 368 | ``` 369 | 370 | ### Submitting your operator 371 | 372 | #### Setup your REStake operator 373 | 374 | You now need to update the [Validator Registry](https://github.com/eco-stake/validator-registry) to add your operator information to any networks you want to auto-compound for. Check the README and existing validators for examples, but the config for a network looks like this: 375 | 376 | ```json 377 | { 378 | "name": "akash", 379 | "address": "akashvaloper1xgnd8aach3vawsl38snpydkng2nv8a4kqgs8hf", 380 | "restake": { 381 | "address": "akash1yxsmtnxdt6gxnaqrg0j0nudg7et2gqczud2r2v", 382 | "run_time": [ 383 | "09:00", 384 | "21:00" 385 | ], 386 | "minimum_reward": 1000 387 | } 388 | }, 389 | ``` 390 | 391 | `address` is your validator's address, and `restake.address` is the address from your new hot wallet you generated earlier. 392 | 393 | `restake.run_time` is the time _in UTC_ that you intend to run your bot, and there are a few options. Pass a single time, e.g. `09:00` to specify a single run at 9am UTC. Use an array for multiple specified times, e.g. `["09:00", "21:00"]`. Use an interval string for multiple times per hour/day, e.g. `"every 15 minutes"`. 394 | 395 | `restake.minimum_reward` is the minimum reward to trigger autostaking, otherwise the address is skipped. This could be set higher for more frequent restaking. Note this is in the base denomination, e.g. `uosmo`. 396 | 397 | Repeat this config for all networks you want to REStake for. 398 | 399 | Note that the `restake.address` is the address which will be granted by the delegator in the UI to carry out their restaking transactions. 400 | 401 | #### Submit your operator to the Validator Registry 402 | 403 | You can now submit your [Validator Registry](https://github.com/eco-stake/validator-registry) update to that repository in a pull request which will be merged as soon as possible. REStake automatically updates within 15 minutes of changes being merged. 404 | 405 | ## Contributing 406 | 407 | ### Adding/updating a network 408 | 409 | Network information is sourced from the [Chain Registry](https://github.com/cosmos/chain-registry) via the [registry.cosmos.directory](https://registry.cosmos.directory) API. Chains in the master branch are automatically added to REStake assuming enough basic information is provided. 410 | 411 | The `networks.json` file defines which chains appear as 'supported' in REStake; so long as the chain name matches the directory name from the Chain Registry, all chain information will be sourced automatically. Alternatively chains _can_ be supported in `networks.json` alone, but this is not a documented feature. 412 | 413 | To add or override a chain in REStake, add the required information to `networks.json` as follows: 414 | 415 | ```json 416 | { 417 | "name": "osmosis", 418 | "prettyName": "Osmosis", 419 | "gasPrice": "0.025uosmo", 420 | "authzSupport": true 421 | } 422 | ``` 423 | 424 | Note that most attributes from Chain Registry can be overridden by defining the camelCase version in networks.json. 425 | 426 | ### Running the UI 427 | 428 | The REStake UI now has it's own dedicated repository at [eco-stake/restake-ui](https://github.com/eco-stake/restake-ui). 429 | 430 | Check the project README for instructions on running your own UI. 431 | 432 | ## Ethos 433 | 434 | The REStake UI is both validator and network agnostic. Any validator can be added as an operator and run this tool to provide an auto-compounding service to their delegators, but they can also run their own UI if they choose and adjust the branding to suit themselves. 435 | 436 | For this to work, we need a common source of chain information, and a common source of 'operator' information. Chain information is sourced from the [Chain Registry](https://github.com/cosmos/chain-registry), via an API provided by [cosmos.directory](https://github.com/eco-stake/cosmos-directory). Operator information lives in the [Validator Registry](https://github.com/eco-stake/validator-registry). 437 | 438 | Now we have a common source of operator information, applications can integrate with REStake validators easier using the data directly from GitHub, or via the [cosmos.directory](https://github.com/eco-stake/cosmos-directory) project. 439 | 440 | ## Disclaimer 441 | 442 | The initial version of REStake was built quickly to take advantage of the new authz features. I'm personally not a React or Javascript developer, and this project leans extremely heavily on the [CosmJS project](https://github.com/cosmos/cosmjs) and other fantastic codebases like [Keplr Wallet](https://github.com/chainapsis/keplr-wallet) and [Osmosis Zone frontend](https://github.com/osmosis-labs/osmosis-frontend). It functions very well and any attack surface is very limited however. Any contributions, suggestions and ideas from the community are extremely welcome. 443 | 444 | ## ECO Stake 🌱 445 | 446 | ECO Stake is a climate positive validator, but we care about the Cosmos ecosystem too. We built REStake to make it easy for all validators to run an autocompounder with Authz, and it's one of many projects we work on in the ecosystem. [Delegate with us](https://ecostake.com) to support more projects like this. 447 | -------------------------------------------------------------------------------- /README_TURKISH.md: -------------------------------------------------------------------------------- 1 | # [REStake](https://restake.app) 2 | 3 | REStake, delegatorlerin validatorlerine stake ödüllerini yeniden stake etmesine izin vermelerine olanak tanır. REStake validatorlerin bir komut dosyasını çalıştırarak kendilerine verilen delegeleri bulmaları ve restake işlemlerini otomatik olarak yapmalarını sağlar. 4 | 5 | REStake, aynı zamanda kullanışlı bir stake etme aracıdır. Ödüllerinizi tek tek ya da toplu olarak telep etmenize ve yeniden stake etmenizi sağlar. Bu da işlem ücretlerinden ve zamandan tasarruf etmenizi sağlar ve daha birçok özellik de planlanmıştır. 6 | 7 | [](https://restake.app) 8 | 9 | [restake.app](https://restake.app)'ı deneyin. 10 | 11 | ## Nasıl çalışır / Authz (Yetkilendirme) 12 | 13 | Authz, Tendermint zincirleri için başka bir cüzdana sizin için belirli işlemleri gerçekleştirmesi için izin vermenizi sağlayan yeni bir özelliktir. 14 | 15 | Bu işlemler, stake eden adına stake alan tarafından gönderilir, yani valiatorler fee ücretini gönderecek ve ödeyecektir. Bu işlemler cüzdanınızı etkiler, ödül talep etme vb. yani siz restake işlemini kapatmadığınız sürece kazandığınız ödüller sürekli restake edilir. 16 | 17 | REStake, özellikle bir validatore `Delegate` işlemlerini yalnızca validatorleriniz yapmasına izin vermenizi sağlar. Validator başka herhangi bir işlem türü gönderemez ya da cüzdanınıza herhangi bir erişimi yoktur. Bunu normal olarak Keplr kullanarak yetkilendiriyorsunuz. REStake artık oto-stake için bir `çekme` izni gerektirmiyor. 18 | 19 | Doğrulayıcının delegelerini otomatik olarak aramasını sağlayan bir komut dosyası da sağlanmıştır, gerekli ödemeler için her birini kontrol edin ve varsa, talep ve işlemleri tek bir işlemde onlar adına gerçekleştirin. Bu script günlük olarak çalıştırılmalıdır ve çalıştıracağınız saat [operatörünüzü eklediğinizde](#operat%C3%B6r-olun) belirtilebilir. 20 | 21 | ## Kısıtlamalar 22 | 23 | Authz da henüz tam olarak desteklenmemektedir. Birçok zincir henüz güncellenmedi. REStake UI, kullanışlı manuel birleştirme özelliklerine sahip bir manuel stake uygulaması olmaya geri dönecek. 24 | 25 | Şu anda REStake, Keplr'ın tarayıcı uzantısı sürümüne ihtiyaç duyuyor, ancak WalletConnect ve Keplr iOS işlevselliği en kısa sürede eklenecek. 26 | 27 | ## Operatör olun 28 | 29 | Bot cüzdanınızı kurun 30 | Otomatik komut dosyasını ayarlayın 31 | Komut dosyasını bir çizelgede çalıştırmak için bir cron veya zamanlayıcı kurun 32 | Operatörünüzü Doğrulayıcı Kayıt Defterine (Validator Registry) gönderin. 33 | 34 | ### Sıcak bir cüzdan kurun 35 | 36 | Stake işlemlerini otomatik olarak gerçekleştirmek için kullanacağınız yeni bir sıcak cüzdan oluşturun. Komut dosyasına anımsatıcının (mnemonic) sağlanması gerekecek, bu nedenle özel bir cüzdan kullanın ve yalnızca işlem ücretleri için yeterli parayı saklayın. Burada gerekli olan SADECE menemonic, sıcak cüzdan içindir, validatorünüze ait cüzdan mnemoniclerinizi hiçbir yere yazmayın. 37 | 38 | Birden çok Cosmos zinciri için yalnızca tek bir anımsatıcıya ihtiyacınız vardır ve komut dosyası, eşleşen bir bot adresi için [networks.json](./src/networks.json) dosyasındaki her ağı kontrol eder. 39 | 40 | #### Türetme Yolları (ÖNEMLİ) 41 | 42 | Şu anda, REStake otomatik stake komut dosyası varsayılan olarak standart 118 türetme yolunu kullanır. Bazı ağlar farklı bir yolu tercih eder ve Keplr gibi uygulamalar bunu izin verecektir. Otomatik stake komutunun kullandığı adres Keplr ile eşleşmeyebilir. 43 | 44 | 118 yolunu kullanan mevcut operatörler olduğundan, operatörlerin yükseltme yapmak istediklerinde doğru yolu seçmeleri gerekecektir. Yeni operatörler, stake almadan önce doğru yolu kullanmalıdır. 45 | 46 | Doğru yol, bir [yapılandırma geçersiz kılma](https://github.com/AnatolianTeam/restake/edit/master/README_TURKISH.md#restakei-%C3%B6zelle%C5%9Ftirin-ve-nodeunuzu-kullan%C4%B1n) dosyası kullanılarak iki yoldan biriyle ayarlanabilir. `"correctSlip44": true`, Zincir Kaydı'nda tanımlanan slip44'ü kullanır. Alternatif olarak `"slip44": 69` kullanarak belirli bir yol ayarlayın. Mümkünse `"correctSlip44": true` kullanmalısınız. 47 | 48 | ```jsonc 49 | { 50 | "desmos": { 51 | "prettyName": "Desmos 852", 52 | "autostake": { 53 | "correctSlip44": true 54 | } 55 | } 56 | } 57 | ``` 58 | 59 | Gelecekte, `correctSlip44` varsayılan olacak ve 118 yolunu kullanmak istiyorsanız `slip44`'ü açıkça ayarlamanız gerekecek. 60 | 61 | ### Oto-stake komut dosyasını ayarlama 62 | 63 | Otomatik ayırma komut dosyasını `docker-compose` ya da doğrudan `npm` kullanarak çalıştırabilirsiniz. Her iki durumda da anımsatıcınızı bir `MNEMONIC` ortam değişkeninde sağlamanız gerekecektir. 64 | 65 | #### Docker Compose için Talimatlar 66 | 67 | ##### Docker ve Docker Compose Yükleme 68 | 69 | En iyisi Docker resmi kılavuzlarını takip etmektir. Önce Docker'ı, ardından Docker Compose'u yükleyin. Son sürümlerde, Docker ve Docker Compose tek bir kurulumda birleştirilebilir. 70 | 71 | Docker: [docs.docker.com/get-docker](https://docs.docker.com/get-docker/) 72 | 73 | Docker Compose: [docs.docker.com/compose/install](https://docs.docker.com/compose/install/) 74 | 75 | ##### Depoyu klonlama ve .env'yi kurma 76 | 77 | Depoyu klonlayın ve anımsatıcınız (menemonic kelimeleriniz) için hazır olan örnek .env dosyasını kopyalayın. 78 | 79 | ```bash 80 | git clone https://github.com/eco-stake/restake 81 | cd restake 82 | cp .env.sample .env 83 | ``` 84 | 85 | **Yeni .env dosyanıza anımsatıcı kelimelerinizi yazın.** 86 | 87 | #### NPM için Talimatlar 88 | 89 | ##### nodejs@v20 yükleme 90 | 91 | ```bash 92 | curl -sL https://deb.nodesource.com/setup_20.x -o /tmp/nodesource_setup.sh 93 | # read the script file and when you're sure it's safe run it 94 | chmod +x /tmp/nodesource_setup.sh 95 | /tmp/nodesource_setup.sh 96 | apt install nodejs -y 97 | node --version 98 | > v20.9.0 99 | npm --version 100 | > 10.1.0 101 | ``` 102 | 103 | Depoyu klonlama ve yükleme 104 | 105 | ```bash 106 | git clone https://github.com/eco-stake/restake 107 | cd restake 108 | npm install && npm run build 109 | cp .env.sample .env 110 | ``` 111 | 112 | **Yeni .env dosyanıza anımsatıcı kelimelerinizi yazın.** 113 | 114 | #### Yerel versiyonunuzu güncelleme 115 | 116 | REStake MVP'dir. Çok MVP. Güncellemeler her zaman oluyor ve hala düzeltilmesi gereken hatalar var. Sık sık güncelleme yaptığınızdan emin olun. 117 | 118 | Yerel deponuzu güncelleyin ve aşağıdaki komutlarla Docker konteynırlarınızı önceden oluşturun: 119 | 120 | ```bash 121 | git pull 122 | docker-compose run --rm app npm install 123 | docker-compose build --no-cache 124 | ``` 125 | 126 | NPM için kodlar: 127 | 128 | ```bash 129 | git pull 130 | npm install 131 | ``` 132 | 133 | Komut dosyasını çalıştırma 134 | 135 | Oto-stake komut dosyasını manuel olarak çalıştırmak basittir. 136 | 137 | Docker kullanıyorsanız, aşağıdaki komutları `docker-compose run ---rm app` ile kullanmalısınız. 138 | 139 | Not: Docker kurulumunuzda root yetkisine sahip değilseniz `sudo` kullanmanız gerekebilir ve bazı docker sürümleri `docker-compose` yerine `docker compose`'u kullanır. Sorunlarla karşılaşırsanız, `docker compose' kullanmayı deneyin. 140 | 141 | ```bash 142 | docker-compose run --rm app npm run autostake 143 | ``` 144 | 145 | Alternatif olarak, eğer NPM kullanıyorsanız, `docker-compose run --rm app` önekini göz ardı edebilirsiniz ve aağıdaki kodu kullanmanız yeterli olacaktır. 146 | 147 | ```bash 148 | npm run autostake 149 | ``` 150 | 151 | Komut dosyasını belirli ağlarla sınırlamak için ağ adlarını yazabilirsiniz. 152 | 153 | ```bash 154 | npm run autostake osmosis akash regen 155 | ``` 156 | 157 | Normal oto-stake komut dosyasını çalıştıran ancak gönderilen son TX'leri ve herhangi bir sağlık durumu kontrolü için pingleri atlayan bir Dry Run komut dosyası da kullanılabilir. 158 | 159 | ```bash 160 | npm run dryrun osmosis 161 | ``` 162 | 163 | **REStake operatör bilgilerinizi [Operatörünüzü kaydetme](#operat%C3%B6r%C3%BCn%C3%BCz%C3%BC-kaydetme) bölümünde gösterileceği gibi [Validator Kayıt Defteri](https://github.com/eco-stake/validator-registry)'ne kaydınızı yapana kadar 'operatör olmadığınıza dair bir uyarı görebilirsiniz.** 164 | 165 | ### REStake'i özelleştirin ve node'unuzu kullanın 166 | 167 | Muhtemelen ağların yapılandırılmasını özelleştirmek isteyeceksiniz, örneğin auto compounding (ödüllerinizi otomatik olarak yeniden stake etmek) komut dosyanızın başarılı bir şekilde tamamlanmasını sağlamak için node'unuzun URL'lerinizi ayarlamak gibi. 168 | 169 | Bir `src/networks.local.json` dosyası oluşturun ve geçersiz kılmak istediğiniz ağları belirtin. Aşağıdaki sadece bir örnektir, **ihtiyaç gerekirse sadece bir yapılandırmayı geçersiz kılmalısınız.** 170 | 171 | ```json 172 | { 173 | "osmosis": { 174 | "prettyName": "Osmosis", 175 | "restUrl": [ 176 | "https://rest.cosmos.directory/osmosis" 177 | ], 178 | "gasPrice": "0.0025uosmo", 179 | "autostake": { 180 | "retries": 3, 181 | "batchPageSize": 100, 182 | "batchQueries": 25, 183 | "batchTxs": 50, 184 | "delegationsTimeout": 20000, 185 | "queryTimeout": 5000, 186 | "queryThrottle": 100, 187 | "gasModifier": 1.1 188 | }, 189 | "healthCheck": { 190 | "uuid": "XXXXX-XXX-XXXX" 191 | } 192 | }, 193 | "desmos": { 194 | "prettyName": "Desmos 118", 195 | "autostake": { 196 | "correctSlip44": true 197 | } 198 | }, 199 | "cosmoshub": { 200 | "enabled": false 201 | } 202 | } 203 | ``` 204 | 205 | Belirttiğiniz değerler `networks.json` dosyasını geçersiz kılacaktır. Bunlar örneklerdir, ihtiyacınız göre ayarlayabilirsiniz. 206 | 207 | Diziler değiştirilecek ve birleştirilmeyecektir. Dosya `.gitignore`'idir, böylece yukarı akış güncellemelerini etkilemez. 208 | 209 | REStake'in, indeksleme etkin ve `networks.json` gas fiyatı ile eşleşen minimum gas fiyatlarına sahip bir düğüm gerektirdiğini unutmayın. 210 | 211 | ### Komut dosyasını bir program dahilinde çalıştırmak için cron/zamanlayıcıları ayarlamak 212 | 213 | Komut dosyasını her gün aynı saatte çalıştıracak şekilde ayarlamalısınız. 2 yöntem aşağıda açıklanmıştır; `crontab` ya da `systemd-timer` kullanma. 214 | 215 | Her iki durumda da, sistem zamanınızın doğru olduğundan emin olun ve komut dosyasının UTC'de ne zaman çalışacağını biliyor olmalısınız, çünkü bu daha sonra gerekli olacak. Her iki örnek de saat 21:00'e göre verilmiştir. 216 | 217 | [Sık sık güncellemeyi](#yerel-versiyonunuzu-g%C3%BCncelleme) unutmayın! 218 | 219 | #### `crontab` Kullanma 220 | 221 | NOT: REStake zamanlayıcınızı `crontab`'a göre belirlemek için faydalı bir hesap makinesini buradan ulaşabilirsiniz:
● restake.timer - Restake bot timer 303 | Loaded: loaded (/etc/systemd/system/restake.timer; enabled; vendor preset: enabled) 304 | Active: active (waiting) since Sun 2022-03-06 22:29:48 UTC; 2 days ago 305 | Trigger: Wed 2022-03-09 21:00:00 UTC; 7h left 306 | Triggers: ● restake.service 307 |308 | 309 | `systemctl status restake.service` 310 |
● restake.service - stakebot service with docker compose 311 | Loaded: loaded (/etc/systemd/system/restake.service; enabled; vendor preset: enabled) 312 | Active: inactive (dead) since Tue 2022-03-08 21:00:22 UTC; 16h ago 313 | TriggeredBy: ● restake.timer 314 | Process: 86925 ExecStart=/usr/bin/docker-compose run --rm app npm run autostake (code=exited, status=0/SUCCESS) 315 | Main PID: 86925 (code=exited, status=0/SUCCESS) 316 |317 | 318 | ### İzleme 319 | 320 | Her ağ için komut dosyası durumunu bildirmek için REStake oto-stake betiği [healthchecks.io] (