├── .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 | [![](./docs/screenshot.png)](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: . 228 | 229 | Updated versions utilize `docker compose` instead of `docker-compose`. If you run into issues, try substituting `docker compose`. 230 | 231 | ```bash 232 | crontab -e 233 | 234 | 0 21 * * * /bin/bash -c "cd restake && docker compose run --rm app npm run autostake" > ./restake.log 2>&1 235 | ``` 236 | 237 | or with NPM: 238 | 239 | ```bash 240 | crontab -e 241 | 242 | 0 21 * * * /bin/bash -c "cd restake && npm run autostake" > ./restake.log 2>&1 243 | ``` 244 | 245 | Warning: Using crontab with docker without the `--rm` flag will continuously create docker images. Add another cronjob to tear down the images weekly if required: 246 | 247 | ```bash 248 | crontab -e 249 | 250 | 0 0 * * */7 /usr/bin/docker image prune -a -f 251 | ``` 252 | 253 | #### Using `systemd-timer` 254 | 255 | Systemd-timer allows to run a one-off service with specified rules. This method is arguably preferable to Cron. 256 | 257 | ##### Create a systemd unit file 258 | 259 | The unit file describes the application to run. We define a dependency with the timer with the `Wants` statement. 260 | 261 | ```bash 262 | sudo vim /etc/systemd/system/restake.service 263 | ``` 264 | 265 | ```bash 266 | [Unit] 267 | Description=restake service with docker compose 268 | Requires=docker.service 269 | After=docker.service 270 | Wants=restake.timer 271 | 272 | [Service] 273 | Type=oneshot 274 | WorkingDirectory=/path/to/restake 275 | ExecStart=/usr/bin/docker-compose run --rm app npm run autostake 276 | 277 | [Install] 278 | WantedBy=multi-user.target 279 | ``` 280 | 281 | For NPM installs, remove `Requires` and `After` directives, and change `ExecStart` to `ExecStart=/usr/bin/npm run autostake`. 282 | 283 | ##### Create a systemd timer file 284 | 285 | The timer file defines the rules for running the restake service every day. All rules are described in the [systemd documentation](https://www.freedesktop.org/software/systemd/man/systemd.timer.html). 286 | 287 | Note: Helpful calculator for determining restake times for `OnCalendar` can also be found at . 288 | 289 | ```bash 290 | sudo vim /etc/systemd/system/restake.timer 291 | ``` 292 | 293 | ```bash 294 | [Unit] 295 | Description=Restake bot timer 296 | 297 | [Timer] 298 | AccuracySec=1min 299 | OnCalendar=*-*-* 21:00:00 300 | 301 | [Install] 302 | WantedBy=timers.target 303 | ``` 304 | 305 | ##### Enable and start everything 306 | 307 | ```bash 308 | systemctl enable restake.service 309 | systemctl enable restake.timer 310 | systemctl start restake.timer 311 | ``` 312 | 313 | ##### Check your timer 314 | 315 | `$ systemctl status restake.timer` 316 |
 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 | [![](./docs/screenshot.png)](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: . 222 | 223 | Güncellenmiş sürümler, `docker-compose` yerine `docker compose` kullanır. Sorunlarla karşılaşırsanız, `docker compose` yerine bunu kullanmayı deneyin. 224 | 225 | ```bash 226 | crontab -e 227 | 0 21 * * * /bin/bash -c "cd restake && docker compose run --rm app npm run autostake" > ./restake.log 2>&1 228 | ``` 229 | 230 | ya da NPM için bunu kullanabilirsiniz: 231 | 232 | ```bash 233 | crontab -e 234 | 0 21 * * * /bin/bash -c "cd restake && npm run autostake" > ./restake.log 2>&1 235 | ``` 236 | 237 | #### `systemd-timer` Kullanma 238 | 239 | Systemd-timer, belirtilen kurallarla bir kerelik hizmetin çalıştırılmasına izin verir. Bu yöntem tartışmasız Cron'a tercih edilir. 240 | 241 | ##### systemd birimi dosyası oluşturma 242 | 243 | Birim dosyası çalıştırılacak uygulamayı tanımlar. `Wants` ve zamanlayıcı ifadesi ile bir bağımlılık tanımlıyoruz. 244 | 245 | ```bash 246 | sudo vim /etc/systemd/system/restake.service 247 | ``` 248 | 249 | ```bash 250 | [Unit] 251 | Description=restake service with docker compose 252 | Requires=docker.service 253 | After=docker.service 254 | Wants=restake.timer 255 | [Service] 256 | Type=oneshot 257 | WorkingDirectory=/path/to/restake 258 | ExecStart=/usr/bin/docker-compose run --rm app npm run autostake 259 | [Install] 260 | WantedBy=multi-user.target 261 | ``` 262 | 263 | NPM kurulumu için `Requires` ve `After` direktiflerini kaldırın ve``ExecStart`'ı` `ExecStart=/usr/bin/npm run autostake` olarak değiştirin. 264 | 265 | 🔴 **Not: Sorun yaşarsanız `WorkingDirectory=/path/to/restake` bölümünü `WorkingDirectory=/root/restake` olarak değiştiriniz. Eğer yine sorun yaşarsanız `chmod 777 /root/restake` komutu ile dosyaya okuma, yazma ve çalıştırma izni veriniz. Daha sonra `systemctl daemon-reload` yaptıktan sonra sistemi yeniden başlatınız.** 266 | 267 | 🔴 **Eğer `Failed to restake service with docker compose` gibi bir hata alırsanız yine `chmod 777 /usr/bin/docker-compose` komutu ile dosyaya okuma, yazma ve çalıştırma izni veriniz.** 268 | 269 | Çözüm için değerli arkadaşım [Odyseus](https://github.com/odyseus8)'a teşekkür ederim. 270 | 271 | ##### systemd timer dosyası oluşturma 272 | 273 | Zamanlayıcı dosyası, yeniden düzenleme hizmetini her gün çalıştırma kurallarını tanımlar. Tüm kurallar [systemd dokümanlarında] () açıklanmaktadır. 274 | 275 | Not: `OnCalendar` için restake sürelerini belirlemek için yararlı hesap makinesi adresinde bulunabilir. 276 | 277 | ```bash 278 | sudo vim /etc/systemd/system/restake.timer 279 | ``` 280 | 281 | ```bash 282 | [Unit] 283 | Description=Restake bot timer 284 | [Timer] 285 | AccuracySec=1min 286 | OnCalendar=*-*-* 21:00:00 287 | [Install] 288 | WantedBy=timers.target 289 | ``` 290 | 291 | ##### Servisleri Etkinleştirme ve Başlatma 292 | 293 | ```bash 294 | systemctl enable restake.service 295 | systemctl enable restake.timer 296 | systemctl start restake.timer 297 | ``` 298 | 299 | ##### Zamanlayıcınızı kontrol etme 300 | 301 | `systemctl status restake.timer` 302 |
 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] () ile entegre olabilir. [HealthChecks.io] () daha sonra, herhangi bir arızayı bildiğinizden emin olmak için e -posta, Discord ve Slack gibi birçok bildirim platformuyla entegre edilebilir. 321 | 322 | Yapılandırıldıktan sonra, komut dosyası başladığında, başarılı ya da başarısız olduğunda REStake [healthchecks.io](https://healthchecks.io/)'a ping atacaktır. Kontrol günlüğü ilgili hata bilgilerini içerecektir ve yapılandırılması basittir. 323 | 324 | Komut dosyasını çalıştırdığınız her ağ için bir kontrol ayarlayın ve beklenen programı yapılandırın. Örneğin, her 12 saatte bir Osmosis kontrolü ekleyin, Akash için her 1 saatte bir vb. 325 | 326 | Kontrol UUID numaranızı aşağıdaki gibi `networks.local.json` yapılandırma dosyanızda ilgili ağa ekleyin. İsteğe bağlı olarak [HealthChecks.io platformunu kendi hostinginizde barındırmak](https://healthchecks.io/docs/self_hosted/) istiyorsanız. `address` özniteliğini de ayarlayabilirsiniz. 327 | 328 | ```JSON 329 | { 330 | "osmosis": { 331 | "healthCheck": { 332 | "uuid": "77f02efd-c521-46cb-70g8-fa5v275au873" 333 | } 334 | } 335 | } 336 | ``` 337 | 338 | ### Operatörünüzü kaydetme 339 | 340 | #### REStake Operatörünüzü Kurma 341 | 342 | Artık operatör bilgilerinizi oto-sake'i aktif etmek istediğiniz ağları eklemek için [Validator Kayıt Defteri](https://github.com/eco-stake/validator-registry)'ni güncellemeniz gerekiyor. Örnekler için README ve mevcut doğrulayıcıları kontrol edebilirsiniz, ancak bir ağ için yapılandırma şuna benziyor: 343 | 344 | ```json 345 | { 346 | "name": "akash", 347 | "address": "akashvaloper1xgnd8aach3vawsl38snpydkng2nv8a4kqgs8hf", 348 | "restake": { 349 | "address": "akash1yxsmtnxdt6gxnaqrg0j0nudg7et2gqczud2r2v", 350 | "run_time": [ 351 | "09:00", 352 | "21:00" 353 | ], 354 | "minimum_reward": 1000 355 | } 356 | }, 357 | ``` 358 | 359 | `address` doğrulayıcınızın adresidir ve `restake.address` ise fee ödemeleri için oluşturduğunuz yeni sıcak cüzdanınızın adresidir. 360 | 361 | `restake.run_time` *UTC zaman diliminde* botunuzu çalıştırmayı düşündüğünüz zamandır ve orada birkaç seçenek vardır. Belli bir saat ayarlamak için, ör. `09:00`, UTC zaman diliminde 9am (sabah dokuzda) scripti çalıştırdığınızı belirtir. Birden fazla zaman için bir dizi de kullanabilirsiniz, örneğin `["09:00", "21:00"]`. Saatte/günde birden çok kez için bir aralık dizesi kullanabilirsiniz, örneğin, `"every 15 minutes"`. 362 | 363 | `restake.minimum_reward`, otomatik stake'i tetiklemek için asgari ödüldür, aksi takdirde adres atlanır. Bu, daha sık yeniden düzenleme için daha yüksek ayarlanabilir. Bunun temel nominal değer olduğunu unutmayın, Örneğin, `uosmo`. 364 | 365 | REStake yapmak istediğiniz tüm ağlar için bu yapılandırmayı tekrarlayın. 366 | 367 | `restake.address`'in kullanıcı ara yüzünde delegator'ün restake işlemlerini gerçekleştirmek için vermiş olduğu adrese stake işleminde fee ücretinin alınacağı adres olduğunu unutmayın. 368 | 369 | #### Operatörünüzü Validator Kayıt Defterine kaydetme 370 | 371 | Artık [Validator Kayıt Defteri] () güncellemenizi mümkün olan en kısa sürede merge edilmek üzere pull request isteğinde bulunabilirsiniz. REStake, değişikliklerin birleştirilmesinden sonraki 15 dakika içinde otomatik olarak güncellenir. 372 | 373 | ## Katkıda Bulunma 374 | 375 | ### Bir Ağ Ekleme/Güncelleme 376 | 377 | Ağ bilgileri [Zincir Kayıt Defteri] () [registry.cosmos.directory] () API üzerinden alınır. Yeterli temel bilgilerin sağlandığı varsayılarak, REStake'e ana daldaki zincirler otomatik olarak eklenir. 378 | 379 | 'networks.json' dosyası, REStake'de 'desteklendiği' gibi hangi zincirlerin göründüğünü tanımlar; zincir adı Zincir Kayıt Defterinden dizin adıyla eşleştiği sürece, tüm zincir bilgileri otomatik olarak sağlanacaktır. Alternatif olarak zincirler, tek başına `networks.json`'da *desteklenebilir*, ancak bu belgelenmiş bir özellik değildir. 380 | 381 | Bir zinciri yeniden eklemek veya geçersiz kılmak için gerekli bilgileri aşağıdaki gibi `networks.json`'a ekleyin: 382 | 383 | ```json 384 | { 385 | "name": "osmosis", 386 | "prettyName": "Osmosis", 387 | "gasPrice": "0.025uosmo", 388 | "authzSupport": true 389 | } 390 | ``` 391 | 392 | `networks.json`'daki CamelCase sürümünü tanımlayarak zincir kayıt defterinin çoğunun geçersiz kılınabileceğini unutmayın. 393 | 394 | ### Kullanıcı Arayüzünü (UI) Çalıştırma 395 | 396 | Docker'ı kullanarak kullanıcı arayüzünü bir satırla çalıştırın: 397 | 398 | ```bash 399 | docker run -p 80:80 -t ghcr.io/eco-stake/restake 400 | ``` 401 | 402 | `docker-compose up` ya da `npm start` kullanarak kaynaktan alternatif olarak da çalıştırılabilir. 403 | 404 | ## Etik 405 | 406 | REStake kullanıcı arayüzü hem validator hem de ağ için agnostiktir. Herhangi bir delegator bir operatör olarak eklenebilir ve delegatorlerine otomatik birleştirme hizmeti sağlamak için bu aracı çalıştırabilir ancak markayı kendilerine uyacak şekilde seçip ayarlarlarsa kendi kullanıcı arayüzlerini de çalıştırabilirler. 407 | 408 | Bunun çalışması için ortak bir zincir bilgisi kaynağına ve ortak bir 'operator' bilgisi kaynağına ihtiyacımız var. Zincir bilgileri, [Cosmos.Directory](https://github.com/eco-stake/cosmos-directory) tarafından sağlanan bir API aracılığıyla [Zincir Kayıt Defteri](https://github.com/cosmos/chain-registry)'nden temin edilir. Operatör bilgileri [Validator Kayıt Defteri](https://github.com/eco-stake/validator-registry)'nde bulunur. 409 | 410 | Artık ortak bir operatör bilgisi kaynağımız var, uygulamalar verileri doğrudan GitHub'dan ya da [cosmos.directory](https://github.com/eco-stake/cosmos-directory) projesi aracılığıyla yeniden kullanabilir. 411 | 412 | ## Feragatname 413 | 414 | REStake ilk sürümü yeni authz özelliklerinden yararlanmak için hızlı bir şekilde oluşturuldu. Ben şahsen bir React veya JavaScript geliştiricisi değilim ve bu proje [CosmJS projesi](https://github.com/cosmos/cosmjs) ve [Keplr Wallet](https://github.com/chainapsis/keplr-wallet) ve [Osmosis Zone frontend](https://github.com/osmosis-labs/osmosis-frontend) gibi diğer fantastik kod tabanlarına son derece eğiliyor. 415 | 416 | ## ECO Stake 🌱 417 | 418 | ECO Stake iklim pozitif bir validatordur, ancak Cosmos ekosistemini de önemsiyoruz. Tüm validatorlerin Authz ile bir otomatik stake çalıştırmasını kolaylaştırmak için REStake'i inşa ettik ve ekosistemde üzerinde çalıştığımız birçok projeden biridir. 419 | 420 | Bunun gibi daha fazla projeyi desteklemek için bizimle delege edin [bizimle delege edin](https://ecostake.com). 421 | --------------------------------------------------------------------------------