diff --git a/.changelog/732.feature.md b/.changelog/732.feature.md new file mode 100644 index 000000000..85cf29e26 --- /dev/null +++ b/.changelog/732.feature.md @@ -0,0 +1 @@ +api: add validator staking history diff --git a/api/spec/v1.yaml b/api/spec/v1.yaml index 2936c51d0..1a25718e3 100644 --- a/api/spec/v1.yaml +++ b/api/spec/v1.yaml @@ -92,8 +92,8 @@ x-examples: parameters-change: - ¶meters_change_1 '{"min_validators":null,"max_validators":"120","voting_power_distribution":null}' epoch: - - &epoch_1 8048956 - - &epoch_2 8048966 + - &epoch_1 13402 + - &epoch_2 13403 event-type: - &event_type_1 'staking.escrow.take' roothash-message-type: @@ -508,7 +508,7 @@ paths: required: true schema: allOf: [$ref: '#/components/schemas/StakingAddress'] - description: The entity ID of the entity to return. + description: The address of the entity to return. responses: '200': description: | @@ -520,6 +520,43 @@ paths: $ref: '#/components/schemas/Validator' <<: *common_error_responses + /consensus/validators/{address}/history: + get: + summary: Returns historical information for a single validator. + parameters: + - *limit + - *offset + - in: query + name: from + schema: + type: integer + format: int64 + description: A filter on minimum epoch number, inclusive. + example: *epoch_1 + - in: query + name: to + schema: + type: integer + format: int64 + description: A filter on maximum epoch number, inclusive. + example: *epoch_2 + - in: path + name: address + required: true + schema: + allOf: [$ref: '#/components/schemas/StakingAddress'] + description: The address of the entity to return. + responses: + '200': + description: | + A JSON object containing historical information for a + validator, grouped by epoch in reverse chronological order. + content: + application/json: + schema: + $ref: '#/components/schemas/ValidatorHistory' + <<: *common_error_responses + /consensus/accounts: get: summary: | @@ -1866,17 +1903,17 @@ components: properties: entity_address: type: string - description: The staking address identifying this Validator. + description: The staking address identifying this validator. example: *staking_address_1 entity_id: x-go-name: EntityID type: string - description: The public key identifying this Validator. + description: The public key identifying this validator. example: *entity_id_1 node_id: x-go-name: NodeID type: string - description: The public key identifying this Validator's node. + description: The public key identifying this validator's node. example: *node_id_1 escrow: allOf: [$ref: '#/components/schemas/Escrow'] @@ -1884,11 +1921,11 @@ components: voting_power: type: integer format: int64 - description: The voting power of this Validator. + description: The voting power of this validator. voting_power_total: type: integer format: int64 - description: The total voting power across all Validators. + description: The total voting power across all validators. active: type: boolean description: Whether the entity has a node that is registered for being a validator, node is up to date, and has successfully registered itself. It may or may not be part of validator set. @@ -1900,7 +1937,7 @@ components: rank: type: integer format: uint64 - description: The rank of the Validator, determined by voting power. + description: The rank of the validator, determined by voting power. in_validator_set: type: boolean description: Whether the entity is part of the validator set (top by stake among active entities). @@ -1937,6 +1974,61 @@ components: self_delegation_shares: allOf: [$ref: '#/components/schemas/TextBigInt'] description: The shares of tokens this validator has delegated to itself, and are NOT in the process of debonding. + active_balance_24: + allOf: [$ref: '#/components/schemas/TextBigInt'] + description: The active_balance of this validator account 24 hours ago. + num_delegators: + type: integer + format: uint64 + description: The number of accounts that have delegated token to this account. + + ValidatorHistory: + allOf: + - $ref: '#/components/schemas/List' + - type: object + required: [history] + properties: + address: + type: string + description: The staking address of the validator. + example: *staking_address_1 + history: + type: array + items: + allOf: [$ref: '#/components/schemas/ValidatorHistoryPoint'] + description: Historical escrow balance data for a single address. + + ValidatorHistoryPoint: + type: object + required: [epoch] + properties: + epoch: + type: integer + format: int64 + description: The epoch number. + example: *epoch_1 + active_balance: + allOf: [$ref: '#/components/schemas/TextBigInt'] + description: | + The amount of tokens that were delegated to this validator account, + at the start of this epoch, and are NOT in the process of debonding. + active_shares: + allOf: [$ref: '#/components/schemas/TextBigInt'] + description: | + The shares of tokens that were delegated to this validator account, + at the start of this epoch, and are NOT in the process of debonding. + debonding_balance: + allOf: [$ref: '#/components/schemas/TextBigInt'] + description: | + The amount of tokens that were delegated to this validator account + at the start of this epoch, but are also in the process of debonding + (i.e. they will be unstaked within ~2 weeks). + debonding_shares: + allOf: [$ref: '#/components/schemas/TextBigInt'] + description: | + The shares of tokens that were delegated to this validator account + at the start of this epoch, but are also in the process of debonding + (i.e. they will be unstaked within ~2 weeks). num_delegators: type: integer format: uint64 diff --git a/api/v1/strict_server.go b/api/v1/strict_server.go index bedf13c83..102e1e531 100644 --- a/api/v1/strict_server.go +++ b/api/v1/strict_server.go @@ -263,6 +263,14 @@ func (srv *StrictServerImpl) GetConsensusValidatorsAddress(ctx context.Context, return apiTypes.GetConsensusValidatorsAddress200JSONResponse(validators.Validators[0]), nil } +func (srv *StrictServerImpl) GetConsensusValidatorsAddressHistory(ctx context.Context, request apiTypes.GetConsensusValidatorsAddressHistoryRequestObject) (apiTypes.GetConsensusValidatorsAddressHistoryResponseObject, error) { + history, err := srv.dbClient.ValidatorHistory(ctx, request.Address, request.Params) + if err != nil { + return nil, err + } + return apiTypes.GetConsensusValidatorsAddressHistory200JSONResponse(*history), nil +} + func (srv *StrictServerImpl) GetRuntimeBlocks(ctx context.Context, request apiTypes.GetRuntimeBlocksRequestObject) (apiTypes.GetRuntimeBlocksResponseObject, error) { blocks, err := srv.dbClient.RuntimeBlocks(ctx, request.Params) if err != nil { diff --git a/storage/client/client.go b/storage/client/client.go index b05cd022c..3da530897 100644 --- a/storage/client/client.go +++ b/storage/client/client.go @@ -1269,6 +1269,7 @@ func (c *StorageClient) Validators(ctx context.Context, p apiTypes.GetConsensusV &v.Escrow.DebondingShares, &v.Escrow.SelfDelegationBalance, &v.Escrow.SelfDelegationShares, + &v.Escrow.ActiveBalance24, &v.Escrow.NumDelegators, &v.VotingPower, &v.VotingPowerTotal, @@ -1312,6 +1313,44 @@ func (c *StorageClient) Validators(ctx context.Context, p apiTypes.GetConsensusV return &vs, nil } +func (c *StorageClient) ValidatorHistory(ctx context.Context, address staking.Address, p apiTypes.GetConsensusValidatorsAddressHistoryParams) (*ValidatorHistory, error) { + res, err := c.withTotalCount( + ctx, + queries.ValidatorHistory, + address.String(), + p.From, + p.To, + p.Limit, + p.Offset, + ) + if err != nil { + return nil, wrapError(err) + } + defer res.rows.Close() + + h := ValidatorHistory{ + History: []ValidatorHistoryPoint{}, + TotalCount: res.totalCount, + IsTotalCountClipped: res.isTotalCountClipped, + } + for res.rows.Next() { + b := ValidatorHistoryPoint{} + if err = res.rows.Scan( + &b.Epoch, + &b.ActiveBalance, + &b.ActiveShares, + &b.DebondingBalance, + &b.DebondingShares, + &b.NumDelegators, + ); err != nil { + return nil, wrapError(err) + } + h.History = append(h.History, b) + } + + return &h, nil +} + // RuntimeBlocks returns a list of runtime blocks. func (c *StorageClient) RuntimeBlocks(ctx context.Context, p apiTypes.GetRuntimeBlocksParams) (*RuntimeBlockList, error) { hash, err := canonicalizedHash(p.Hash) diff --git a/storage/client/queries/queries.go b/storage/client/queries/queries.go index 146b8c30a..82bf37eec 100644 --- a/storage/client/queries/queries.go +++ b/storage/client/queries/queries.go @@ -335,6 +335,7 @@ const ( COALESCE ( self_delegations.shares , 0) AS self_delegation_shares, + history.validators.escrow_balance_active AS active_balance_24, COALESCE ( delegators_count.count , 0) AS num_delegators, @@ -357,6 +358,11 @@ const ( JOIN chain.blocks ON chain.entities.start_block = chain.blocks.height LEFT JOIN chain.commissions ON chain.entities.address = chain.commissions.address LEFT JOIN self_delegations ON chain.entities.address = self_delegations.address + LEFT JOIN history.validators ON chain.entities.id = history.validators.id + -- Find the epoch id from 24 hours ago. Each epoch is ~1hr. + AND history.validators.epoch = (SELECT id - 24 from chain.epochs + ORDER BY id DESC + LIMIT 1) LEFT JOIN delegators_count ON chain.entities.address = delegators_count.address LEFT JOIN validator_nodes ON validator_nodes.address = entities.address JOIN validator_rank ON chain.entities.address = validator_rank.address @@ -369,6 +375,23 @@ const ( LIMIT $3::bigint OFFSET $4::bigint` + ValidatorHistory = ` + SELECT + epoch, + escrow_balance_active, + escrow_total_shares_active, + escrow_balance_debonding, + escrow_total_shares_debonding, + num_delegators + FROM chain.entities + JOIN history.validators ON chain.entities.id = history.validators.id + WHERE (chain.entities.address = $1::text) AND + ($2::bigint IS NULL OR history.validators.epoch >= $2::bigint) AND + ($3::bigint IS NULL OR history.validators.epoch <= $3::bigint) + ORDER BY epoch DESC + LIMIT $4::bigint + OFFSET $5::bigint` + RuntimeBlocks = ` SELECT round, block_hash, timestamp, num_transactions, size, gas_used FROM chain.runtime_blocks diff --git a/storage/client/types.go b/storage/client/types.go index fca7a1204..1f99e84ce 100644 --- a/storage/client/types.go +++ b/storage/client/types.go @@ -96,6 +96,12 @@ type ValidatorMedia = api.ValidatorMedia // ValidatorCommissionBound is the commission bound for a validator. type ValidatorCommissionBound = api.ValidatorCommissionBound +// ValidatorHistory is the storage response for GetValidatorHistory. +type ValidatorHistory = api.ValidatorHistory + +// ValidatorHistoryPoint is the escrow information for a validator at a given epoch. +type ValidatorHistoryPoint = api.ValidatorHistoryPoint + // RuntimeBlockList is the storage response for RuntimeListBlocks. type RuntimeBlockList = api.RuntimeBlockList diff --git a/tests/e2e_regression/damask/expected/validator_history.body b/tests/e2e_regression/damask/expected/validator_history.body new file mode 100644 index 000000000..572b79610 --- /dev/null +++ b/tests/e2e_regression/damask/expected/validator_history.body @@ -0,0 +1,22 @@ +{ + "history": [ + { + "active_balance": "75369956295476647", + "active_shares": "62390383512876854", + "debonding_balance": "683221451594757", + "debonding_shares": "683221451594757", + "epoch": 13403, + "num_delegators": 10840 + }, + { + "active_balance": "75368981020862238", + "active_shares": "62390367366454536", + "debonding_balance": "683221451594757", + "debonding_shares": "683221451594757", + "epoch": 13402, + "num_delegators": 10840 + } + ], + "is_total_count_clipped": false, + "total_count": 2 +} diff --git a/tests/e2e_regression/damask/expected/validator_history.headers b/tests/e2e_regression/damask/expected/validator_history.headers new file mode 100644 index 000000000..1cce01c2d --- /dev/null +++ b/tests/e2e_regression/damask/expected/validator_history.headers @@ -0,0 +1,6 @@ +HTTP/1.1 200 OK +Content-Type: application/json +Vary: Origin +Date: UNINTERESTING +Content-Length: UNINTERESTING + diff --git a/tests/e2e_regression/damask/test_cases.sh b/tests/e2e_regression/damask/test_cases.sh index 8c2c70eff..624e08e84 100644 --- a/tests/e2e_regression/damask/test_cases.sh +++ b/tests/e2e_regression/damask/test_cases.sh @@ -18,6 +18,7 @@ testCases=( 'epoch /v1/consensus/epochs/13403' 'tx /v1/consensus/transactions/f7a03e0912d355901ee794e5fec79a6b4c91363fc27d953596ee6de5c1492798' 'validator /v1/consensus/validators/oasis1qr3w66akc8ud9a4zsgyjw2muvcfjgfszn5ycgc0a' + 'validator_history /v1/consensus/validators/oasis1qq0xmq7r0z9sdv02t5j9zs7en3n6574gtg8v9fyt/history' 'emerald_tx /v1/emerald/transactions/a6471a9c6f3307087586da9156f3c9876fbbaf4b23910cd9a2ac524a54d0aefe' 'emerald_failed_tx /v1/emerald/transactions/a7e76442c52a3cb81f719bde26c9a6179bd3415f96740d91a93ee8f205b45150' 'emerald_token_nfts /v1/emerald/evm_tokens/oasis1qqewaa87rnyshyqs7yutnnpzzetejecgeu005l8u/nfts' diff --git a/tests/e2e_regression/eden/expected/validator_history.body b/tests/e2e_regression/eden/expected/validator_history.body new file mode 100644 index 000000000..8f3289f87 --- /dev/null +++ b/tests/e2e_regression/eden/expected/validator_history.body @@ -0,0 +1,22 @@ +{ + "history": [ + { + "active_balance": "510452102234085399", + "active_shares": "409180341632413967", + "debonding_balance": "3664693487026403", + "debonding_shares": "3664693487026403", + "epoch": 28018, + "num_delegators": 10334 + }, + { + "active_balance": "510450642345248292", + "active_shares": "409180224607170953", + "debonding_balance": "3664847637026402", + "debonding_shares": "3664847637026402", + "epoch": 28017, + "num_delegators": 10334 + } + ], + "is_total_count_clipped": false, + "total_count": 2 +} diff --git a/tests/e2e_regression/eden/expected/validator_history.headers b/tests/e2e_regression/eden/expected/validator_history.headers new file mode 100644 index 000000000..1cce01c2d --- /dev/null +++ b/tests/e2e_regression/eden/expected/validator_history.headers @@ -0,0 +1,6 @@ +HTTP/1.1 200 OK +Content-Type: application/json +Vary: Origin +Date: UNINTERESTING +Content-Length: UNINTERESTING + diff --git a/tests/e2e_regression/eden/test_cases.sh b/tests/e2e_regression/eden/test_cases.sh index 62d8f1246..7b8e6a7db 100644 --- a/tests/e2e_regression/eden/test_cases.sh +++ b/tests/e2e_regression/eden/test_cases.sh @@ -18,6 +18,7 @@ testCases=( 'epoch /v1/consensus/epochs/28017' 'tx /v1/consensus/transactions/142d43e5194b738ab2223f8d0b42326fab06edd714a8cefc59a078b89b5de057' 'validator /v1/consensus/validators/oasis1qqekv2ymgzmd8j2s2u7g0hhc7e77e654kvwqtjwm' + 'validator_history /v1/consensus/validators/oasis1qqekv2ymgzmd8j2s2u7g0hhc7e77e654kvwqtjwm/history' 'emerald_tx /v1/emerald/transactions/ec1173a69272c67f126f18012019d19cd25199e831f9417b6206fb7844406f9d' 'emerald_failed_tx /v1/emerald/transactions/35fdc8261dd81be8187c858aa9a623085494baf0565d414f48562a856147c093' 'emerald_events_by_nft /v1/emerald/events?contract_address=oasis1qz29t7nxkwfqgfk36uqqs9pzuzdt8zmrjud5mehx&nft_id=1'