diff --git a/.changeset/three-sheep-arrive.md b/.changeset/three-sheep-arrive.md new file mode 100644 index 0000000000..51e2474e5f --- /dev/null +++ b/.changeset/three-sheep-arrive.md @@ -0,0 +1,5 @@ +--- +'@penumbra-zone/services': minor +--- + +Return jailed validators in delegation balances request diff --git a/packages/services/src/view-service/delegations-by-address-index.test.ts b/packages/services/src/view-service/delegations-by-address-index.test.ts index 3ae7e37542..d9bf9d73d9 100644 --- a/packages/services/src/view-service/delegations-by-address-index.test.ts +++ b/packages/services/src/view-service/delegations-by-address-index.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { delegationsByAddressIndex } from './delegations-by-address-index.js'; -import { ViewService, StakeService } from '@penumbra-zone/protobuf'; +import { StakeService, ViewService } from '@penumbra-zone/protobuf'; import { createContextValues, createHandlerContext, @@ -22,10 +22,11 @@ import { } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/stake/v1/stake_pb.js'; import { getAmount, getValidatorInfoFromValueView } from '@penumbra-zone/getters/value-view'; import { identityKeyFromBech32m } from '@penumbra-zone/bech32m/penumbravalid'; -import { PartialMessage } from '@bufbuild/protobuf'; +import { Any, PartialMessage } from '@bufbuild/protobuf'; import { Metadata, ValueView, + ValueView_KnownAssetId, } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb.js'; vi.mock('@penumbra-zone/wasm/metadata', () => ({ @@ -98,6 +99,21 @@ const inactiveValidator2InfoResponse = new ValidatorInfoResponse({ }, }); +// udelegation_penumbravalid163d86qvg7c6fv33a2rqfxdr4xjjst9xg49exup0x9g9t9plnm5qqcvjyyp +const jailedValidatorBech32IdentityKey = + 'penumbravalid1mr4j6nh3za3wjptjr2uj2ssr3fg0gxxqgqg9vgjl7luqa3qur5zs3fj5w6'; +const jailedValidatorInfoResponse = new ValidatorInfoResponse({ + validatorInfo: { + validator: { + name: 'Jailed validator', + identityKey: identityKeyFromBech32m(jailedValidatorBech32IdentityKey), + }, + status: { + state: { state: ValidatorState_ValidatorStateEnum.JAILED }, + }, + }, +}); + const MOCK_ALL_VALIDATOR_INFOS = [ activeValidatorInfoResponse, activeValidator2InfoResponse, @@ -173,6 +189,29 @@ const inactiveValidatorBalancesResponse = new BalancesResponse({ }, }); +const jailedValidatorBalancesResponse = new BalancesResponse({ + accountAddress: { + addressView: { + case: 'decoded', + value: { + index: { account: 0 }, + }, + }, + }, + balanceView: { + valueView: { + case: 'knownAssetId', + value: { + amount: { hi: 0n, lo: 4738495n }, + metadata: { + base: `udelegation_${jailedValidatorBech32IdentityKey}`, + display: `delegation_${jailedValidatorBech32IdentityKey}`, + }, + }, + }, + }, +}); + const MOCK_BALANCES = [ penumbraBalancesResponse, activeValidatorBalancesResponse, @@ -182,6 +221,7 @@ const MOCK_BALANCES = [ describe('DelegationsByAddressIndex request handler', () => { const mockStakeClient = { validatorInfo: vi.fn(), + getValidatorInfo: vi.fn(), }; let mockCtx: HandlerContext; @@ -200,78 +240,41 @@ describe('DelegationsByAddressIndex request handler', () => { [Symbol.asyncIterator]: () => mockActiveValidatorInfosResponse, }; - beforeEach(() => { - vi.resetAllMocks(); - mockBalances.mockReturnValue(mockBalancesResponse); - MOCK_BALANCES.forEach(value => mockBalancesResponse.next.mockResolvedValueOnce({ value })); - mockBalancesResponse.next.mockResolvedValueOnce({ done: true }); - - // Miniature mock staking client that actually switches what response it - // gives based on `req.showInactive`. - mockStakeClient.validatorInfo.mockImplementation((req: ValidatorInfoRequest) => - req.showInactive ? mockAllValidatorInfosResponse : mockActiveValidatorInfosResponse, - ); - MOCK_ALL_VALIDATOR_INFOS.forEach(value => - mockAllValidatorInfosResponse.next.mockResolvedValueOnce({ value }), - ); - mockAllValidatorInfosResponse.next.mockResolvedValueOnce({ done: true }); - MOCK_ACTIVE_VALIDATOR_INFOS.forEach(value => - mockActiveValidatorInfosResponse.next.mockResolvedValueOnce({ value }), - ); - mockActiveValidatorInfosResponse.next.mockResolvedValueOnce({ done: true }); - - mockCtx = createHandlerContext({ - service: ViewService, - method: ViewService.methods.fMDParameters, - protocolName: 'mock', - requestMethod: 'MOCK', - url: '/mock', - contextValues: createContextValues().set( - stakeClientCtx, - mockStakeClient as unknown as PromiseClient, - ), + describe('only active/inactive responses', () => { + beforeEach(() => { + vi.resetAllMocks(); + mockBalances.mockReturnValue(mockBalancesResponse); + MOCK_BALANCES.forEach(value => mockBalancesResponse.next.mockResolvedValueOnce({ value })); + mockBalancesResponse.next.mockResolvedValueOnce({ done: true }); + + // Miniature mock staking client that actually switches what response it + // gives based on `req.showInactive`. + mockStakeClient.validatorInfo.mockImplementation((req: ValidatorInfoRequest) => + req.showInactive ? mockAllValidatorInfosResponse : mockActiveValidatorInfosResponse, + ); + MOCK_ALL_VALIDATOR_INFOS.forEach(value => + mockAllValidatorInfosResponse.next.mockResolvedValueOnce({ value }), + ); + mockAllValidatorInfosResponse.next.mockResolvedValueOnce({ done: true }); + MOCK_ACTIVE_VALIDATOR_INFOS.forEach(value => + mockActiveValidatorInfosResponse.next.mockResolvedValueOnce({ value }), + ); + mockActiveValidatorInfosResponse.next.mockResolvedValueOnce({ done: true }); + + mockCtx = createHandlerContext({ + service: ViewService, + method: ViewService.methods.fMDParameters, + protocolName: 'mock', + requestMethod: 'MOCK', + url: '/mock', + contextValues: createContextValues().set( + stakeClientCtx, + mockStakeClient as unknown as PromiseClient, + ), + }); }); - }); - - it("includes the address's balance in the `ValueView` for delegation tokens the address holds", async () => { - const results: ( - | DelegationsByAddressIndexResponse - | PartialMessage - )[] = []; - - for await (const result of delegationsByAddressIndex( - new DelegationsByAddressIndexRequest({ addressIndex: { account: 0 } }), - mockCtx, - )) { - results.push(result); - } - const firstValueView = new ValueView(results[0]!.valueView); - - expect(getAmount(firstValueView)).toEqual({ hi: 0n, lo: 2n }); - }); - - it("includes `ValidatorInfo` in the `ValueView`'s `extendedMetadata` property", async () => { - const results: ( - | DelegationsByAddressIndexResponse - | PartialMessage - )[] = []; - - for await (const result of delegationsByAddressIndex( - new DelegationsByAddressIndexRequest({ addressIndex: { account: 0 } }), - mockCtx, - )) { - results.push(result); - } - - const firstValueView = new ValueView(results[0]!.valueView); - const validatorInfo = getValidatorInfoFromValueView(firstValueView); - - expect(validatorInfo.toJson()).toEqual(activeValidatorInfoResponse.validatorInfo!.toJson()); - }); - - describe('when no filter option is passed', () => { - it('returns one `ValueView` for each active validator', async () => { + it("includes the address's balance in the `ValueView` for delegation tokens the address holds", async () => { const results: ( | DelegationsByAddressIndexResponse | PartialMessage @@ -284,10 +287,12 @@ describe('DelegationsByAddressIndex request handler', () => { results.push(result); } - expect(results.length).toBe(2); + const firstValueView = new ValueView(results[0]!.valueView); + + expect(getAmount(firstValueView)).toEqual({ hi: 0n, lo: 2n }); }); - it('returns a zero-balance `ValueView` for validators the address has no tokens for', async () => { + it("includes `ValidatorInfo` in the `ValueView`'s `extendedMetadata` property", async () => { const results: ( | DelegationsByAddressIndexResponse | PartialMessage @@ -300,35 +305,121 @@ describe('DelegationsByAddressIndex request handler', () => { results.push(result); } - const secondValueView = new ValueView(results[1]!.valueView); + const firstValueView = new ValueView(results[0]!.valueView); + const validatorInfo = getValidatorInfoFromValueView(firstValueView); - expect(getAmount(secondValueView)).toEqual({ hi: 0n, lo: 0n }); + expect(validatorInfo.toJson()).toEqual(activeValidatorInfoResponse.validatorInfo!.toJson()); }); - }); - describe('when the nonzero balances filter option is passed', () => { - it('returns one `ValueView` for each validator the address has tokens for', async () => { - const results: ( - | DelegationsByAddressIndexResponse - | PartialMessage - )[] = []; + describe('when no filter option is passed', () => { + it('returns one `ValueView` for each active validator', async () => { + const results: ( + | DelegationsByAddressIndexResponse + | PartialMessage + )[] = []; + + for await (const result of delegationsByAddressIndex( + new DelegationsByAddressIndexRequest({ addressIndex: { account: 0 } }), + mockCtx, + )) { + results.push(result); + } + + expect(results.length).toBe(2); + }); + + it('returns a zero-balance `ValueView` for validators the address has no tokens for', async () => { + const results: ( + | DelegationsByAddressIndexResponse + | PartialMessage + )[] = []; + + for await (const result of delegationsByAddressIndex( + new DelegationsByAddressIndexRequest({ addressIndex: { account: 0 } }), + mockCtx, + )) { + results.push(result); + } + + const secondValueView = new ValueView(results[1]!.valueView); + + expect(getAmount(secondValueView)).toEqual({ hi: 0n, lo: 0n }); + }); + }); - for await (const result of delegationsByAddressIndex( - new DelegationsByAddressIndexRequest({ - addressIndex: { account: 0 }, - filter: DelegationsByAddressIndexRequest_Filter.ALL_ACTIVE_WITH_NONZERO_BALANCES, - }), - mockCtx, - )) { - results.push(result); - } + describe('when the nonzero balances filter option is passed', () => { + it('returns one `ValueView` for each validator the address has tokens for', async () => { + const results: ( + | DelegationsByAddressIndexResponse + | PartialMessage + )[] = []; + + for await (const result of delegationsByAddressIndex( + new DelegationsByAddressIndexRequest({ + addressIndex: { account: 0 }, + filter: DelegationsByAddressIndexRequest_Filter.ALL_ACTIVE_WITH_NONZERO_BALANCES, + }), + mockCtx, + )) { + results.push(result); + } + + expect(results.length).toBe(1); + }); + }); - expect(results.length).toBe(1); + describe('when the `ALL` filter option is passed', () => { + it('returns one `ValueView` for each validator, including inactive ones', async () => { + const results: ( + | DelegationsByAddressIndexResponse + | PartialMessage + )[] = []; + + for await (const result of delegationsByAddressIndex( + new DelegationsByAddressIndexRequest({ + addressIndex: { account: 0 }, + filter: DelegationsByAddressIndexRequest_Filter.ALL, + }), + mockCtx, + )) { + results.push(result); + } + + expect(results.length).toBe(4); + }); }); }); - describe('when the `ALL` filter option is passed', () => { - it('returns one `ValueView` for each validator, including inactive ones', async () => { + describe('when user has a jailed validator it its balances', () => { + beforeEach(() => { + vi.resetAllMocks(); + mockBalances.mockReturnValue(mockBalancesResponse); + [...MOCK_BALANCES, jailedValidatorBalancesResponse].forEach(value => + mockBalancesResponse.next.mockResolvedValueOnce({ value }), + ); + mockBalancesResponse.next.mockResolvedValueOnce({ done: true }); + + mockStakeClient.validatorInfo.mockReturnValue(mockAllValidatorInfosResponse); + + [...MOCK_ALL_VALIDATOR_INFOS, jailedValidatorInfoResponse].forEach(value => + mockAllValidatorInfosResponse.next.mockResolvedValueOnce({ value }), + ); + mockAllValidatorInfosResponse.next.mockResolvedValueOnce({ done: true }); + + mockCtx = createHandlerContext({ + service: ViewService, + method: ViewService.methods.fMDParameters, + protocolName: 'mock', + requestMethod: 'MOCK', + url: '/mock', + contextValues: createContextValues().set( + stakeClientCtx, + mockStakeClient as unknown as PromiseClient, + ), + }); + }); + + it('returns the jailed balance back', async () => { const results: ( | DelegationsByAddressIndexResponse | PartialMessage @@ -344,7 +435,23 @@ describe('DelegationsByAddressIndex request handler', () => { results.push(result); } - expect(results.length).toBe(4); + // balance augmented with extended metadata of validator info + const expectedResponse = new ValueView({ + valueView: { + case: 'knownAssetId', + value: { + amount: jailedValidatorBalancesResponse.balanceView!.valueView.value!.amount, + metadata: ( + jailedValidatorBalancesResponse.balanceView!.valueView.value as ValueView_KnownAssetId + ).metadata, + extendedMetadata: Any.pack(jailedValidatorInfoResponse.validatorInfo!), + }, + }, + }); + + expect((results[4]!.valueView! as ValueView).equals(expectedResponse)).toBeTruthy(); + + expect(results.length).toBe(5); }); }); }); diff --git a/packages/services/src/view-service/delegations-by-address-index.ts b/packages/services/src/view-service/delegations-by-address-index.ts index 09d9da1581..80e8b93891 100644 --- a/packages/services/src/view-service/delegations-by-address-index.ts +++ b/packages/services/src/view-service/delegations-by-address-index.ts @@ -1,8 +1,11 @@ -import { IdentityKey } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb.js'; +import { + AddressIndex, + IdentityKey, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb.js'; import { customizeSymbol } from '@penumbra-zone/wasm/metadata'; -import { assetPatterns } from '@penumbra-zone/types/assets'; -import { bech32mIdentityKey } from '@penumbra-zone/bech32m/penumbravalid'; -import { Any, PartialMessage } from '@bufbuild/protobuf'; +import { assetPatterns, DelegationCaptureGroups } from '@penumbra-zone/types/assets'; +import { bech32mIdentityKey, identityKeyFromBech32m } from '@penumbra-zone/bech32m/penumbravalid'; +import { Any } from '@bufbuild/protobuf'; import { getValidatorInfo } from '@penumbra-zone/getters/validator-info-response'; import { getIdentityKeyFromValidatorInfo } from '@penumbra-zone/getters/validator-info'; import { ValidatorInfo } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/stake/v1/stake_pb.js'; @@ -22,23 +25,19 @@ import { import { assetMetadataById } from './asset-metadata-by-id.js'; import { getDisplayDenomFromView } from '@penumbra-zone/getters/value-view'; import { Impl } from './index.js'; - -const isDelegationBalance = (balance: BalancesResponse, identityKey: IdentityKey) => { - const match = assetPatterns.delegationToken.capture(getDisplayDenomFromView(balance.balanceView)); - if (!match) { - return false; - } - - return bech32mIdentityKey(identityKey) === match.idKey; -}; +import { HandlerContext } from '@connectrpc/connect'; const getDelegationTokenBaseDenom = (validatorInfo: ValidatorInfo) => `udelegation_${bech32mIdentityKey(getIdentityKeyFromValidatorInfo(validatorInfo))}`; -const addressHasDelegationTokens = ( - delegation?: PartialMessage, -): delegation is PartialMessage & { balanceView: ValueView } => - delegation?.balanceView instanceof ValueView; +const responseWithExtMetadata = function* (delegation: ValueView, extendedMetadata: Any) { + const withValidatorInfo = delegation.clone(); + if (withValidatorInfo.valueView.case !== 'knownAssetId') { + throw new Error(`Unexpected ValueView case: ${withValidatorInfo.valueView.case}`); + } + withValidatorInfo.valueView.value.extendedMetadata = extendedMetadata; + yield new DelegationsByAddressIndexResponse({ valueView: withValidatorInfo }); +}; export const delegationsByAddressIndex: Impl['delegationsByAddressIndex'] = async function* ( req, @@ -49,40 +48,31 @@ export const delegationsByAddressIndex: Impl['delegationsByAddressIndex'] = asyn throw new Error('Missing `addressIndex` in `DelegationsByAddressIndex` request'); } - const mockStakeClient = ctx.values.get(stakeClientCtx); - if (!mockStakeClient) { + const stakeClient = ctx.values.get(stakeClientCtx); + if (!stakeClient) { throw new Error('Staking context not found'); } - const assetBalances = await Array.fromAsync( - balances(new BalancesRequest({ accountFilter: addressIndex }), ctx), - ); + const delTokenTracker = await DelegationTokenTracker.init({ addressIndex, ctx }); // See https://github.com/typescript-eslint/typescript-eslint/issues/7114 // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison const showInactive = req.filter === DelegationsByAddressIndexRequest_Filter.ALL; - for await (const validatorInfoResponse of mockStakeClient.validatorInfo({ showInactive })) { + // Step 1: Query the current validator list. Mark those that have matched what's in the balances. + for await (const validatorInfoResponse of stakeClient.validatorInfo({ showInactive })) { const validatorInfo = getValidatorInfo(validatorInfoResponse); const extendedMetadata = Any.pack(validatorInfo); const identityKey = getValidatorInfo.pipe(getIdentityKeyFromValidatorInfo)( validatorInfoResponse, ); - const delegation = assetBalances.find(balance => - isDelegationBalance(new BalancesResponse(balance), identityKey), - ); - if (addressHasDelegationTokens(delegation)) { - const withValidatorInfo = delegation.balanceView.clone(); - - if (withValidatorInfo.valueView.case !== 'knownAssetId') { - throw new Error(`Unexpected ValueView case: ${withValidatorInfo.valueView.case}`); - } + const delegation = delTokenTracker.getDelegationFor(identityKey); - withValidatorInfo.valueView.value.extendedMetadata = extendedMetadata; - - yield new DelegationsByAddressIndexResponse({ valueView: withValidatorInfo }); + if (delegation) { + yield* responseWithExtMetadata(delegation, extendedMetadata); + delTokenTracker.markAsQueried(identityKey); } else { // See https://github.com/typescript-eslint/typescript-eslint/issues/7114 // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison @@ -114,4 +104,95 @@ export const delegationsByAddressIndex: Impl['delegationsByAddressIndex'] = asyn }); } } + + // Step 2: For the delegation tokens that haven't been queried for, it must mean they are jailed. + // It's necessary to query for these individually as they are not available in the previous query. + if (showInactive) { + const allUnqueried = delTokenTracker.allUnqueried(); + for (const { identityKey, valueView } of allUnqueried) { + const { validatorInfo } = await stakeClient.getValidatorInfo({ identityKey }); + if (!validatorInfo) { + console.warn(`No validator info found for ${bech32mIdentityKey(identityKey)}`); + continue; + } + + const extendedMetadata = Any.pack(validatorInfo); + yield* responseWithExtMetadata(valueView, extendedMetadata); + } + } }; + +type Bech32IdentityKey = string; + +interface DelTokenQueryStatus { + valueView: ValueView; + queried: boolean; + identityKey: IdentityKey; +} + +type DelTokenMap = Record; + +// Class used to keep track of what delegation balances have been queried yet. +// Used after main stream loop to ensure we still query and send back delegation token balances +// that have a jailed state. +class DelegationTokenTracker { + private constructor(private readonly delTokens: DelTokenMap) {} + + static async init({ + addressIndex, + ctx, + }: { + addressIndex: AddressIndex; + ctx: HandlerContext; + }): Promise { + const allBalances = await Array.fromAsync( + balances(new BalancesRequest({ accountFilter: addressIndex }), ctx), + ); + + const delTokenMap: DelTokenMap = {}; + + for (const partialRes of allBalances) { + const res = new BalancesResponse(partialRes); + const match = this.getDelTokenCaptureGroups(res.balanceView); + if (match && res.balanceView) { + delTokenMap[match.idKey] = { + valueView: res.balanceView, + queried: false, + identityKey: new IdentityKey(identityKeyFromBech32m(match.idKey)), + }; + } + } + return new this(delTokenMap); + } + + private static getDelTokenCaptureGroups(view?: ValueView): DelegationCaptureGroups | undefined { + return assetPatterns.delegationToken.capture(getDisplayDenomFromView(view)); + } + + private findDelegationStatus(idKey: IdentityKey): DelTokenQueryStatus | undefined { + return this.delTokens[bech32mIdentityKey(idKey)]; + } + + getDelegationFor(idKey: IdentityKey): ValueView | undefined { + const delegation = this.findDelegationStatus(idKey); + if (delegation) { + return delegation.valueView; + } + return undefined; + } + + markAsQueried(idKey: IdentityKey): void { + const delegation = this.findDelegationStatus(idKey); + if (!delegation) { + console.warn( + 'tried to mark a delegation token as queried the user did not have a balance for', + ); + } else { + delegation.queried = true; + } + } + + allUnqueried(): DelTokenQueryStatus[] { + return Object.values(this.delTokens).filter(t => !t.queried); + } +} diff --git a/packages/types/src/assets.ts b/packages/types/src/assets.ts index 743938278c..84c3cea938 100644 --- a/packages/types/src/assets.ts +++ b/packages/types/src/assets.ts @@ -75,6 +75,7 @@ export const assetPatterns: AssetPatterns = { /^auctionnft_(?[0-9]+)_(?pauctid1[a-zA-HJ-NP-Z0-9]+)$/, ), lpNft: new RegexMatcher(/^lpnft_/), + // TODO: This should be a regex on the base denom and not the display denom delegationToken: new RegexMatcher( /^delegation_(?penumbravalid1(?[a-zA-HJ-NP-Z0-9]+))$/, ),