Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New asset pattern helper #801

Merged
merged 1 commit into from
Mar 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/minifront/src/components/swap/swap-loader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 3 additions & 6 deletions apps/minifront/src/state/ibc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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!);

Expand Down
2 changes: 1 addition & 1 deletion apps/minifront/src/state/staking/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!);

Expand Down
87 changes: 66 additions & 21 deletions packages/constants/src/assets.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -28,26 +28,26 @@ 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,
);
});
});

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);
Expand All @@ -56,34 +56,79 @@ 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,
);
});
});

describe('ibc', () => {
it('matches when a string follows the pattern transfer/<channel>/<denom>', () => {
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();
Comment on lines +109 to +110
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kinda nice the type system knows it shouldn't be calling something without capture groups

});

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 = /(?<greeting>hello) (?<subject>world)/;
const matcher = new RegexMatcher<GreetingSubjectGroups>(regex);
const expected: GreetingSubjectGroups = {
greeting: 'hello',
subject: 'world',
};
expect(matcher.capture('hello world')).toEqual(expected);
});
});
47 changes: 31 additions & 16 deletions packages/constants/src/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -36,8 +27,32 @@ export interface UnbondingCaptureGroups {
bech32IdentityKey: string;
}

export interface AssetPatterns {
lpNft: RegexMatcher;
delegationToken: RegexMatcher<DelegationCaptureGroups>;
proposalNft: RegexMatcher;
unbondingToken: RegexMatcher<UnbondingCaptureGroups>;
votingReceipt: RegexMatcher;
ibc: RegexMatcher<IbcCaptureGroups>;
}

export class RegexMatcher<T = never> {
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
Expand All @@ -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_(?<bech32IdentityKey>penumbravalid1(?<id>[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_(?<epoch>[0-9]+)_(?<bech32IdentityKey>penumbravalid1(?<id>[a-zA-HJ-NP-Z0-9]+))$/,
),
votingReceipt: new RegExp(/^voted_on_/),
ibc: new RegExp(/transfer\/(?<channel>channel-\d+)\/(?<denom>.*)/),
votingReceipt: new RegexMatcher(/^voted_on_/),
ibc: new RegexMatcher(/^transfer\/(?<channel>channel-\d+)\/(?<denom>.*)/),
};
21 changes: 7 additions & 14 deletions packages/getters/src/metadata.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand Down Expand Up @@ -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);
}

Expand All @@ -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;
Expand Down
8 changes: 4 additions & 4 deletions packages/router/src/grpc/view-protocol-server/assets.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -18,7 +18,7 @@ export const assets: Impl['assets'] = async function* (req, ctx) {

const patterns: {
includeReq: boolean;
pattern: RegExp;
pattern: RegexMatcher<unknown>;
}[] = [
{
includeReq: includeLpNfts,
Expand All @@ -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 };
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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) =>
Expand Down
Loading
Loading