From 807648ac3fd057e10e9b4292929e725684608977 Mon Sep 17 00:00:00 2001 From: Gabe Rodriguez Date: Mon, 5 Aug 2024 16:40:13 +0200 Subject: [PATCH] Jailed validators pt 2: block processor + getValidatorInfo endpoint (#1643) * Update block processor + getValidator info endpoint * Add a label to prominently display statuses --- .changeset/slow-trees-drum.md | 9 +++++ .../validator-info-component.tsx | 4 ++ .../validator-state-label.tsx | 36 ++++++++++++++++++ apps/minifront/src/state/staking/index.ts | 6 ++- packages/getters/src/validator-info.ts | 4 ++ packages/query/src/block-processor.ts | 5 ++- packages/query/src/queriers/staking.ts | 6 +++ .../stake-service/get-validator-info.test.ts | 38 ++++++++++++++----- .../src/stake-service/get-validator-info.ts | 23 ++++++++--- packages/services/src/test-utils.ts | 2 + packages/storage/src/indexed-db/index.ts | 4 ++ packages/types/src/indexed-db.ts | 1 + packages/types/src/querier.ts | 31 ++++++++------- 13 files changed, 138 insertions(+), 31 deletions(-) create mode 100644 .changeset/slow-trees-drum.md create mode 100644 apps/minifront/src/components/staking/account/delegation-value-view/validator-state-label.tsx diff --git a/.changeset/slow-trees-drum.md b/.changeset/slow-trees-drum.md new file mode 100644 index 0000000000..82d9996418 --- /dev/null +++ b/.changeset/slow-trees-drum.md @@ -0,0 +1,9 @@ +--- +'@penumbra-zone/services': minor +'@penumbra-zone/storage': minor +'minifront': minor +'@penumbra-zone/query': minor +'@penumbra-zone/types': minor +--- + +Support viewing fresh state of jailed validators diff --git a/apps/minifront/src/components/staking/account/delegation-value-view/validator-info-component.tsx b/apps/minifront/src/components/staking/account/delegation-value-view/validator-info-component.tsx index c5a2106e74..b0036103f9 100644 --- a/apps/minifront/src/components/staking/account/delegation-value-view/validator-info-component.tsx +++ b/apps/minifront/src/components/staking/account/delegation-value-view/validator-info-component.tsx @@ -10,10 +10,12 @@ import { useStore } from '../../../../state'; import { getIdentityKeyFromValidatorInfo, getValidator, + getValidatorState, } from '@penumbra-zone/getters/validator-info'; import { calculateCommissionAsPercentage } from '@penumbra-zone/types/staking'; import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb.js'; import { AssetIcon } from '@repo/ui/components/ui/asset-icon'; +import { ValidatorStateLabel } from './validator-state-label.tsx'; /** * Renders a single `ValidatorInfo`: its name and identity key, @@ -33,6 +35,7 @@ export const ValidatorInfoComponent = ({ const showTooltips = useStore(state => !state.staking.loading); const validator = getValidator(validatorInfo); const identityKey = getIdentityKeyFromValidatorInfo(validatorInfo); + const state = getValidatorState.optional()(validatorInfo); return ( @@ -71,6 +74,7 @@ export const ValidatorInfoComponent = ({ )} {!showTooltips && Com:} {calculateCommissionAsPercentage(validatorInfo)}% + {state && } diff --git a/apps/minifront/src/components/staking/account/delegation-value-view/validator-state-label.tsx b/apps/minifront/src/components/staking/account/delegation-value-view/validator-state-label.tsx new file mode 100644 index 0000000000..f827496a84 --- /dev/null +++ b/apps/minifront/src/components/staking/account/delegation-value-view/validator-state-label.tsx @@ -0,0 +1,36 @@ +import { ValidatorState_ValidatorStateEnum } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/stake/v1/stake_pb.js'; +import { cn } from '@repo/ui/lib/utils'; + +interface LabelInfo { + label: string; + color: string; +} + +const validatorStateMap = new Map([ + [ValidatorState_ValidatorStateEnum.UNSPECIFIED, { label: 'UNSPECIFIED', color: 'bg-gray-500' }], + [ValidatorState_ValidatorStateEnum.DEFINED, { label: 'DEFINED', color: 'bg-blue-500' }], + [ValidatorState_ValidatorStateEnum.INACTIVE, { label: 'INACTIVE', color: 'bg-yellow-600' }], + [ValidatorState_ValidatorStateEnum.ACTIVE, { label: 'ACTIVE', color: 'bg-green-500' }], + [ValidatorState_ValidatorStateEnum.JAILED, { label: 'JAILED', color: 'bg-orange-700' }], + [ValidatorState_ValidatorStateEnum.TOMBSTONED, { label: 'TOMBSTONED', color: 'bg-red-800' }], + [ValidatorState_ValidatorStateEnum.DISABLED, { label: 'DISABLED', color: 'bg-purple-600' }], +]); + +const getStateLabel = (state: ValidatorState_ValidatorStateEnum) => + validatorStateMap.get(state) ?? { + label: 'UNKNOWN', + color: 'bg-yellow-600', + }; + +export const ValidatorStateLabel = ({ state }: { state: ValidatorState_ValidatorStateEnum }) => { + if (state === ValidatorState_ValidatorStateEnum.ACTIVE) { + return <>; + } + + const { label, color } = getStateLabel(state); + return ( +
+ {label} +
+ ); +}; diff --git a/apps/minifront/src/state/staking/index.ts b/apps/minifront/src/state/staking/index.ts index 5066b952eb..5adb266df2 100644 --- a/apps/minifront/src/state/staking/index.ts +++ b/apps/minifront/src/state/staking/index.ts @@ -8,6 +8,7 @@ import { import { AddressIndex } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb.js'; import { planBuildBroadcast } from '../helpers'; import { + DelegationsByAddressIndexRequest_Filter, TransactionPlannerRequest, UnbondingTokensByAddressIndexResponse, } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb.js'; @@ -231,7 +232,10 @@ export const createStakingSlice = (): SliceCreator => (set, get) = }; const throttledFlushToState = throttle(flushToState, THROTTLE_MS, { trailing: true }); - for await (const response of viewClient.delegationsByAddressIndex({ addressIndex })) { + for await (const response of viewClient.delegationsByAddressIndex({ + addressIndex, + filter: DelegationsByAddressIndexRequest_Filter.ALL, + })) { if (newAbortController.signal.aborted) { throttledFlushToState.cancel(); return; diff --git a/packages/getters/src/validator-info.ts b/packages/getters/src/validator-info.ts index 2081ea5095..742f7e7d10 100644 --- a/packages/getters/src/validator-info.ts +++ b/packages/getters/src/validator-info.ts @@ -14,6 +14,10 @@ export const getValidator = createGetter( (validatorInfo?: ValidatorInfo) => validatorInfo?.validator, ); +export const getValidatorState = createGetter( + (validatorInfo?: ValidatorInfo) => validatorInfo?.status?.state?.state, +); + export const getVotingPowerFromValidatorInfo = getStatus.pipe(getVotingPower); export const getStateEnumFromValidatorInfo = getStatus.pipe(getState).pipe(getValidatorStateEnum); diff --git a/packages/query/src/block-processor.ts b/packages/query/src/block-processor.ts index 1150884fe9..cfec76aa23 100644 --- a/packages/query/src/block-processor.ts +++ b/packages/query/src/block-processor.ts @@ -31,7 +31,7 @@ import { getIdentityKeyFromValidatorInfoResponse, } from '@penumbra-zone/getters/validator-info-response'; import { toDecimalExchangeRate } from '@penumbra-zone/types/amount'; -import { PRICE_RELEVANCE_THRESHOLDS, assetPatterns } from '@penumbra-zone/types/assets'; +import { assetPatterns, PRICE_RELEVANCE_THRESHOLDS } from '@penumbra-zone/types/assets'; import type { BlockProcessorInterface } from '@penumbra-zone/types/block-processor'; import { uint8ArrayToHex } from '@penumbra-zone/types/hex'; import type { IndexedDbInterface } from '@penumbra-zone/types/indexed-db'; @@ -657,6 +657,9 @@ export class BlockProcessor implements BlockProcessorInterface { // TODO: refactor. there is definitely a better way to do this. batch // endpoint issue https://github.com/penumbra-zone/penumbra/issues/4688 private async updateValidatorInfos(nextEpochStartHeight: bigint): Promise { + // It's important to clear the table so any stale (jailed, tombstoned, etc) entries are filtered out. + await this.indexedDb.clearValidatorInfos(); + for await (const validatorInfoResponse of this.querier.stake.allValidatorInfos()) { if (!validatorInfoResponse.validatorInfo) { continue; diff --git a/packages/query/src/queriers/staking.ts b/packages/query/src/queriers/staking.ts index 65fc76142b..dd9f7edf33 100644 --- a/packages/query/src/queriers/staking.ts +++ b/packages/query/src/queriers/staking.ts @@ -2,6 +2,8 @@ import { PromiseClient } from '@connectrpc/connect'; import { createClient } from './utils.js'; import { StakeService } from '@penumbra-zone/protobuf'; import { + GetValidatorInfoRequest, + GetValidatorInfoResponse, ValidatorInfoResponse, ValidatorPenaltyRequest, ValidatorPenaltyResponse, @@ -15,6 +17,10 @@ export class StakeQuerier implements StakeQuerierInterface { this.client = createClient(grpcEndpoint, StakeService); } + validatorInfo(req: GetValidatorInfoRequest): Promise { + return this.client.getValidatorInfo(req); + } + allValidatorInfos(): AsyncIterable { /** * Include inactive validators when saving to our local database, since we diff --git a/packages/services/src/stake-service/get-validator-info.test.ts b/packages/services/src/stake-service/get-validator-info.test.ts index 6b75f7b35d..7533357f27 100644 --- a/packages/services/src/stake-service/get-validator-info.test.ts +++ b/packages/services/src/stake-service/get-validator-info.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { IndexedDbMock, MockServices } from '../test-utils.js'; import { createContextValues, createHandlerContext, HandlerContext } from '@connectrpc/connect'; import { StakeService } from '@penumbra-zone/protobuf'; @@ -14,6 +14,7 @@ import { getValidatorInfo } from './get-validator-info.js'; describe('GetValidatorInfo request handler', () => { let mockServices: MockServices; let mockIndexedDb: IndexedDbMock; + let mockStakingQuerierValidatorInfo: Mock; let mockCtx: HandlerContext; let req: GetValidatorInfoRequest; const mockGetValidatorInfoResponse = new GetValidatorInfoResponse({ @@ -29,11 +30,19 @@ describe('GetValidatorInfo request handler', () => { mockIndexedDb = { getValidatorInfo: vi.fn(), }; + + mockStakingQuerierValidatorInfo = vi.fn(); + mockServices = { getWalletServices: vi.fn(() => - Promise.resolve({ indexedDb: mockIndexedDb }), + Promise.resolve({ + indexedDb: mockIndexedDb, + querier: { + stake: { validatorInfo: mockStakingQuerierValidatorInfo }, + }, + }), ) as MockServices['getWalletServices'], - }; + } satisfies MockServices; mockCtx = createHandlerContext({ service: StakeService, method: StakeService.methods.validatorInfo, @@ -50,7 +59,13 @@ describe('GetValidatorInfo request handler', () => { }); }); - it('should successfully get validator info when idb has them', async () => { + it('should fail to get validator info if identity key is not passed', async () => { + await expect(getValidatorInfo(new GetValidatorInfoRequest(), mockCtx)).rejects.toThrow( + 'Missing identityKey in request', + ); + }); + + it('should successfully return validator info when idb has them', async () => { mockIndexedDb.getValidatorInfo?.mockResolvedValueOnce( mockGetValidatorInfoResponse.validatorInfo, ); @@ -59,13 +74,16 @@ describe('GetValidatorInfo request handler', () => { expect(validatorInfoResponse.validatorInfo).toEqual(mockGetValidatorInfoResponse.validatorInfo); }); - it('should fail to get validator info when idb has none', async () => { - await expect(getValidatorInfo(req, mockCtx)).rejects.toThrow('No found validator info'); + it('should successfully return validator info when ibd does not, but remote does have them', async () => { + mockStakingQuerierValidatorInfo = vi.fn().mockResolvedValue(mockGetValidatorInfoResponse); + + const validatorInfoResponse = await getValidatorInfo(req, mockCtx); + expect(validatorInfoResponse.validatorInfo).toEqual(mockGetValidatorInfoResponse.validatorInfo); }); - it('should fail to get validator info if identity key is not passed', async () => { - await expect(getValidatorInfo(new GetValidatorInfoRequest(), mockCtx)).rejects.toThrow( - 'Missing identityKey in request', - ); + it('should fail to get validator info when idb has none', async () => { + mockStakingQuerierValidatorInfo = vi.fn().mockResolvedValue({}); + + await expect(getValidatorInfo(req, mockCtx)).rejects.toThrow('No found validator info'); }); }); diff --git a/packages/services/src/stake-service/get-validator-info.ts b/packages/services/src/stake-service/get-validator-info.ts index e6d548bbba..6f139a42df 100644 --- a/packages/services/src/stake-service/get-validator-info.ts +++ b/packages/services/src/stake-service/get-validator-info.ts @@ -1,18 +1,31 @@ import { Impl } from './index.js'; import { servicesCtx } from '../ctx/prax.js'; import { Code, ConnectError } from '@connectrpc/connect'; +import { + GetValidatorInfoRequest, + GetValidatorInfoResponse, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/stake/v1/stake_pb.js'; export const getValidatorInfo: Impl['getValidatorInfo'] = async (req, ctx) => { if (!req.identityKey) { throw new ConnectError('Missing identityKey in request', Code.InvalidArgument); } const services = await ctx.values.get(servicesCtx)(); - const { indexedDb } = await services.getWalletServices(); + const { indexedDb, querier } = await services.getWalletServices(); - const validatorInfo = await indexedDb.getValidatorInfo(req.identityKey); + // Step 1: Try to find validator info in database + const infoInDb = await indexedDb.getValidatorInfo(req.identityKey); + if (infoInDb) { + return new GetValidatorInfoResponse({ validatorInfo: infoInDb }); + } - if (!validatorInfo) { - throw new ConnectError('No found validator info', Code.NotFound); + // Step 2: If none locally, query remote node + const { validatorInfo: infoFromNode } = await querier.stake.validatorInfo( + new GetValidatorInfoRequest({ identityKey: req.identityKey }), + ); + if (infoFromNode) { + return new GetValidatorInfoResponse({ validatorInfo: infoFromNode }); } - return { validatorInfo }; + + throw new ConnectError('No found validator info', Code.NotFound); }; diff --git a/packages/services/src/test-utils.ts b/packages/services/src/test-utils.ts index 5bd7820ff2..cd8920cb30 100644 --- a/packages/services/src/test-utils.ts +++ b/packages/services/src/test-utils.ts @@ -68,8 +68,10 @@ export interface MockQuerier { export interface SctMock { timestampByHeight?: Mock; } + export interface StakeMock { validatorPenalty?: Mock; + validatorInfo?: Mock; } interface MockServicesInner { diff --git a/packages/storage/src/indexed-db/index.ts b/packages/storage/src/indexed-db/index.ts index 38f650b749..e20e1af81e 100644 --- a/packages/storage/src/indexed-db/index.ts +++ b/packages/storage/src/indexed-db/index.ts @@ -689,6 +689,10 @@ export class IndexedDb implements IndexedDbInterface { ); } + async clearValidatorInfos() { + await this.db.clear('VALIDATOR_INFOS'); + } + async getValidatorInfo(identityKey: IdentityKey): Promise { // bech32m conversion asserts length const key = bech32mIdentityKey(identityKey); diff --git a/packages/types/src/indexed-db.ts b/packages/types/src/indexed-db.ts index 244acfcda2..efada74e76 100644 --- a/packages/types/src/indexed-db.ts +++ b/packages/types/src/indexed-db.ts @@ -109,6 +109,7 @@ export interface IndexedDbInterface { getEpochByHeight(height: bigint): Promise; upsertValidatorInfo(validatorInfo: ValidatorInfo): Promise; iterateValidatorInfos(): AsyncGenerator; + clearValidatorInfos(): Promise; getValidatorInfo(identityKey: IdentityKey): Promise; updatePrice( pricedAsset: AssetId, diff --git a/packages/types/src/querier.ts b/packages/types/src/querier.ts index 48fa38d21b..47f9ecb37a 100644 --- a/packages/types/src/querier.ts +++ b/packages/types/src/querier.ts @@ -1,30 +1,32 @@ -import { AppParameters } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/app/v1/app_pb.js'; -import { CompactBlock } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/compact_block/v1/compact_block_pb.js'; -import { - AssetId, - Metadata, -} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb.js'; import { QueryClientStatesRequest, QueryClientStatesResponse, } from '@buf/cosmos_ibc.bufbuild_es/ibc/core/client/v1/query_pb.js'; -import { TransactionId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/txhash/v1/txhash_pb.js'; -import { Transaction } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/transaction/v1/transaction_pb.js'; +import { AppParameters } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/app/v1/app_pb.js'; import { - ValidatorInfoRequest, - ValidatorInfoResponse, - ValidatorPenaltyRequest, - ValidatorPenaltyResponse, -} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/stake/v1/stake_pb.js'; -import { MerkleRoot } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/crypto/tct/v1/tct_pb.js'; + AssetId, + Metadata, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb.js'; import { AuctionId, DutchAuction, } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/auction/v1/auction_pb.js'; +import { CompactBlock } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/compact_block/v1/compact_block_pb.js'; import { TimestampByHeightRequest, TimestampByHeightResponse, } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/sct/v1/sct_pb.js'; +import { + GetValidatorInfoRequest, + GetValidatorInfoResponse, + ValidatorInfoRequest, + ValidatorInfoResponse, + ValidatorPenaltyRequest, + ValidatorPenaltyResponse, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/stake/v1/stake_pb.js'; +import { Transaction } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/transaction/v1/transaction_pb.js'; +import { TransactionId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/txhash/v1/txhash_pb.js'; +import { MerkleRoot } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/crypto/tct/v1/tct_pb.js'; export interface RootQuerierInterface { app: AppQuerierInterface; @@ -70,6 +72,7 @@ export interface IbcClientQuerierInterface { export interface StakeQuerierInterface { allValidatorInfos(req: ValidatorInfoRequest): AsyncIterable; validatorPenalty(req: ValidatorPenaltyRequest): Promise; + validatorInfo(req: GetValidatorInfoRequest): Promise; } export interface CnidariumQuerierInterface {