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

chore(431-1): multichain list preparation #12293

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
164 changes: 164 additions & 0 deletions app/components/UI/Tokens/util/enableAllNetworksFilter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { RpcEndpointType } from '@metamask/network-controller';
import { NETWORK_CHAIN_ID } from '../../../../util/networks/customNetworks';
import {
enableAllNetworksFilter,
KnownNetworkConfigurations,
} from './enableAllNetworksFilter';

type TestNetworkConfigurations = Pick<
KnownNetworkConfigurations,
'0x1' | '0x89'
>;

type FlareTestNetworkConfigurations = Pick<
KnownNetworkConfigurations,
'0xe' | '0x13'
>;

type MultiNetworkConfigurations = Pick<
KnownNetworkConfigurations,
'0x1' | '0x89' | typeof NETWORK_CHAIN_ID.BASE
>;

describe('enableAllNetworksFilter', () => {
it('should create a record with all network chain IDs mapped to true', () => {
const mockNetworks: TestNetworkConfigurations = {
[NETWORK_CHAIN_ID.MAINNET]: {
chainId: NETWORK_CHAIN_ID.MAINNET,
name: 'Ethereum Mainnet',
blockExplorerUrls: ['https://etherscan.io'],
defaultRpcEndpointIndex: 0,
nativeCurrency: 'ETH',
rpcEndpoints: [
{
type: RpcEndpointType.Custom,
networkClientId: NETWORK_CHAIN_ID.MAINNET,
url: 'https://mainnet.infura.io/v3/{infuraProjectId}',
},
],
},
[NETWORK_CHAIN_ID.POLYGON]: {
chainId: NETWORK_CHAIN_ID.POLYGON,
name: 'Polygon',
blockExplorerUrls: ['https://polygonscan.com'],
defaultRpcEndpointIndex: 0,
nativeCurrency: 'MATIC',
rpcEndpoints: [
{
type: RpcEndpointType.Custom,
networkClientId: NETWORK_CHAIN_ID.POLYGON,
url: 'https://polygon-rpc.com',
},
],
},
};

const result = enableAllNetworksFilter(mockNetworks);

expect(result).toEqual({
[NETWORK_CHAIN_ID.MAINNET]: true,
[NETWORK_CHAIN_ID.POLYGON]: true,
});
});

it('should handle empty networks object', () => {
const result = enableAllNetworksFilter({});
expect(result).toEqual({});
});

it('should work with NETWORK_CHAIN_ID constants', () => {
const mockNetworks: FlareTestNetworkConfigurations = {
[NETWORK_CHAIN_ID.FLARE_MAINNET]: {
chainId: NETWORK_CHAIN_ID.FLARE_MAINNET,
name: 'Flare Mainnet',
blockExplorerUrls: ['https://flare.network'],
defaultRpcEndpointIndex: 0,
nativeCurrency: 'FLR',
rpcEndpoints: [
{
type: RpcEndpointType.Custom,
networkClientId: NETWORK_CHAIN_ID.FLARE_MAINNET,
url: 'https://flare-rpc.com',
},
],
},
[NETWORK_CHAIN_ID.SONGBIRD_TESTNET]: {
chainId: NETWORK_CHAIN_ID.SONGBIRD_TESTNET,
name: 'Songbird Testnet',
blockExplorerUrls: ['https://songbird.flare.network'],
defaultRpcEndpointIndex: 0,
nativeCurrency: 'SGB',
rpcEndpoints: [
{
type: RpcEndpointType.Custom,
networkClientId: NETWORK_CHAIN_ID.SONGBIRD_TESTNET,
url: 'https://songbird-rpc.flare.network',
},
],
},
};

const result = enableAllNetworksFilter(mockNetworks);

expect(result).toEqual({
[NETWORK_CHAIN_ID.FLARE_MAINNET]: true,
[NETWORK_CHAIN_ID.SONGBIRD_TESTNET]: true,
});
});

it('should handle networks with different property values', () => {
const mockNetworks: MultiNetworkConfigurations = {
[NETWORK_CHAIN_ID.MAINNET]: {
chainId: NETWORK_CHAIN_ID.MAINNET,
name: 'Network 1',
blockExplorerUrls: ['https://etherscan.io'],
defaultRpcEndpointIndex: 0,
nativeCurrency: 'ETH',
rpcEndpoints: [
{
type: RpcEndpointType.Custom,
networkClientId: NETWORK_CHAIN_ID.MAINNET,
url: 'https://mainnet.infura.io/v3/your-api-key',
},
],
},
[NETWORK_CHAIN_ID.POLYGON]: {
chainId: NETWORK_CHAIN_ID.POLYGON,
name: 'Network 2',
blockExplorerUrls: ['https://polygonscan.com'],
defaultRpcEndpointIndex: 0,
nativeCurrency: 'MATIC',
rpcEndpoints: [
{
type: RpcEndpointType.Custom,
networkClientId: NETWORK_CHAIN_ID.POLYGON,
url: 'https://polygon-rpc.com',
},
],
},
[NETWORK_CHAIN_ID.BASE]: {
chainId: NETWORK_CHAIN_ID.BASE,
name: 'Network 3',
blockExplorerUrls: ['https://base.network'],
defaultRpcEndpointIndex: 0,
nativeCurrency: 'BASE',
rpcEndpoints: [
{
type: RpcEndpointType.Custom,
networkClientId: NETWORK_CHAIN_ID.BASE,
url: 'https://base-rpc.com',
},
],
},
};

const result = enableAllNetworksFilter(mockNetworks);

expect(Object.values(result).every((value) => value === true)).toBe(true);
expect(Object.keys(result)).toEqual([
NETWORK_CHAIN_ID.MAINNET,
NETWORK_CHAIN_ID.POLYGON,
NETWORK_CHAIN_ID.BASE,
]);
});
});
18 changes: 18 additions & 0 deletions app/components/UI/Tokens/util/enableAllNetworksFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { NetworkConfiguration } from '@metamask/network-controller';
import { Hex } from '@metamask/utils';
import { NETWORK_CHAIN_ID } from '../../../../util/networks/customNetworks';

export type KnownNetworkConfigurations = {
[K in (typeof NETWORK_CHAIN_ID)[keyof typeof NETWORK_CHAIN_ID]]: NetworkConfiguration;
};

export function enableAllNetworksFilter(
networks: Partial<KnownNetworkConfigurations>,
) {
const allOpts: Record<Hex, boolean> = {};
Object.keys(networks).forEach((chainId) => {
const hexChainId = chainId as Hex;
allOpts[hexChainId] = true;
});
return allOpts;
}
98 changes: 98 additions & 0 deletions app/components/UI/Tokens/util/filterAssets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { filterAssets, FilterCriteria } from './filterAssets';

describe('filterAssets function - balance and chainId filtering', () => {
interface MockToken {
name: string;
symbol: string;
chainId: string; // Updated to string (e.g., '0x01', '0x89')
balance: number;
}

const mockTokens: MockToken[] = [
{ name: 'Token1', symbol: 'T1', chainId: '0x01', balance: 100 },
{ name: 'Token2', symbol: 'T2', chainId: '0x02', balance: 50 },
{ name: 'Token3', symbol: 'T3', chainId: '0x01', balance: 200 },
{ name: 'Token4', symbol: 'T4', chainId: '0x89', balance: 150 },
];

test('filters by inclusive chainId', () => {
const criteria: FilterCriteria[] = [
{
key: 'chainId',
opts: { '0x01': true, '0x89': true }, // ChainId must be '0x01' or '0x89'
filterCallback: 'inclusive',
},
];

const filtered = filterAssets(mockTokens, criteria);

expect(filtered.length).toBe(3); // Should include 3 tokens with chainId '0x01' and '0x89'
expect(filtered.map((token) => token.chainId)).toEqual([
'0x01',
'0x01',
'0x89',
]);
});

test('filters tokens with balance between 100 and 150 inclusive', () => {
const criteria: FilterCriteria[] = [
{
key: 'balance',
opts: { min: 100, max: 150 }, // Balance between 100 and 150
filterCallback: 'range',
},
];

const filtered = filterAssets(mockTokens, criteria);

expect(filtered.length).toBe(2); // Token1 and Token4
expect(filtered.map((token) => token.balance)).toEqual([100, 150]);
});

test('filters by inclusive chainId and balance range', () => {
const criteria: FilterCriteria[] = [
{
key: 'chainId',
opts: { '0x01': true, '0x89': true }, // ChainId must be '0x01' or '0x89'
filterCallback: 'inclusive',
},
{
key: 'balance',
opts: { min: 100, max: 150 }, // Balance between 100 and 150
filterCallback: 'range',
},
];

const filtered = filterAssets(mockTokens, criteria);

expect(filtered.length).toBe(2); // Token1 and Token4 meet both criteria
});

test('returns no tokens if no chainId matches', () => {
const criteria: FilterCriteria[] = [
{
key: 'chainId',
opts: { '0x04': true }, // No token with chainId '0x04'
filterCallback: 'inclusive',
},
];

const filtered = filterAssets(mockTokens, criteria);

expect(filtered.length).toBe(0); // No matching tokens
});

test('returns no tokens if balance is not within range', () => {
const criteria: FilterCriteria[] = [
{
key: 'balance',
opts: { min: 300, max: 400 }, // No token with balance between 300 and 400
filterCallback: 'range',
},
];

const filtered = filterAssets(mockTokens, criteria);

expect(filtered.length).toBe(0); // No matching tokens
});
});
62 changes: 62 additions & 0 deletions app/components/UI/Tokens/util/filterAssets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { get } from 'lodash';

export interface FilterCriteria {
key: string;
opts: Record<string, FilterType>; // Use opts for range, inclusion, etc.
filterCallback: FilterCallbackKeys; // Specify the type of filter: 'range', 'inclusive', etc.
}

export type FilterType = string | number | boolean | Date;
type FilterCallbackKeys = keyof FilterCallbacksT;

export interface FilterCallbacksT {
inclusive: (value: string, opts: Record<string, boolean>) => boolean;
range: (value: number, opts: Record<string, number>) => boolean;
}

const filterCallbacks: FilterCallbacksT = {
inclusive: (value: string, opts: Record<string, boolean>) => {
if (Object.entries(opts).length === 0) {
return false;
}
return opts[value];
},
range: (value: number, opts: Record<string, number>) =>
value >= opts.min && value <= opts.max,
};

function getNestedValue<T>(obj: T, keyPath: string): FilterType {
return get(obj, keyPath);
}

export function filterAssets<T>(assets: T[], criteria: FilterCriteria[]): T[] {
if (criteria.length === 0) {
return assets;
}

return assets.filter((asset) =>
criteria.every(({ key, opts, filterCallback }) => {
const nestedValue = getNestedValue(asset, key);

// If there's no callback or options, exit early and don't filter based on this criterion.
if (!filterCallback || !opts) {
return true;
}

switch (filterCallback) {
case 'inclusive':
return filterCallbacks.inclusive(
nestedValue as string,
opts as Record<string, boolean>,
);
case 'range':
return filterCallbacks.range(
nestedValue as number,
opts as { min: number; max: number },
);
default:
return true;
}
}),
);
}
30 changes: 24 additions & 6 deletions app/util/networks/customNetworks.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Hex } from '@metamask/utils';
import { toHex } from '@metamask/controller-utils';
import { CHAIN_IDS } from '@metamask/transaction-controller';

/* eslint-disable @typescript-eslint/no-require-imports, import/no-commonjs */
const InfuraKey = process.env.MM_INFURA_PROJECT_ID;
Expand Down Expand Up @@ -130,10 +132,26 @@ export const UnpopularNetworkList = [
},
];

export const CustomNetworkImgMapping: Record<`0x${string}`, string> = {
'0xe': require('../../images/flare-mainnet.png'), // Flare Mainnet
'0x13': require('../../images/songbird.png'), // Songbird Testnet
'0x8157': require('../../images/ape-network.png'), // ApeChain testnet
'0x8173': require('../../images/ape-network.png'), // ApeChain mainnet
'0x659': require('../../images/gravity.png'), // Gravity Alpha Mainnet
export const NETWORK_CHAIN_ID: {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Mirror what we have in extension. See here

readonly FLARE_MAINNET: '0xe';
readonly SONGBIRD_TESTNET: '0x13';
readonly APE_CHAIN_TESTNET: '0x8157';
readonly APE_CHAIN_MAINNET: '0x8173';
readonly GRAVITY_ALPHA_MAINNET: '0x659';
} & typeof CHAIN_IDS = {
FLARE_MAINNET: '0xe',
SONGBIRD_TESTNET: '0x13',
APE_CHAIN_TESTNET: '0x8157',
APE_CHAIN_MAINNET: '0x8173',
GRAVITY_ALPHA_MAINNET: '0x659',
...CHAIN_IDS,
};

/* eslint-disable @typescript-eslint/no-require-imports, import/no-commonjs */
export const CustomNetworkImgMapping: Record<Hex, string> = {
[NETWORK_CHAIN_ID.FLARE_MAINNET]: require('../../images/flare-mainnet.png'),
[NETWORK_CHAIN_ID.SONGBIRD_TESTNET]: require('../../images/songbird.png'),
[NETWORK_CHAIN_ID.APE_CHAIN_TESTNET]: require('../../images/ape-network.png'),
[NETWORK_CHAIN_ID.APE_CHAIN_MAINNET]: require('../../images/ape-network.png'),
[NETWORK_CHAIN_ID.GRAVITY_ALPHA_MAINNET]: require('../../images/gravity.png'),
};
Loading