diff --git a/apps/minifront/src/components/swap/swap-loader.tsx b/apps/minifront/src/components/swap/swap-loader.tsx index 9d30347db8..7b5712cc07 100644 --- a/apps/minifront/src/components/swap/swap-loader.tsx +++ b/apps/minifront/src/components/swap/swap-loader.tsx @@ -31,7 +31,7 @@ const getAndSetDefaultAssetBalances = async () => { // filter assets that are not available for swap const filteredAssetBalances = assetBalances.filter(b => [assetPatterns.lpNft, assetPatterns.proposalNft, assetPatterns.votingReceipt].every( - pattern => !pattern.test(getDisplayDenomFromView(b.balanceView)), + pattern => !pattern.matches(getDisplayDenomFromView(b.balanceView)), ), ); // set initial denom in if there is an available balance diff --git a/apps/minifront/src/state/ibc.ts b/apps/minifront/src/state/ibc.ts index 6121b7077b..2d1ecbd1c1 100644 --- a/apps/minifront/src/state/ibc.ts +++ b/apps/minifront/src/state/ibc.ts @@ -21,7 +21,6 @@ import { IbcLoaderResponse } from '../components/ibc/ibc-loader'; import { getAssetId } from '@penumbra-zone/getters/src/metadata'; import { assetPatterns, - IbcCaptureGroups, localAssets, STAKING_TOKEN_METADATA, } from '@penumbra-zone/constants/src/assets'; @@ -183,11 +182,9 @@ export const filterBalancesPerChain = ( const penumbraAssetId = getAssetId(STAKING_TOKEN_METADATA); const assetsWithMatchingChannel = localAssets .filter(a => { - const ibcAsset = assetPatterns.ibc.exec(a.base); - if (!ibcAsset) return false; - - const { channel } = ibcAsset.groups as unknown as IbcCaptureGroups; - return chain?.ibcChannel === channel; + const match = assetPatterns.ibc.capture(a.base); + if (!match) return false; + return chain?.ibcChannel === match.channel; }) .map(m => m.penumbraAssetId!); diff --git a/apps/minifront/src/state/staking/index.ts b/apps/minifront/src/state/staking/index.ts index df333a24bd..4a12411b03 100644 --- a/apps/minifront/src/state/staking/index.ts +++ b/apps/minifront/src/state/staking/index.ts @@ -426,7 +426,7 @@ const toUnbondingTokensByAccount = ( ) => { const unbondingTokens = curr.balances .filter(({ balanceView }) => - assetPatterns.unbondingToken.test(getDisplayDenomFromView(balanceView)), + assetPatterns.unbondingToken.matches(getDisplayDenomFromView(balanceView)), ) .map(({ balanceView }) => balanceView!); diff --git a/packages/constants/src/assets.test.ts b/packages/constants/src/assets.test.ts index b42b79f4d8..607ca13c41 100644 --- a/packages/constants/src/assets.test.ts +++ b/packages/constants/src/assets.test.ts @@ -1,25 +1,25 @@ import { describe, expect, it } from 'vitest'; -import { assetPatterns } from './assets'; +import { assetPatterns, RegexMatcher } from './assets'; describe('assetPatterns', () => { describe('lpNftPattern', () => { it('matches when a string begins with `lpnft_`', () => { - expect(assetPatterns.lpNft.test('lpnft_abc123')).toBe(true); + expect(assetPatterns.lpNft.matches('lpnft_abc123')).toBe(true); }); it('does not match when a string contains, but does not begin with, `lpnft_`', () => { - expect(assetPatterns.lpNft.test('ibc-transfer/channel-1234/lpnft_abc123')).toBe(false); + expect(assetPatterns.lpNft.matches('ibc-transfer/channel-1234/lpnft_abc123')).toBe(false); }); }); describe('delegationTokenPattern', () => { it('matches when a string is a valid delegation token name', () => { - expect(assetPatterns.delegationToken.test('delegation_penumbravalid1abc123')).toBe(true); + expect(assetPatterns.delegationToken.matches('delegation_penumbravalid1abc123')).toBe(true); }); it('does not match when a string contains, but does not begin with, a valid delegation token name', () => { expect( - assetPatterns.delegationToken.test( + assetPatterns.delegationToken.matches( 'ibc-transfer/channel-1234/delegation_penumbravalid1abc123', ), ).toBe(false); @@ -28,11 +28,11 @@ describe('assetPatterns', () => { describe('proposalNftPattern', () => { it('matches when a string begins with `proposal_`', () => { - expect(assetPatterns.proposalNft.test('proposal_abc123')).toBe(true); + expect(assetPatterns.proposalNft.matches('proposal_abc123')).toBe(true); }); it('does not match when a string contains, but does not begin with, `proposal_`', () => { - expect(assetPatterns.proposalNft.test('ibc-transfer/channel-1234/proposal_abc123')).toBe( + expect(assetPatterns.proposalNft.matches('ibc-transfer/channel-1234/proposal_abc123')).toBe( false, ); }); @@ -40,14 +40,14 @@ describe('assetPatterns', () => { describe('unbondingTokenPattern', () => { it('matches when a string is a valid unbonding token name', () => { - expect(assetPatterns.unbondingToken.test('uunbonding_epoch_1_penumbravalid1abc123')).toBe( + expect(assetPatterns.unbondingToken.matches('uunbonding_epoch_1_penumbravalid1abc123')).toBe( true, ); }); it('does not match when a string contains, but does not begin with, a valid unbonding token name', () => { expect( - assetPatterns.unbondingToken.test( + assetPatterns.unbondingToken.matches( 'ibc-transfer/channel-1234/uunbonding_epoch_1_penumbravalid1abc123', ), ).toBe(false); @@ -56,11 +56,11 @@ describe('assetPatterns', () => { describe('votingReceiptPattern', () => { it('matches when a string begins with `voted_on_`', () => { - expect(assetPatterns.votingReceipt.test('voted_on_abc123')).toBe(true); + expect(assetPatterns.votingReceipt.matches('voted_on_abc123')).toBe(true); }); it('does not match when a string contains, but does not begin with, `voted_on_`', () => { - expect(assetPatterns.votingReceipt.test('ibc-transfer/channel-1234/voted_on_abc123')).toBe( + expect(assetPatterns.votingReceipt.matches('ibc-transfer/channel-1234/voted_on_abc123')).toBe( false, ); }); @@ -68,22 +68,67 @@ describe('assetPatterns', () => { describe('ibc', () => { it('matches when a string follows the pattern transfer//', () => { - expect(assetPatterns.ibc.test('transfer/channel-141/uosmo')).toBeTruthy(); - expect(assetPatterns.ibc.test('transfer/channel-0/upenumbra')).toBeTruthy(); - expect(assetPatterns.ibc.test('transfer/channel-0/upenumbra/moo/test')).toBeTruthy(); - expect(assetPatterns.ibc.test('x/channel-0/upenumbra')).toBeFalsy(); + expect(assetPatterns.ibc.matches('transfer/channel-141/uosmo')).toBeTruthy(); + expect(assetPatterns.ibc.matches('transfer/channel-0/upenumbra')).toBeTruthy(); + expect(assetPatterns.ibc.matches('transfer/channel-0/upenumbra/moo/test')).toBeTruthy(); + expect(assetPatterns.ibc.matches('x/channel-0/upenumbra')).toBeFalsy(); }); it('captures channel and denom correctly', () => { - const match = 'transfer/channel-141/uosmo'.match(assetPatterns.ibc); - expect(match?.groups?.['channel']).toBe('channel-141'); - expect(match?.groups?.['denom']).toBe('uosmo'); + const match = assetPatterns.ibc.capture('transfer/channel-141/uosmo'); + expect(match?.channel).toBe('channel-141'); + expect(match?.denom).toBe('uosmo'); }); it('captures multi-hops', () => { - const match = 'transfer/channel-141/channel-42/uosmo'.match(assetPatterns.ibc); - expect(match?.groups?.['channel']).toBe('channel-141'); - expect(match?.groups?.['denom']).toBe('channel-42/uosmo'); + const match = assetPatterns.ibc.capture('transfer/channel-141/channel-42/uosmo'); + expect(match?.channel).toBe('channel-141'); + expect(match?.denom).toBe('channel-42/uosmo'); }); }); }); + +describe('RegexMatcher', () => { + describe('RegexMatcher.matches', () => { + it('should return true when the string matches the regex', () => { + const regex = /^[a-z]+$/; + const matcher = new RegexMatcher(regex); + expect(matcher.matches('abc')).toBe(true); + }); + + it('should return false when the string does not match the regex', () => { + const regex = /^[a-z]+$/; + const matcher = new RegexMatcher(regex); + expect(matcher.matches('123')).toBe(false); + }); + }); + + it('should return undefined if no groups are present', () => { + const regex = /hello/; + const matcher = new RegexMatcher(regex); + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + expect(matcher.capture('hello')).toBeUndefined(); + }); + + it('should return undefined if the string does not match', () => { + const regex = /hello/; + const matcher = new RegexMatcher(regex); + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + expect(matcher.capture('world')).toBeUndefined(); + }); + + it('should return typed capture groups object if present', () => { + interface GreetingSubjectGroups { + greeting: string; + subject: string; + } + + const regex = /(?hello) (?world)/; + const matcher = new RegexMatcher(regex); + const expected: GreetingSubjectGroups = { + greeting: 'hello', + subject: 'world', + }; + expect(matcher.capture('hello world')).toEqual(expected); + }); +}); diff --git a/packages/constants/src/assets.ts b/packages/constants/src/assets.ts index 9798cad67d..fb52ed2665 100644 --- a/packages/constants/src/assets.ts +++ b/packages/constants/src/assets.ts @@ -11,15 +11,6 @@ export const STAKING_TOKEN_METADATA = localAssets.find( metadata => metadata.display === STAKING_TOKEN, )!; -export interface AssetPatterns { - lpNft: RegExp; - delegationToken: RegExp; - proposalNft: RegExp; - unbondingToken: RegExp; - votingReceipt: RegExp; - ibc: RegExp; -} - export interface IbcCaptureGroups { channel: string; denom: string; @@ -36,8 +27,32 @@ export interface UnbondingCaptureGroups { bech32IdentityKey: string; } +export interface AssetPatterns { + lpNft: RegexMatcher; + delegationToken: RegexMatcher; + proposalNft: RegexMatcher; + unbondingToken: RegexMatcher; + votingReceipt: RegexMatcher; + ibc: RegexMatcher; +} + +export class RegexMatcher { + constructor(private readonly regex: RegExp) {} + + matches(str: string): boolean { + return this.regex.exec(str) !== null; + } + + capture(str: string): T | undefined { + const match = this.regex.exec(str); + if (!match) return undefined; + return match.groups as unknown as T; + } +} + /** - * Call `.test()` on these RegExp patterns to test whether a token is of a given type. + * Call `.matches()` on these RegExp patterns to test whether a token is of a given type. + * Call `.capture()` to grab the content by its capture groups (if present) * * NOTE - SECURITY IMPLICATIONS: These RegExps each assert that the given prefix * is at the _beginning_ of the string. This ensures that they are @@ -53,18 +68,18 @@ export interface UnbondingCaptureGroups { * https://github.com/penumbra-zone/penumbra/blob/main/crates/core/asset/src/asset/registry.rs */ export const assetPatterns: AssetPatterns = { - lpNft: new RegExp(/^lpnft_/), - delegationToken: new RegExp( + lpNft: new RegexMatcher(/^lpnft_/), + delegationToken: new RegexMatcher( /^delegation_(?penumbravalid1(?[a-zA-HJ-NP-Z0-9]+))$/, ), - proposalNft: new RegExp(/^proposal_/), + proposalNft: new RegexMatcher(/^proposal_/), /** * Unbonding tokens have only one denom unit, which is the base denom. Hence * the extra `u` at the beginning. */ - unbondingToken: new RegExp( + unbondingToken: new RegexMatcher( /^uunbonding_epoch_(?[0-9]+)_(?penumbravalid1(?[a-zA-HJ-NP-Z0-9]+))$/, ), - votingReceipt: new RegExp(/^voted_on_/), - ibc: new RegExp(/transfer\/(?channel-\d+)\/(?.*)/), + votingReceipt: new RegexMatcher(/^voted_on_/), + ibc: new RegexMatcher(/^transfer\/(?channel-\d+)\/(?.*)/), }; diff --git a/packages/getters/src/metadata.ts b/packages/getters/src/metadata.ts index 4a626e9c24..a44443a0ee 100644 --- a/packages/getters/src/metadata.ts +++ b/packages/getters/src/metadata.ts @@ -1,10 +1,6 @@ import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; import { createGetter } from './utils/create-getter'; -import { - assetPatterns, - DelegationCaptureGroups, - UnbondingCaptureGroups, -} from '@penumbra-zone/constants/src/assets'; +import { assetPatterns } from '@penumbra-zone/constants/src/assets'; export const getAssetId = createGetter((metadata?: Metadata) => metadata?.penumbraAssetId); @@ -35,11 +31,10 @@ export const getDisplayDenomExponent = createGetter( export const getStartEpochIndex = createGetter((metadata?: Metadata) => { if (!metadata) return undefined; - const unbondingMatch = assetPatterns.unbondingToken.exec(metadata.display); + const unbondingMatch = assetPatterns.unbondingToken.capture(metadata.display); if (unbondingMatch) { - const { epoch } = unbondingMatch.groups as unknown as UnbondingCaptureGroups; - + const { epoch } = unbondingMatch; if (epoch) return BigInt(epoch); } @@ -55,16 +50,14 @@ export const getStartEpochIndex = createGetter((metadata?: Metadata) => { export const getValidatorIdentityKeyAsBech32String = createGetter((metadata?: Metadata) => { if (!metadata) return undefined; - const delegationMatch = assetPatterns.delegationToken.exec(metadata.display); + const delegationMatch = assetPatterns.delegationToken.capture(metadata.display); if (delegationMatch) { - const { bech32IdentityKey } = delegationMatch.groups as unknown as DelegationCaptureGroups; - return bech32IdentityKey; + return delegationMatch.bech32IdentityKey; } - const unbondingMatch = assetPatterns.unbondingToken.exec(metadata.display); + const unbondingMatch = assetPatterns.unbondingToken.capture(metadata.display); if (unbondingMatch) { - const { bech32IdentityKey } = unbondingMatch.groups as unknown as UnbondingCaptureGroups; - return bech32IdentityKey; + return unbondingMatch.bech32IdentityKey; } return undefined; diff --git a/packages/router/src/grpc/view-protocol-server/assets.ts b/packages/router/src/grpc/view-protocol-server/assets.ts index c604015b64..67fa27c5c2 100644 --- a/packages/router/src/grpc/view-protocol-server/assets.ts +++ b/packages/router/src/grpc/view-protocol-server/assets.ts @@ -1,6 +1,6 @@ import type { Impl } from '.'; import { servicesCtx } from '../../ctx'; -import { assetPatterns } from '@penumbra-zone/constants/src/assets'; +import { assetPatterns, RegexMatcher } from '@penumbra-zone/constants/src/assets'; export const assets: Impl['assets'] = async function* (req, ctx) { const services = ctx.values.get(servicesCtx); @@ -18,7 +18,7 @@ export const assets: Impl['assets'] = async function* (req, ctx) { const patterns: { includeReq: boolean; - pattern: RegExp; + pattern: RegexMatcher; }[] = [ { includeReq: includeLpNfts, @@ -42,12 +42,12 @@ export const assets: Impl['assets'] = async function* (req, ctx) { }, ...includeSpecificDenominations.map(d => ({ includeReq: true, - pattern: new RegExp(`^${d.denom}$`), + pattern: new RegexMatcher(new RegExp(`^${d.denom}$`)), })), ].filter(i => i.includeReq); for await (const metadata of indexedDb.iterateAssetsMetadata()) { - if (filtered && !patterns.find(p => p.pattern.test(metadata.display))) continue; + if (filtered && !patterns.find(p => p.pattern.matches(metadata.display))) continue; yield { denomMetadata: metadata }; } }; diff --git a/packages/router/src/grpc/view-protocol-server/delegations-by-address-index.ts b/packages/router/src/grpc/view-protocol-server/delegations-by-address-index.ts index 173d44f1e6..3d7dcb8a6e 100644 --- a/packages/router/src/grpc/view-protocol-server/delegations-by-address-index.ts +++ b/packages/router/src/grpc/view-protocol-server/delegations-by-address-index.ts @@ -2,7 +2,7 @@ import { IdentityKey } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/co import Array from '@penumbra-zone/polyfills/src/Array.fromAsync'; import { customizeSymbol } from '@penumbra-zone/types/src/customize-symbol'; import { bech32IdentityKey } from '@penumbra-zone/bech32'; -import { assetPatterns, DelegationCaptureGroups } from '@penumbra-zone/constants/src/assets'; +import { assetPatterns } from '@penumbra-zone/constants/src/assets'; import { Any, PartialMessage } from '@bufbuild/protobuf'; import { getValidatorInfo } from '@penumbra-zone/getters/src/validator-info-response'; import { getIdentityKeyFromValidatorInfo } from '@penumbra-zone/getters/src/validator-info'; @@ -25,12 +25,10 @@ import { getDisplayDenomFromView } from '@penumbra-zone/getters/src/value-view'; import { Impl } from '.'; const isDelegationBalance = (balance: BalancesResponse, identityKey: IdentityKey) => { - const match = assetPatterns.delegationToken.exec(getDisplayDenomFromView(balance.balanceView)); + const match = assetPatterns.delegationToken.capture(getDisplayDenomFromView(balance.balanceView)); if (!match) return false; - const matchGroups = match.groups as unknown as DelegationCaptureGroups; - - return bech32IdentityKey(identityKey) === matchGroups.bech32IdentityKey; + return bech32IdentityKey(identityKey) === match.bech32IdentityKey; }; const getDelegationTokenBaseDenom = (validatorInfo: ValidatorInfo) => diff --git a/packages/storage/src/indexed-db/index.ts b/packages/storage/src/indexed-db/index.ts index f969ed63f2..040d90089b 100644 --- a/packages/storage/src/indexed-db/index.ts +++ b/packages/storage/src/indexed-db/index.ts @@ -23,11 +23,7 @@ import { AddressIndex, IdentityKey, } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb'; -import { - assetPatterns, - DelegationCaptureGroups, - localAssets, -} from '@penumbra-zone/constants/src/assets'; +import { assetPatterns, localAssets } from '@penumbra-zone/constants/src/assets'; import { Position, PositionId, @@ -361,7 +357,7 @@ export class IndexedDb implements IndexedDbInterface { for await (const assetCursor of this.db.transaction('ASSETS').store) { const denomMetadata = Metadata.fromJson(assetCursor.value); if ( - assetPatterns.delegationToken.test(denomMetadata.display) && + assetPatterns.delegationToken.matches(denomMetadata.display) && denomMetadata.penumbraAssetId ) { delegationAssets.set(uint8ArrayToHex(denomMetadata.penumbraAssetId.inner), denomMetadata); @@ -396,15 +392,15 @@ export class IndexedDb implements IndexedDbInterface { // delegation asset denom consists of prefix 'delegation_' and validator identity key in bech32m encoding // For example, in denom 'delegation_penumbravalid12s9lanucncnyasrsqgy6z532q7nwsw3aqzzeqqas55kkpyf6lhsqs2w0zar' // 'penumbravalid12s9lanucncnyasrsqgy6z532q7nwsw3aqzzeqas55kkpyf6lhsqs2w0zar' is validator identity key. - const regexResult = assetPatterns.delegationToken.exec(asset?.display ?? ''); + const regexResult = assetPatterns.delegationToken.capture(asset?.display ?? ''); if (!regexResult) throw new Error('expected delegation token identity key not present'); - const { bech32IdentityKey } = regexResult.groups as unknown as DelegationCaptureGroups; - notesForVoting.push( new NotesForVotingResponse({ noteRecord: note, - identityKey: new IdentityKey({ ik: bech32ToIdentityKey(bech32IdentityKey) }), + identityKey: new IdentityKey({ + ik: bech32ToIdentityKey(regexResult.bech32IdentityKey), + }), }), ); } diff --git a/packages/types/src/customize-symbol.ts b/packages/types/src/customize-symbol.ts index d985165597..57c712dcd4 100644 --- a/packages/types/src/customize-symbol.ts +++ b/packages/types/src/customize-symbol.ts @@ -1,30 +1,24 @@ import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; -import { - assetPatterns, - DelegationCaptureGroups, - UnbondingCaptureGroups, -} from '@penumbra-zone/constants/src/assets'; +import { assetPatterns } from '@penumbra-zone/constants/src/assets'; const SHORTENED_ID_LENGTH = 8; // If the metadata is for a delegation or unbonding tokens, customize its symbol. // We can't trust the validator's self-described name, so use their validator ID (in metadata.display). export const customizeSymbol = (metadata: Metadata) => { - const delegationMatch = assetPatterns.delegationToken.exec(metadata.display); + const delegationMatch = assetPatterns.delegationToken.capture(metadata.display); if (delegationMatch) { - const { id } = delegationMatch.groups as unknown as DelegationCaptureGroups; - const shortenedId = id.slice(0, SHORTENED_ID_LENGTH); + const shortenedId = delegationMatch.id.slice(0, SHORTENED_ID_LENGTH); const customized = metadata.clone(); customized.symbol = `delUM(${shortenedId}…)`; return customized; } - const unbondingMatch = assetPatterns.unbondingToken.exec(metadata.display); + const unbondingMatch = assetPatterns.unbondingToken.capture(metadata.display); if (unbondingMatch) { - const { id, epoch } = unbondingMatch.groups as unknown as UnbondingCaptureGroups; - const shortenedId = id.slice(0, SHORTENED_ID_LENGTH); + const shortenedId = unbondingMatch.id.slice(0, SHORTENED_ID_LENGTH); const customized = metadata.clone(); - customized.symbol = `unbondUMe${epoch}(${shortenedId}…)`; + customized.symbol = `unbondUMe${unbondingMatch.epoch}(${shortenedId}…)`; return customized; } diff --git a/packages/types/src/staking.ts b/packages/types/src/staking.ts index 49b448704c..d9802a42b0 100644 --- a/packages/types/src/staking.ts +++ b/packages/types/src/staking.ts @@ -14,7 +14,7 @@ import { getRateBpsFromFundingStream } from '@penumbra-zone/getters/src/funding- import { joinLoHiAmount } from './amount'; import { bech32IdentityKey } from '@penumbra-zone/bech32'; import { ValueView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; -import { assetPatterns, DelegationCaptureGroups } from '@penumbra-zone/constants/src/assets'; +import { assetPatterns } from '@penumbra-zone/constants/src/assets'; import { getDisplayDenomFromView } from '@penumbra-zone/getters/src/value-view'; export const getStateLabel = (validatorInfo: ValidatorInfo): string => @@ -35,13 +35,13 @@ export const isDelegationTokenForValidator = ( delegation: ValueView, validatorInfo: ValidatorInfo, ): boolean => { - const delegationMatch = assetPatterns.delegationToken.exec(getDisplayDenomFromView(delegation)); + const delegationMatch = assetPatterns.delegationToken.capture( + getDisplayDenomFromView(delegation), + ); if (!delegationMatch) return false; - const matchGroups = delegationMatch.groups as unknown as DelegationCaptureGroups; - return ( - matchGroups.bech32IdentityKey === + delegationMatch.bech32IdentityKey === bech32IdentityKey(getIdentityKeyFromValidatorInfo(validatorInfo)) ); };