diff --git a/README.md b/README.md index da0e33722..15d919b95 100644 --- a/README.md +++ b/README.md @@ -82,23 +82,15 @@ a 32-byte hex string (`0x` followed by 64 hexadecimal digits) that denotes the b - [`/accounts/ADDRESS/staking-payouts` fetch staking payouts for `ADDRESS`.](/src/controllers/accounts/AccountsStakingPayoutsController.ts) -- [`/blocks` fetch a block.](/src/controllers/blocks/BlocksController.ts) (Replaces `/block`.) +- [`/accounts/ADDRESS/balance-info` fetch balances info for `ADDRESS`.](src/controllers/accounts/AccountsBalanceInfoController.ts) (Replaces `/balance/ADDRESS`.) -- [`/balance/ADDRESS` fetch balances for `ADDRESS` at latest finalized block.](src/controllers/accounts/AccountsBalanceInfoController.ts) +- [`/accounts/ADDRESS/vesting-info` vesting info for `ADDRESS`.](src/controllers/accounts/AccountsVestingInfoController.ts) (Replaces `/vesting/ADDRESS`.) -- [`/balance/ADDRESS/NUMBER` fetch balances for `ADDRESS` at the block identified by 'NUMBER`.](src/controllers/accounts/AccountsBalanceInfoController.ts) +- [`/accounts/ADDRESS/staking-info` fetch the staking info for `ADDRESS`.](src/controllers/accounts/AccountsStakingInfoController.ts) (Replaces `/staking/ADDRESS`.) -- [`/staking/ADDRESS` fetch the staking info for `ADDRESS` at latest finalized block.](src/controllers/accounts/AccountsStakingInfoController.ts) +- [`/blocks/{head, BlockId}` fetch the finalized head or block identified by BlockId.](/src/controllers/blocks/BlocksController.ts) (Replaces `/block`.) -- [`/staking/ADDRESS/NUMBER` fetch the staking info for `ADDRESS` at the block identified by 'NUMBER`.](src/controllers/accounts/AccountsStakingInfoController.ts) - -- [`/staking-info` fetch information on general staking progress at the latest finalized block.](src/controllers/pallets/PalletsStakingProgressController.ts) - -- [`/staking-info/NUMBER` fetch information on general staking progress at the block identified by 'NUMBER`.](src/controllers/pallets/PalletsStakingProgressController.ts) - -- [`/vesting/ADDRESS` fetch the vesting info for `ADDRESS` at latest finalized block.](src/controllers/accounts/AccountsVestingInfoController.ts) - -- [`/vesting/ADDRESS/NUMBER` fetch the vesting info for `ADDRESS` at the block identified by 'NUMBER`.](src/controllers/accounts/AccountsVestingInfoController.ts) +- [`/pallets/staking/progress` fetch information on general staking progress.](src/controllers/pallets/PalletsStakingProgressController.ts) (Replaces `/staking-info`.) - [`/node/network` fetch information about the Substrate node's activity in the peer-to-peer network.](src/controllers/node/NodeNetworkController.ts) diff --git a/openapi/openapi-proposal.yaml b/openapi/openapi-proposal.yaml index 0534eb0ed..9283cdb21 100755 --- a/openapi/openapi-proposal.yaml +++ b/openapi/openapi-proposal.yaml @@ -26,7 +26,7 @@ paths: summary: Get balance information for an account. description: Returns information about an account's balance. Replaces `/balance/{address}` from versions < v1.0.0. - operationId: getBalanceSummaryByAccountId + operationId: getAccountBalanceInfo parameters: - name: accountId in: path @@ -433,7 +433,7 @@ paths: get: tags: - staking - summary: Get a progress report on the chain's staking system. + summary: Get progress on the general Staking pallet system. description: Returns information on the progress of key components of the staking system and estimates of future points of interest. Replaces `/staking-info` from versions < v1.0.0. diff --git a/src/controllers/accounts/AccountsBalanceInfoController.ts b/src/controllers/accounts/AccountsBalanceInfoController.ts new file mode 100644 index 000000000..26516be6c --- /dev/null +++ b/src/controllers/accounts/AccountsBalanceInfoController.ts @@ -0,0 +1,77 @@ +import { ApiPromise } from '@polkadot/api'; +import { RequestHandler } from 'express'; +import { IAddressParam } from 'src/types/requests'; + +import { validateAddress } from '../../middleware'; +import { AccountsBalanceInfoService } from '../../services'; +import AbstractController from '../AbstractController'; + +/** + * GET balance information for an address. + * + * Paths: + * - `address`: The address to query. + * + * Query: + * - (Optional)`at`: Block at which to retrieve runtime version information at. Block + * identifier, as the block height or block hash. Defaults to most recent block. + * + * Returns: + * - `at`: Block number and hash at which the call was made. + * - `nonce`: Account nonce. + * - `free`: Free balance of the account. Not equivalent to _spendable_ balance. This is the only + * balance that matters in terms of most operations on tokens. + * - `reserved`: Reserved balance of the account. + * - `miscFrozen`: The amount that `free` may not drop below when withdrawing for anything except + * transaction fee payment. + * - `feeFrozen`: The amount that `free` may not drop below when withdrawing specifically for + * transaction fee payment. + * - `locks`: Array of locks on a balance. There can be many of these on an account and they + * "overlap", so the same balance is frozen by multiple locks. Contains: + * - `id`: An identifier for this lock. Only one lock may be in existence for each identifier. + * - `amount`: The amount below which the free balance may not drop with this lock in effect. + * - `reasons`: If true, then the lock remains in effect even for payment of transaction fees. + * + * Substrate Reference: + * - FRAME System: https://crates.parity.io/frame_system/index.html + * - Balances Pallet: https://crates.parity.io/pallet_balances/index.html + * - `AccountInfo`: https://crates.parity.io/frame_system/struct.AccountInfo.html + * - `AccountData`: https://crates.parity.io/pallet_balances/struct.AccountData.html + * - `BalanceLock`: https://crates.parity.io/pallet_balances/struct.BalanceLock.html + */ +export default class AccountsBalanceController extends AbstractController< + AccountsBalanceInfoService +> { + constructor(api: ApiPromise) { + super( + api, + '/accounts/:address/balance-info', + new AccountsBalanceInfoService(api) + ); + this.initRoutes(); + } + + protected initRoutes(): void { + this.router.use(this.path, validateAddress); + + this.safeMountAsyncGetHandlers([['', this.getAccountBalanceInfo]]); + } + + /** + * Get the latest account balance summary of `address`. + * + * @param req Express Request + * @param res Express Response + */ + private getAccountBalanceInfo: RequestHandler = async ( + { params: { address }, query: { at } }, + res + ): Promise => { + const hash = await this.getHashFromAt(at); + + AccountsBalanceController.sanitizedSend( + res, + await this.service.fetchAccountBalanceInfo(hash, address) + ); + }; +} diff --git a/src/controllers/accounts/AccountsStakingInfoController.ts b/src/controllers/accounts/AccountsStakingInfoController.ts new file mode 100644 index 000000000..621656331 --- /dev/null +++ b/src/controllers/accounts/AccountsStakingInfoController.ts @@ -0,0 +1,85 @@ +import { ApiPromise } from '@polkadot/api'; +import { RequestHandler } from 'express'; +import { IAddressParam } from 'src/types/requests'; + +import { validateAddress } from '../../middleware'; +import { AccountsStakingInfoService } from '../../services'; +import AbstractController from '../AbstractController'; + +/** + * GET staking information for an address. + * + * Paths: + * - `address`: The _Stash_ address for staking. + * + * Query: + * - (Optional)`at`: Block at which to retrieve runtime version information at. Block + * identifier, as the block height or block hash. Defaults to most recent block. + * + * Returns: + * - `at`: Block number and hash at which the call was made. + * - `rewardDestination`: The account to which rewards will be paid. Can be 'Staked' (Stash + * account, adding to the amount at stake), 'Stash' (Stash address, not adding to the amount at + * stake), or 'Controller' (Controller address). + * - `controller`: Controller address for the given Stash. + * - `numSlashingSpans`: Number of slashing spans on Stash account; `null` if provided address is + * not a Controller. + * - `staking`: The staking ledger. Empty object if provided address is not a Controller. + * - `stash`: The stash account whose balance is actually locked and at stake. + * - `total`: The total amount of the stash's balance that we are currently accounting for. + * Simply `active + unlocking`. + * - `active`: The total amount of the stash's balance that will be at stake in any forthcoming + * eras. + * - `unlocking`: Any balance that is becoming free, which may eventually be transferred out of + * the stash (assuming it doesn't get slashed first). Represented as an array of objects, each + * with an `era` at which `value` will be unlocked. + * - `claimedRewards`: Array of eras for which the stakers behind a validator have claimed + * rewards. Only updated for _validators._ + * + * Note: Runtime versions of Kusama less than 1062 will either have `lastReward` in place of + * `claimedRewards`, or no field at all. This is related to changes in reward distribution. See: + * - Lazy Payouts: https://github.com/paritytech/substrate/pull/4474 + * - Simple Payouts: https://github.com/paritytech/substrate/pull/5406 + * + * Substrate Reference: + * - Staking Pallet: https://crates.parity.io/pallet_staking/index.html + * - `RewardDestination`: https://crates.parity.io/pallet_staking/enum.RewardDestination.html + * - `Bonded`: https://crates.parity.io/pallet_staking/struct.Bonded.html + * - `StakingLedger`: https://crates.parity.io/pallet_staking/struct.StakingLedger.html + */ +export default class AccountsStakingInfoController extends AbstractController< + AccountsStakingInfoService +> { + constructor(api: ApiPromise) { + super( + api, + '/accounts/:address/staking-info', + new AccountsStakingInfoService(api) + ); + this.initRoutes(); + } + + protected initRoutes(): void { + this.router.use(this.path, validateAddress); + + this.safeMountAsyncGetHandlers([['', this.getAccountStakingInfo]]); + } + + /** + * Get the latest account staking summary of `address`. + * + * @param req Express Request + * @param res Express Response + */ + private getAccountStakingInfo: RequestHandler = async ( + { params: { address }, query: { at } }, + res + ): Promise => { + const hash = await this.getHashFromAt(at); + + AccountsStakingInfoController.sanitizedSend( + res, + await this.service.fetchAccountStakingInfo(hash, address) + ); + }; +} diff --git a/src/controllers/accounts/AccountsVestingInfoController.ts b/src/controllers/accounts/AccountsVestingInfoController.ts new file mode 100644 index 000000000..754c4ebe7 --- /dev/null +++ b/src/controllers/accounts/AccountsVestingInfoController.ts @@ -0,0 +1,65 @@ +import { ApiPromise } from '@polkadot/api'; +import { RequestHandler } from 'express'; +import { IAddressParam } from 'src/types/requests'; + +import { validateAddress } from '../../middleware'; +import { AccountsVestingInfoService } from '../../services'; +import AbstractController from '../AbstractController'; + +/** + * GET vesting information for an address. + * + * Paths: + * - `address`: Address to query. + * + * Query params: + * - (Optional)`at`: Block at which to retrieve runtime version information at. Block + * identifier, as the block height or block hash. Defaults to most recent block. + * + * Returns: + * - `at`: Block number and hash at which the call was made. + * - `vesting`: Vesting schedule for an account. + * - `locked`: Number of tokens locked at start. + * - `perBlock`: Number of tokens that gets unlocked every block after `startingBlock`. + * - `startingBlock`: Starting block for unlocking(vesting). + * + * Substrate Reference: + * - Vesting Pallet: https://crates.parity.io/pallet_vesting/index.html + * - `VestingInfo`: https://crates.parity.io/pallet_vesting/struct.VestingInfo.html + */ +export default class AccountsVestingInfoController extends AbstractController< + AccountsVestingInfoService +> { + constructor(api: ApiPromise) { + super( + api, + '/accounts/:address/vesting-info', + new AccountsVestingInfoService(api) + ); + this.initRoutes(); + } + + protected initRoutes(): void { + this.router.use(this.path, validateAddress); + + this.safeMountAsyncGetHandlers([['', this.getAccountVestingInfo]]); + } + + /** + * Get vesting information for an account. + * + * @param req Express Request + * @param res Express Response + */ + private getAccountVestingInfo: RequestHandler = async ( + { params: { address }, query: { at } }, + res + ): Promise => { + const hash = await this.getHashFromAt(at); + + AccountsVestingInfoController.sanitizedSend( + res, + await this.service.fetchAccountVestingInfo(hash, address) + ); + }; +} diff --git a/src/controllers/accounts/index.ts b/src/controllers/accounts/index.ts index 03d5cfb3e..c18d34411 100644 --- a/src/controllers/accounts/index.ts +++ b/src/controllers/accounts/index.ts @@ -1 +1,4 @@ export { default as AccountsStakingPayouts } from './AccountsStakingPayoutsController'; +export { default as AccountsBalanceInfo } from './AccountsBalanceInfoController'; +export { default as AccountsStakingInfo } from './AccountsStakingInfoController'; +export { default as AccountsVestingInfo } from './AccountsVestingInfoController'; diff --git a/src/controllers/index.ts b/src/controllers/index.ts index 3360c931e..ce46a3793 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -2,7 +2,7 @@ export * from './transaction'; export * from './accounts'; export * from './blocks'; export * from './runtime'; -// export * from './pallets'; +export * from './pallets'; export * from './node'; export * as v0 from './v0'; diff --git a/src/controllers/pallets/PalletsStakingProgressController.ts b/src/controllers/pallets/PalletsStakingProgressController.ts new file mode 100644 index 000000000..f12844fbe --- /dev/null +++ b/src/controllers/pallets/PalletsStakingProgressController.ts @@ -0,0 +1,104 @@ +import { ApiPromise } from '@polkadot/api'; +import { RequestHandler } from 'express'; + +import { PalletsStakingProgressService } from '../../services'; +import AbstractController from '../AbstractController'; + +/** + * GET progress on the general Staking pallet system. + * + * Paths: + * - (Optional) `number`: Block hash or height at which to query. If not provided, queries + * finalized head. + * + * Query params: + * - (Optional)`at`: Block at which to retrieve runtime version information at. Block + * identifier, as the block height or block hash. Defaults to most recent block. + * + * Returns: + * - `at`: Block number and hash at which the call was made. + * - `activeEra`: `EraIndex` of the era being rewarded. + * - `forceEra`: Current status of era forcing. + * - `nextActiveEraEstimate`: **Upper bound estimate** of the block height at which the next + * active era will start. Not included in response when `forceEra.isForceNone`. + * - `nextSessionEstimate`: **Upper bound estimate** of the block height at which the next + * session will start. + * - `unappliedSlashes`: Array of upcoming `UnappliedSlash` indexed by era. Each `UnappliedSlash` + * contains: + * - `validator`: Stash account ID of the offending validator. + * - `own`: The amount the validator will be slashed. + * - `others`: Array of tuples of (accountId, amount) representing all the stashes of other + * slashed stakers and the amount they will be slashed. + * - `reporters`: Array of account IDs of the reporters of the offense. + * - `payout`: Amount of bounty payout to reporters. + * - `electionStatus`: Information about the off-chain election. Not included in response when + * `forceEra.isForceNone`. Response includes: + * - `status`: Era election status; either `Close: null` or `Open: `. A status of + * `Close` indicates that the submission window for solutions from off-chain Phragmen is not + * open. A status of `Open` indicates the submission window for off-chain Phragmen solutions + * has been open since BlockNumber. N.B. when the submission window is open, certain + * extrinsics are not allowed because they would mutate the state that the off-chain Phragmen + * calculation relies on for calculating results. + * - `toggleEstimate`: **Upper bound estimate** of the block height at which the `status` will + * switch. + * - `idealValidatorCount`: Upper bound of validator set size; considered the ideal size. Not + * included in response when `forceEra.isForceNone`. + * - `validatorSet`: Stash account IDs of the validators for the current session. Not included in + * response when `forceEra.isForceNone`. + * + * Note about 'active' vs. 'current' era: The _active_ era is the era currently being rewarded. + * That is, an elected validator set will be in place for an entire active era, as long as none + * are kicked out due to slashing. Elections take place at the end of each _current_ era, which + * is the latest planned era, and may not equal the active era. Normally, the current era index + * increments one session before the active era, in order to perform the election and queue the + * validator set for the next active era. For example: + * + * ``` + * Time: ---------> + * CurrentEra: 1 | 2 | + * ActiveEra: | 1 | 2 | + * SessionIdx: | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | + * Elections: ^ ^ + * Set Changes: ^ ^ + * ``` + * + * Substrate Reference: + * - Staking Pallet: https://crates.parity.io/pallet_staking/index.html + * - Session Pallet: https://crates.parity.io/pallet_session/index.html + * - `Forcing`: https://crates.parity.io/pallet_staking/enum.Forcing.html + * - `ElectionStatus`: https://crates.parity.io/pallet_staking/enum.ElectionStatus.html + */ +export default class PalletsStakingProgressController extends AbstractController< + PalletsStakingProgressService +> { + constructor(api: ApiPromise) { + super( + api, + '/pallets/staking/progress', + new PalletsStakingProgressService(api) + ); + this.initRoutes(); + } + + protected initRoutes(): void { + this.safeMountAsyncGetHandlers([['', this.getPalletStakingProgress]]); + } + + /** + * Get the progress of the staking pallet system. + * + * @param _req Express Request + * @param res Express Response + */ + private getPalletStakingProgress: RequestHandler = async ( + { query: { at } }, + res + ): Promise => { + const hash = await this.getHashFromAt(at); + + PalletsStakingProgressController.sanitizedSend( + res, + await this.service.derivePalletStakingProgress(hash) + ); + }; +} diff --git a/src/controllers/pallets/index.ts b/src/controllers/pallets/index.ts new file mode 100644 index 000000000..bac9fbe88 --- /dev/null +++ b/src/controllers/pallets/index.ts @@ -0,0 +1 @@ +export { default as palletsStakingProgress } from './PalletsStakingProgressController'; diff --git a/src/main.ts b/src/main.ts index 9e7a85ee3..b5e20c996 100644 --- a/src/main.ts +++ b/src/main.ts @@ -93,6 +93,15 @@ async function main() { const accountsStakingPayoutsController = new controllers.AccountsStakingPayouts( api ); + const accountsBalanceInfoController = new controllers.AccountsBalanceInfo( + api + ); + const accountsStakingInfoController = new controllers.AccountsStakingInfo( + api + ); + const accountsVestingInfoController = new controllers.AccountsVestingInfo( + api + ); const nodeNetworkController = new controllers.NodeNetwork(api); const nodeVersionController = new controllers.NodeVersion(api); const nodeTransactionPoolController = new controllers.NodeTransactionPool( @@ -109,6 +118,9 @@ async function main() { api ); const transactionSubmitController = new controllers.TransactionSubmit(api); + const palletsStakingProgressController = new controllers.palletsStakingProgress( + api + ); // Create our App const app = new App({ @@ -116,6 +128,9 @@ async function main() { controllers: [ blocksController, accountsStakingPayoutsController, + accountsBalanceInfoController, + accountsStakingInfoController, + accountsVestingInfoController, nodeNetworkController, nodeVersionController, nodeTransactionPoolController, @@ -126,6 +141,7 @@ async function main() { transactionMaterialController, transactionFeeEstimateController, transactionSubmitController, + palletsStakingProgressController, ...v0Controllers, ], postMiddleware: [ diff --git a/src/services/accounts/AccountsBalanceInfoService.spec.ts b/src/services/accounts/AccountsBalanceInfoService.spec.ts new file mode 100644 index 000000000..97e3f17ed --- /dev/null +++ b/src/services/accounts/AccountsBalanceInfoService.spec.ts @@ -0,0 +1,21 @@ +import { sanitizeNumbers } from '../../sanitize/sanitizeNumbers'; +import { blockHash789629, mockApi, testAddress } from '../test-helpers/mock'; +import * as accountsBalanceInfo789629 from '../test-helpers/responses/accounts/balanceInfo789629.json'; +import { AccountsBalanceInfoService } from './AccountsBalanceInfoService'; + +const accountsBalanceInfoService = new AccountsBalanceInfoService(mockApi); + +describe('AccountsBalanceInfoService', () => { + describe('fetchAccountBalanceInfo', () => { + it('works when ApiPromise works (block 789629)', async () => { + expect( + sanitizeNumbers( + await accountsBalanceInfoService.fetchAccountBalanceInfo( + blockHash789629, + testAddress + ) + ) + ).toStrictEqual(accountsBalanceInfo789629); + }); + }); +}); diff --git a/src/services/accounts/AccountsBalanceInfoService.ts b/src/services/accounts/AccountsBalanceInfoService.ts new file mode 100644 index 000000000..0ebe19601 --- /dev/null +++ b/src/services/accounts/AccountsBalanceInfoService.ts @@ -0,0 +1,55 @@ +import { BlockHash } from '@polkadot/types/interfaces'; +import { IAccountBalanceInfo } from 'src/types/responses'; + +import { AbstractService } from '../AbstractService'; + +export class AccountsBalanceInfoService extends AbstractService { + /** + * Fetch balance information for an account at a given block. + * + * @param hash `BlockHash` to make call at + * @param address address of the account to get the balance info of + */ + async fetchAccountBalanceInfo( + hash: BlockHash, + address: string + ): Promise { + const api = await this.ensureMeta(hash); + + const [header, locks, sysAccount] = await Promise.all([ + api.rpc.chain.getHeader(hash), + api.query.balances.locks.at(hash, address), + api.query.system.account.at(hash, address), + ]); + + const account = + sysAccount.data != null + ? sysAccount.data + : await api.query.balances.account.at(hash, address); + + const at = { + hash, + height: header.number.toNumber().toString(10), + }; + + if (account && locks && sysAccount) { + const { free, reserved, miscFrozen, feeFrozen } = account; + const { nonce } = sysAccount; + + return { + at, + nonce, + free, + reserved, + miscFrozen, + feeFrozen, + locks, + }; + } else { + throw { + at, + error: 'Account not found', + }; + } + } +} diff --git a/src/services/accounts/AccountsStakingInfoService.spec.ts b/src/services/accounts/AccountsStakingInfoService.spec.ts new file mode 100644 index 000000000..27840d88d --- /dev/null +++ b/src/services/accounts/AccountsStakingInfoService.spec.ts @@ -0,0 +1,71 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { BadRequest, InternalServerError } from 'http-errors'; + +import { sanitizeNumbers } from '../../sanitize/sanitizeNumbers'; +import { polkadotRegistry } from '../../test-helpers/registries'; +import { + blockHash789629, + bondedAt, + ledgerAt, + mockApi, + testAddress, + testAddressController, +} from '../test-helpers/mock'; +import * as response789629 from '../test-helpers/responses/accounts/stakingInfo789629.json'; +import { AccountsStakingInfoService } from './AccountsStakingInfoService'; + +const accountStakingInfoService = new AccountsStakingInfoService(mockApi); + +describe('AccountsStakingInfoService', () => { + describe('fetchAccountStakingInfo', () => { + it('works with a valid stash address (block 789629)', async () => { + expect( + sanitizeNumbers( + await accountStakingInfoService.fetchAccountStakingInfo( + blockHash789629, + testAddress + ) + ) + ).toStrictEqual(response789629); + }); + + it('throws a 400 when the given address is not a stash', async () => { + (mockApi.query.staking.bonded as any).at = () => + Promise.resolve().then(() => + polkadotRegistry.createType('Option', null) + ); + + await expect( + accountStakingInfoService.fetchAccountStakingInfo( + blockHash789629, + 'NotStash' + ) + ).rejects.toStrictEqual( + new BadRequest('The address NotStash is not a stash address.') + ); + + (mockApi.query.staking.bonded as any).at = bondedAt; + }); + + it('throws a 404 when the staking ledger cannot be found', async () => { + (mockApi.query.staking.ledger as any).at = () => + Promise.resolve().then(() => + polkadotRegistry.createType('Option', null) + ); + + await expect( + accountStakingInfoService.fetchAccountStakingInfo( + blockHash789629, + testAddress + ) + ).rejects.toStrictEqual( + new InternalServerError( + `Staking ledger could not be found for controller address "${testAddressController.toString()}"` + ) + ); + + (mockApi.query.staking.ledger as any).at = ledgerAt; + }); + }); +}); diff --git a/src/services/accounts/AccountsStakingInfoService.ts b/src/services/accounts/AccountsStakingInfoService.ts new file mode 100644 index 000000000..8ff325704 --- /dev/null +++ b/src/services/accounts/AccountsStakingInfoService.ts @@ -0,0 +1,69 @@ +import { BlockHash } from '@polkadot/types/interfaces'; +import { BadRequest, InternalServerError } from 'http-errors'; +import { IAccountStakingInfo } from 'src/types/responses'; + +import { AbstractService } from '../AbstractService'; + +export class AccountsStakingInfoService extends AbstractService { + /** + * Fetch staking information for a _Stash_ account at a given block. + * + * @param hash `BlockHash` to make call at + * @param stash address of the _Stash_ account to get the staking info of + */ + async fetchAccountStakingInfo( + hash: BlockHash, + stash: string + ): Promise { + const api = await this.ensureMeta(hash); + + const [header, controllerOption] = await Promise.all([ + api.rpc.chain.getHeader(hash), + api.query.staking.bonded.at(hash, stash), // Option representing the controller + ]); + + const at = { + hash, + height: header.number.unwrap().toString(10), + }; + + if (controllerOption.isNone) { + throw new BadRequest( + `The address ${stash} is not a stash address.` + ); + } + + const controller = controllerOption.unwrap(); + + const [ + stakingLedgerOption, + rewardDestination, + slashingSpansOption, + ] = await Promise.all([ + api.query.staking.ledger.at(hash, controller), + api.query.staking.payee.at(hash, stash), + api.query.staking.slashingSpans.at(hash, stash), + ]); + + const stakingLedger = stakingLedgerOption.unwrapOr(null); + + if (stakingLedger === null) { + // should never throw because by time we get here we know we have a bonded pair + throw new InternalServerError( + `Staking ledger could not be found for controller address "${controller.toString()}"` + ); + } + + const numSlashingSpans = slashingSpansOption.isSome + ? slashingSpansOption.unwrap().prior.length + 1 + : 0; + + return { + at, + controller, + rewardDestination, + numSlashingSpans, + staking: stakingLedger, + }; + } +} diff --git a/src/services/accounts/AccountsVestingInfoService.spec.ts b/src/services/accounts/AccountsVestingInfoService.spec.ts new file mode 100644 index 000000000..c70a50f48 --- /dev/null +++ b/src/services/accounts/AccountsVestingInfoService.spec.ts @@ -0,0 +1,21 @@ +import { sanitizeNumbers } from '../../sanitize/sanitizeNumbers'; +import { blockHash789629, mockApi, testAddress } from '../test-helpers/mock'; +import * as response789629 from '../test-helpers/responses/accounts/vestingInfo789629.json'; +import { AccountsVestingInfoService } from './AccountsVestingInfoService'; + +const accountsVestingInfoService = new AccountsVestingInfoService(mockApi); + +describe('AccountVestingInfoService', () => { + describe('fetchAccountVestingInfo', () => { + it('works when ApiPromise works (block 789629)', async () => { + expect( + sanitizeNumbers( + await accountsVestingInfoService.fetchAccountVestingInfo( + blockHash789629, + testAddress + ) + ) + ).toStrictEqual(response789629); + }); + }); +}); diff --git a/src/services/accounts/AccountsVestingInfoService.ts b/src/services/accounts/AccountsVestingInfoService.ts new file mode 100644 index 000000000..1d183211d --- /dev/null +++ b/src/services/accounts/AccountsVestingInfoService.ts @@ -0,0 +1,34 @@ +import { BlockHash } from '@polkadot/types/interfaces'; +import { IAccountVestingInfo } from 'src/types/responses'; + +import { AbstractService } from '../AbstractService'; + +export class AccountsVestingInfoService extends AbstractService { + /** + * Fetch vesting information for an account at a given block. + * + * @param hash `BlockHash` to make call at + * @param address address of the account to get the vesting info of + */ + async fetchAccountVestingInfo( + hash: BlockHash, + address: string + ): Promise { + const api = await this.ensureMeta(hash); + + const [{ number }, vesting] = await Promise.all([ + api.rpc.chain.getHeader(hash), + api.query.vesting.vesting.at(hash, address), + ]); + + const at = { + hash, + height: number.toNumber().toString(10), + }; + + return { + at, + vesting: vesting.isNone ? {} : vesting.unwrap(), + }; + } +} diff --git a/src/services/accounts/index.ts b/src/services/accounts/index.ts index 884f9e649..746962a72 100644 --- a/src/services/accounts/index.ts +++ b/src/services/accounts/index.ts @@ -1 +1,4 @@ export * from './AccountsStakingPayoutsService'; +export * from './AccountsBalanceInfoService'; +export * from './AccountsStakingInfoService'; +export * from './AccountsVestingInfoService'; diff --git a/src/services/index.ts b/src/services/index.ts index 1385f59e5..d0e77534d 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,8 +1,6 @@ export * from './accounts'; export * from './blocks'; -// export * from './pallets'; +export * from './pallets'; export * from './transaction'; export * from './runtime'; export * from './node'; - -// export * as v0 from './v0'; diff --git a/src/services/pallets/PalletsStakingProgressService.spec.ts b/src/services/pallets/PalletsStakingProgressService.spec.ts new file mode 100644 index 000000000..4ed3c744e --- /dev/null +++ b/src/services/pallets/PalletsStakingProgressService.spec.ts @@ -0,0 +1,72 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { InternalServerError } from 'http-errors'; + +import { sanitizeNumbers } from '../../sanitize/sanitizeNumbers'; +import { polkadotRegistry } from '../../test-helpers/registries'; +import { + activeEraAt, + blockHash789629, + erasStartSessionIndexAt, + mockApi, +} from '../test-helpers/mock'; +import * as palletsStakingProgress789629SResponse from '../test-helpers/responses/pallets/stakingProgress789629.json'; +import { PalletsStakingProgressService } from './PalletsStakingProgressService'; + +/** + * Mock PalletStakingProgressService instance. + */ +const palletStakingProgressService = new PalletsStakingProgressService(mockApi); + +describe('PalletStakingProgressService', () => { + describe('derivePalletStakingProgress', () => { + it('works when ApiPromise works (block 789629)', async () => { + expect( + sanitizeNumbers( + await palletStakingProgressService.derivePalletStakingProgress( + blockHash789629 + ) + ) + ).toStrictEqual(palletsStakingProgress789629SResponse); + }); + + it('throws when ErasStartSessionIndex.isNone', async () => { + (mockApi.query.staking.erasStartSessionIndex as any).at = () => + Promise.resolve().then(() => + polkadotRegistry.createType('Option', null) + ); + + await expect( + palletStakingProgressService.derivePalletStakingProgress( + blockHash789629 + ) + ).rejects.toStrictEqual( + new InternalServerError( + 'EraStartSessionIndex is None when Some was expected.' + ) + ); + + (mockApi.query.staking + .erasStartSessionIndex as any).at = erasStartSessionIndexAt; + }); + + it('throws when activeEra.isNone', async () => { + (mockApi.query.staking.activeEra as any).at = () => + Promise.resolve().then(() => + polkadotRegistry.createType('Option', null) + ); + + await expect( + palletStakingProgressService.derivePalletStakingProgress( + blockHash789629 + ) + ).rejects.toStrictEqual( + new InternalServerError( + 'ActiveEra is None when Some was expected.' + ) + ); + + (mockApi.query.staking.activeEra as any).at = activeEraAt; + }); + }); +}); diff --git a/src/services/pallets/PalletsStakingProgressService.ts b/src/services/pallets/PalletsStakingProgressService.ts new file mode 100644 index 000000000..b041ca918 --- /dev/null +++ b/src/services/pallets/PalletsStakingProgressService.ts @@ -0,0 +1,207 @@ +import { ApiPromise } from '@polkadot/api'; +import { BlockHash, EraIndex } from '@polkadot/types/interfaces'; +import * as BN from 'bn.js'; +import { InternalServerError } from 'http-errors'; +import { IPalletStakingProgress } from 'src/types/responses'; + +import { AbstractService } from '../AbstractService'; + +export class PalletsStakingProgressService extends AbstractService { + /** + * Fetch and derive generalized staking information at a given block. + * + * @param hash `BlockHash` to make call at + */ + async derivePalletStakingProgress( + hash: BlockHash + ): Promise { + const api = await this.ensureMeta(hash); + + const [ + validatorCount, + forceEra, + eraElectionStatus, + validators, + { number }, + ] = await Promise.all([ + api.query.staking.validatorCount.at(hash), + api.query.staking.forceEra.at(hash), + api.query.staking.eraElectionStatus.at(hash), + api.query.session.validators.at(hash), + api.rpc.chain.getHeader(hash), + ]); + + const { + eraLength, + eraProgress, + sessionLength, + sessionProgress, + activeEra, + } = await this.deriveSessionAndEraProgress(api, hash); + + const unappliedSlashesAtActiveEra = await api.query.staking.unappliedSlashes.at( + hash, + activeEra + ); + + const currentBlockNumber = number.toBn(); + + const nextSession = sessionLength + .sub(sessionProgress) + .add(currentBlockNumber); + + const baseResponse = { + at: { + hash: hash.toJSON(), + height: currentBlockNumber.toString(10), + }, + activeEra: activeEra.toString(10), + forceEra: forceEra.toJSON(), + nextSessionEstimate: nextSession.toString(10), + unappliedSlashes: unappliedSlashesAtActiveEra.map((slash) => + slash.toJSON() + ), + }; + + if (forceEra.isForceNone) { + // Most likely we are in a PoA network with no elections. Things + // like `ValidatorCount` and `Validators` are hardcoded from genesis + // to support a transition into NPoS, but are irrelevant here and would be + // confusing to include. Thus, we craft a response excluding those values. + return baseResponse; + } + + const nextActiveEra = forceEra.isForceAlways + ? nextSession // there is a new era every session + : eraLength.sub(eraProgress).add(currentBlockNumber); // the nextActiveEra is at the end of this era + + const electionLookAhead = await this.deriveElectionLookAhead(api, hash); + + const nextCurrentEra = nextActiveEra + .sub(currentBlockNumber) + .sub(sessionLength) + .gt(new BN(0)) + ? nextActiveEra.sub(sessionLength) // current era simply one session before active era + : nextActiveEra.add(eraLength).sub(sessionLength); // we are in the last session of an active era + + let toggle; + if (electionLookAhead.eq(new BN(0))) { + // no offchain solutions accepted + toggle = null; + } else if (eraElectionStatus.isClose) { + // election window is yet to open + toggle = nextCurrentEra.sub(electionLookAhead); + } else { + // election window closes at the end of the current era + toggle = nextCurrentEra; + } + + return { + ...baseResponse, + nextActiveEraEstimate: nextActiveEra.toString(10), + electionStatus: { + status: eraElectionStatus.toJSON(), + toggleEstimate: toggle?.toString(10) ?? null, + }, + idealValidatorCount: validatorCount.toString(10), + validatorSet: validators.map((accountId) => accountId.toString()), + }; + } + + /** + * Derive information on the progress of the current session and era. + * + * @param api ApiPromise with ensured metadata + * @param hash `BlockHash` to make call at + */ + private async deriveSessionAndEraProgress( + api: ApiPromise, + hash: BlockHash + ): Promise<{ + eraLength: BN; + eraProgress: BN; + sessionLength: BN; + sessionProgress: BN; + activeEra: EraIndex; + }> { + const [ + currentSlot, + epochIndex, + genesisSlot, + currentIndex, + activeEraOption, + ] = await Promise.all([ + api.query.babe.currentSlot.at(hash), + api.query.babe.epochIndex.at(hash), + api.query.babe.genesisSlot.at(hash), + api.query.session.currentIndex.at(hash), + api.query.staking.activeEra.at(hash), + ]); + + if (activeEraOption.isNone) { + // TODO refactor to newer error type + throw new InternalServerError( + 'ActiveEra is None when Some was expected.' + ); + } + const { index: activeEra } = activeEraOption.unwrap(); + + const activeEraStartSessionIndexOption = await api.query.staking.erasStartSessionIndex.at( + hash, + activeEra + ); + if (activeEraStartSessionIndexOption.isNone) { + throw new InternalServerError( + 'EraStartSessionIndex is None when Some was expected.' + ); + } + const activeEraStartSessionIndex = activeEraStartSessionIndexOption.unwrap(); + + const { epochDuration: sessionLength } = api.consts.babe; + const eraLength = api.consts.staking.sessionsPerEra.mul(sessionLength); + const epochStartSlot = epochIndex.mul(sessionLength).add(genesisSlot); + const sessionProgress = currentSlot.sub(epochStartSlot); + const eraProgress = currentIndex + .sub(activeEraStartSessionIndex) + .mul(sessionLength) + .add(sessionProgress); + + return { + eraLength, + eraProgress, + sessionLength, + sessionProgress, + activeEra, + }; + } + + /** + * Get electionLookAhead as a const if available. Otherwise derive + * `electionLookAhead` based on the `specName` & `epochDuration`. + * N.B. Values are hardcoded based on `specName`s polkadot, kusama, and westend. + * There are no guarantees that this will return the expected values for + * other `specName`s. + * + * @param api ApiPromise with ensured metadata + * @param hash `BlockHash` to make call at + */ + private async deriveElectionLookAhead( + api: ApiPromise, + hash: BlockHash + ): Promise { + if (api.consts.staking.electionLookahead) { + return api.consts.staking.electionLookahead; + } + + const { specName } = await api.rpc.state.getRuntimeVersion(hash); + const { epochDuration } = api.consts.babe; + + // TODO - create a configurable epochDivisor env for a more generic solution + const epochDurationDivisor = + specName.toString() === 'polkadot' + ? new BN(16) // polkadot electionLookAhead = epochDuration / 16 + : new BN(4); // kusama, westend, `substrate/bin/node` electionLookAhead = epochDuration / 4 + + return epochDuration.div(epochDurationDivisor); + } +} diff --git a/src/services/pallets/index.ts b/src/services/pallets/index.ts new file mode 100644 index 000000000..e7a3c6acb --- /dev/null +++ b/src/services/pallets/index.ts @@ -0,0 +1 @@ +export * from './PalletsStakingProgressService';