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

feat: add tokens search discovery controller #13111

Open
wants to merge 54 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
7a46fdf
feat: add-token-search-discovery-controller
Bigshmow Jan 22, 2025
5e57f42
chore: update package and yarn lock
Bigshmow Jan 22, 2025
db3774a
feat: introduce to engine context
Bigshmow Jan 22, 2025
02b04ed
feat: hooks and selectors for FE
Bigshmow Jan 22, 2025
a0a125f
chore: remove empty tests
Bigshmow Jan 22, 2025
d85c0f5
chore: update test json
Bigshmow Jan 22, 2025
ab3aa5b
chore: update failing snapshot
Bigshmow Jan 22, 2025
bfd52c3
Merge branch 'main' into add-tokens-search-discovery-controller
Bigshmow Jan 22, 2025
34aaea0
Merge branch 'main' into add-tokens-search-discovery-controller
Bigshmow Jan 23, 2025
c4d3239
Merge branch 'main' into add-tokens-search-discovery-controller
Bigshmow Jan 24, 2025
9d394d4
Merge branch 'main' into add-tokens-search-discovery-controller
Bigshmow Jan 28, 2025
6dde564
chore: add controller factory tests
Bigshmow Jan 28, 2025
d8019b7
chore: linter fix for obj property shorthand
Bigshmow Jan 28, 2025
69421f1
Merge branch 'main' into add-tokens-search-discovery-controller
Bigshmow Jan 28, 2025
a6214b5
chore: code coverage and resilient selector
Bigshmow Jan 28, 2025
b767d50
chore: use requireActual method per convention and linter
Bigshmow Jan 28, 2025
35f5cab
Merge branch 'main' into add-tokens-search-discovery-controller
Bigshmow Jan 29, 2025
9bc97fb
Merge branch 'main' into add-tokens-search-discovery-controller
Bigshmow Jan 29, 2025
10638db
Merge branch 'main' into add-tokens-search-discovery-controller
Bigshmow Jan 29, 2025
8716d3e
Merge branch 'main' into add-tokens-search-discovery-controller
Bigshmow Jan 30, 2025
f89c331
feat: introduce generic debounce hook to searchTokens
Bigshmow Jan 30, 2025
c3c081d
Merge branch 'add-tokens-search-discovery-controller' of github.com:M…
Bigshmow Jan 30, 2025
2e2dea8
chore: use lodash debounce directly, update test
Bigshmow Jan 30, 2025
9e0ed48
feat: manage result state internally
Bigshmow Jan 30, 2025
25b495b
chore: fix shadow
Bigshmow Jan 30, 2025
f70bfa6
Merge branch 'main' into add-tokens-search-discovery-controller
Bigshmow Jan 30, 2025
a85355b
Merge branch 'main' into add-tokens-search-discovery-controller
Bigshmow Jan 30, 2025
92afe2b
Merge branch 'main' into add-tokens-search-discovery-controller
Bigshmow Jan 31, 2025
8f2f3e7
chore: add portfolio team to codeowners
Bigshmow Jan 31, 2025
43dce11
chore: fix unintended notification team codeowner update
Bigshmow Jan 31, 2025
ad1c8f4
Merge branch 'main' into add-tokens-search-discovery-controller
Bigshmow Feb 3, 2025
cd4d72e
add tokensearchdiscoverycontroller to bg state for state change subsc…
Bigshmow Feb 3, 2025
c076d24
add controller to the get state()
Bigshmow Feb 3, 2025
8c73868
add missing types for actions and events
Bigshmow Feb 3, 2025
3dcfdd1
Merge branch 'main' into add-tokens-search-discovery-controller
Bigshmow Feb 3, 2025
1e49ad7
Merge branch 'main' into add-tokens-search-discovery-controller
Bigshmow Feb 4, 2025
ccedea8
Merge branch 'main' into add-tokens-search-discovery-controller
Bigshmow Feb 5, 2025
df13db9
Merge branch 'main' into add-tokens-search-discovery-controller
Bigshmow Feb 6, 2025
cd6999c
chore: bump token controller version
Bigshmow Feb 6, 2025
9ad0b53
chore: fix unnecessary format
Bigshmow Feb 6, 2025
f3a936f
chore: import from index where we can
Bigshmow Feb 6, 2025
e580b75
chore: use testing best practices as a guideline
Bigshmow Feb 6, 2025
d46e0c3
add missing discovery service
Bigshmow Feb 6, 2025
d6645dc
prefer imports from index
Bigshmow Feb 6, 2025
3a221ea
Merge branch 'main' into add-tokens-search-discovery-controller
Bigshmow Feb 6, 2025
5c34e89
update yarn lock to use yarnpkg > jfrog
Bigshmow Feb 6, 2025
0b05b30
fix: prevent race conditions by tracking request IDs and ensuring onl…
Bigshmow Feb 6, 2025
f3647e3
add missing deps
Bigshmow Feb 6, 2025
4fa4022
fix: add mock of discovery service for tests
Bigshmow Feb 6, 2025
f8029ec
linter prefers arrow functions
Bigshmow Feb 6, 2025
a746b8d
use memo for better deps tracking
Bigshmow Feb 6, 2025
40b5ecf
remove wrapper and isolate within single scope where we can
Bigshmow Feb 6, 2025
4fff6f0
Merge branch 'main' into add-tokens-search-discovery-controller
Bigshmow Feb 7, 2025
85076cb
Merge branch 'main' into add-tokens-search-discovery-controller
Bigshmow Feb 7, 2025
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { renderHook } from '@testing-library/react-hooks';
import { useSelector } from 'react-redux';
import Engine from '../../../core/Engine';
import useTokenSearchDiscovery from './useTokenSearchDiscovery';

jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn(),
}));

jest.mock('../../../core/Engine', () => ({
context: {
TokenSearchDiscoveryController: {
searchTokens: jest.fn(),
},
},
}));

describe('useTokenSearchDiscovery', () => {
const mockRecentSearches = ['0x123', '0x456'];

beforeEach(() => {
jest.clearAllMocks();
(useSelector as jest.Mock).mockReturnValue(mockRecentSearches);
});

it('should return searchTokens function and recent searches', () => {
const { result } = renderHook(() => useTokenSearchDiscovery());

expect(result.current.searchTokens).toBeDefined();
expect(result.current.recentSearches).toEqual(mockRecentSearches);
});

it('should call TokenSearchDiscoveryController.searchTokens with correct params', async () => {
const mockSearchParams = {
chainId: '0x1',
query: 'DAI',
limit: '10',
};
const mockSearchResult = { tokens: [] };

(
Engine.context.TokenSearchDiscoveryController.searchTokens as jest.Mock
).mockResolvedValueOnce(mockSearchResult);

const { result } = renderHook(() => useTokenSearchDiscovery());
const response = await result.current.searchTokens(mockSearchParams);

expect(
Engine.context.TokenSearchDiscoveryController.searchTokens,
).toHaveBeenCalledWith(mockSearchParams);
expect(response).toEqual(mockSearchResult);
});

it('should handle search errors gracefully', async () => {
const mockError = new Error('Search failed');
(
Engine.context.TokenSearchDiscoveryController.searchTokens as jest.Mock
).mockRejectedValueOnce(mockError);

const { result } = renderHook(() => useTokenSearchDiscovery());

await expect(result.current.searchTokens({})).rejects.toThrow(
'Search failed',
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useCallback } from 'react';
import { useSelector } from 'react-redux';
import Engine from '../../../core/Engine';
import { selectRecentTokenSearches } from '../../../selectors/tokenSearchDiscoveryController';
import { TokenSearchParams } from '@metamask/token-search-discovery-controller/dist/types.cjs';
Bigshmow marked this conversation as resolved.
Show resolved Hide resolved

export const useTokenSearchDiscovery = () => {
Bigshmow marked this conversation as resolved.
Show resolved Hide resolved
const recentSearches = useSelector(selectRecentTokenSearches);

const searchTokens = useCallback(async (params: TokenSearchParams) => {
const { TokenSearchDiscoveryController } = Engine.context;
return await TokenSearchDiscoveryController.searchTokens(params);
}, []);

return {
searchTokens,
recentSearches,
};
};

export default useTokenSearchDiscovery;
13 changes: 13 additions & 0 deletions app/core/Engine/Engine.ts
Bigshmow marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ import {
getGlobalNetworkClientId,
} from '../../util/networks/global-network';
import { logEngineCreation } from './utils/logger';
import { createTokenSearchDiscoveryController } from './controllers/TokenSearchDiscoveryController';

const NON_EMPTY = 'NON_EMPTY';

Expand Down Expand Up @@ -518,6 +519,17 @@ export class Engine {
getMetaMetricsId: () => metaMetricsId ?? '',
});

const tokenSearchDiscoveryController = createTokenSearchDiscoveryController(
{
state: initialState.TokenSearchDiscoveryController,
messenger: this.controllerMessenger.getRestricted({
name: 'TokenSearchDiscoveryController',
allowedActions: [],
allowedEvents: [],
}),
},
);

const phishingController = new PhishingController({
messenger: this.controllerMessenger.getRestricted({
name: 'PhishingController',
Expand Down Expand Up @@ -1445,6 +1457,7 @@ export class Engine {
isDecodeSignatureRequestEnabled: () =>
preferencesController.state.useTransactionSimulations,
}),
TokenSearchDiscoveryController: tokenSearchDiscoveryController,
LoggingController: loggingController,
///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps)
SnapController: this.snapController,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const PORTFOLIO_API_URL = {
dev: 'https://portfolio.dev-api.cx.metamask.io/',
prod: 'https://portfolio.api.cx.metamask.io/',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { createTokenSearchDiscoveryController } from './utils';
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { TokenSearchDiscoveryControllerMessenger } from '@metamask/token-search-discovery-controller/dist/token-search-discovery-controller.cjs';
Bigshmow marked this conversation as resolved.
Show resolved Hide resolved
import { TokenSearchDiscoveryControllerState } from '@metamask/token-search-discovery-controller';

export interface TokenSearchDiscoveryControllerParams {
state?: Partial<TokenSearchDiscoveryControllerState>;
messenger: TokenSearchDiscoveryControllerMessenger;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { createTokenSearchDiscoveryController } from './utils';
import { ControllerMessenger } from '@metamask/base-controller';
import {
TokenSearchDiscoveryControllerMessenger,
TokenSearchDiscoveryControllerState,
} from '@metamask/token-search-discovery-controller/dist/token-search-discovery-controller.cjs';
import Logger from '../../../../util/Logger';
import { TokenSearchApiService } from '@metamask/token-search-discovery-controller';

const mockError = new Error('Controller creation failed');

// Top-level mocks
jest.mock('../../../../util/Logger', () => ({
error: jest.fn(),
}));

jest.mock('@metamask/token-search-discovery-controller', () => ({
TokenSearchApiService: jest.fn(),
TokenSearchDiscoveryController: jest
.fn()
.mockImplementation(function (
this: { state: TokenSearchDiscoveryControllerState },
params: { state?: TokenSearchDiscoveryControllerState },
) {
this.state = {
lastSearchTimestamp: null,
recentSearches: [],
...params.state,
};
return this;
}),
}));

describe('TokenSearchDiscoveryController utils', () => {
let messenger: TokenSearchDiscoveryControllerMessenger;

beforeEach(() => {
messenger =
new ControllerMessenger() as unknown as TokenSearchDiscoveryControllerMessenger;
});

describe('createTokenSearchDiscoveryController', () => {
afterEach(() => {
jest.clearAllMocks();
jest.resetModules();
});

it('creates controller with initial undefined state', () => {
const controller = createTokenSearchDiscoveryController({
state: undefined,
messenger,
});

expect(controller).toBeDefined();
expect(controller.state).toStrictEqual({
lastSearchTimestamp: null,
recentSearches: [],
});
});

it('internal state matches initial state', () => {
const initialState: TokenSearchDiscoveryControllerState = {
lastSearchTimestamp: 123456789,
recentSearches: [
{
tokenAddress: '0x123',
chainId: '0x1',
name: 'Test Token 1',
symbol: 'TEST1',
usdPrice: 1.0,
usdPricePercentChange: {
oneDay: 0.0,
},
},
{
tokenAddress: '0x456',
chainId: '0x1',
name: 'Test Token 2',
symbol: 'TEST2',
usdPrice: 2.0,
usdPricePercentChange: {
oneDay: 0.0,
},
},
],
};

const controller = createTokenSearchDiscoveryController({
state: initialState,
messenger,
});

expect(controller.state).toStrictEqual(initialState);
});

it('controller keeps initial extra data in its state', () => {
const initialState = {
extraData: true,
};

const controller = createTokenSearchDiscoveryController({
// @ts-expect-error giving a wrong initial state
state: initialState,
messenger,
});

expect(controller.state).toStrictEqual({
lastSearchTimestamp: null,
recentSearches: [],
extraData: true,
});
});

it('logs and rethrows error when controller creation fails', () => {
(TokenSearchApiService as jest.Mock).mockImplementation(() => {
throw mockError;
});

expect(() =>
createTokenSearchDiscoveryController({
state: undefined,
messenger,
}),
).toThrow(mockError);

expect(Logger.error).toHaveBeenCalledWith(mockError);
});
});

describe('getPortfolioApiBaseUrl', () => {
const originalEnv = process.env;

beforeEach(() => {
jest.resetModules();
process.env = { ...originalEnv };
});

afterEach(() => {
process.env = originalEnv;
});

it('returns dev URL when environment is local', () => {
process.env.METAMASK_ENVIRONMENT = 'local';
jest.isolateModules(() => {
const { createTokenSearchDiscoveryController: freshCreate } =
jest.requireActual('./utils');
const controller = freshCreate({
state: undefined,
messenger,
});
expect(controller.state).toBeDefined();
});
});

it('returns prod URL when environment is pre-release', () => {
process.env.METAMASK_ENVIRONMENT = 'pre-release';
jest.isolateModules(() => {
const { createTokenSearchDiscoveryController: freshCreate } =
jest.requireActual('./utils');
const controller = freshCreate({
state: undefined,
messenger,
});
expect(controller.state).toBeDefined();
});
});

it('returns prod URL when environment is production', () => {
process.env.METAMASK_ENVIRONMENT = 'production';
jest.isolateModules(() => {
const { createTokenSearchDiscoveryController: freshCreate } =
jest.requireActual('./utils');
const controller = freshCreate({
state: undefined,
messenger,
});
expect(controller.state).toBeDefined();
});
});

it('returns dev URL when environment is not recognized', () => {
process.env.METAMASK_ENVIRONMENT = 'unknown';
jest.isolateModules(() => {
const { createTokenSearchDiscoveryController: freshCreate } =
jest.requireActual('./utils');
const controller = freshCreate({
state: undefined,
messenger,
});
expect(controller.state).toBeDefined();
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Logger from '../../../../util/Logger';
import {
TokenSearchApiService,
TokenSearchDiscoveryController,
} from '@metamask/token-search-discovery-controller';
import { TokenSearchDiscoveryControllerParams } from './types';
import { PORTFOLIO_API_URL } from './constants';

const getPortfolioApiBaseUrl = () => {
const env = process.env.METAMASK_ENVIRONMENT;
switch (env) {
case 'local':
return PORTFOLIO_API_URL.dev;
case 'pre-release':
case 'production':
return PORTFOLIO_API_URL.prod;
default:
return PORTFOLIO_API_URL.dev;
}
};

export const createTokenSearchDiscoveryController = ({
state,
messenger,
}: TokenSearchDiscoveryControllerParams) => {
try {
const controller = new TokenSearchDiscoveryController({
state,
messenger,
tokenSearchService: new TokenSearchApiService(getPortfolioApiBaseUrl()),
});
return controller;
} catch (error) {
Logger.error(error as Error);
throw error;
}
};
6 changes: 6 additions & 0 deletions app/core/Engine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ import {
RemoteFeatureFlagControllerActions,
RemoteFeatureFlagControllerEvents,
} from '@metamask/remote-feature-flag-controller/dist/remote-feature-flag-controller.cjs';
import {
TokenSearchDiscoveryController,
TokenSearchDiscoveryControllerState,
} from '@metamask/token-search-discovery-controller';

/**
* Controllers that area always instantiated
Expand Down Expand Up @@ -323,6 +327,7 @@ export type Controllers = {
TokenListController: TokenListController;
TokenDetectionController: TokenDetectionController;
TokenRatesController: TokenRatesController;
TokenSearchDiscoveryController: TokenSearchDiscoveryController;
TokensController: TokensController;
TransactionController: TransactionController;
SmartTransactionsController: SmartTransactionsController;
Expand Down Expand Up @@ -363,6 +368,7 @@ export type EngineState = {
PhishingController: PhishingControllerState;
TokenBalancesController: TokenBalancesControllerState;
TokenRatesController: TokenRatesControllerState;
TokenSearchDiscoveryController: TokenSearchDiscoveryControllerState;
TransactionController: TransactionControllerState;
SmartTransactionsController: SmartTransactionsControllerState;
SwapsController: SwapsControllerState;
Expand Down
Loading
Loading