Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
4758e65
refactor: migrate PhishingController data fetching to PhishingDataSer…
0xOhm Aug 17, 2026
5b293ee
Merge branch 'main' into refactor/psafe-593-phishing-data-service
0xOhm Aug 26, 2026
236ea8e
chore: update generated README and formatting
adonesky1 Aug 28, 2026
1113eae
Merge origin/main into refactor/psafe-593-phishing-data-service
adonesky1 Sep 1, 2026
67a3fa8
chore: link phishing-controller changelog entries to #9914
adonesky1 Sep 1, 2026
83d1c23
fix: address review findings in PhishingDataService
adonesky1 Sep 1, 2026
8987113
chore: apply oxfmt formatting
adonesky1 Sep 1, 2026
1cf7cc7
fix: validate inside the query function rather than via responseStruct
adonesky1 Sep 1, 2026
a4c6746
Merge remote-tracking branch 'origin/main' into refactor/psafe-593-ph…
adonesky1 Sep 9, 2026
68646de
fix: preserve successful bulk token scans
adonesky1 Sep 9, 2026
e4df833
fix: abort timed-out phishing requests
adonesky1 Sep 9, 2026
a465286
fix: validate phishing API response fields
adonesky1 Sep 9, 2026
90db8dc
fix: wait for data service cache initialization
adonesky1 Sep 9, 2026
87d127c
fix: bound rehydrated phishing query lifetime
adonesky1 Sep 9, 2026
7c1c20c
test: remove obsolete phishing cache state
adonesky1 Sep 9, 2026
b23238a
docs: update data service changelogs
adonesky1 Sep 9, 2026
3478d00
test: avoid leaking timed-out HTTP requests
adonesky1 Sep 9, 2026
a8bfc6b
fix: preserve uninitialized query timing
adonesky1 Sep 9, 2026
176dd21
fix: enforce phishing query retention bound
adonesky1 Sep 9, 2026
0054126
fix: tighten phishing response discriminants
adonesky1 Sep 9, 2026
a5bd89a
fix: cancel pending phishing list requests
adonesky1 Sep 9, 2026
a905f5c
test: cover initialized infinite queries
adonesky1 Sep 9, 2026
4a8f8e1
fix: accept documented security alert verdicts
adonesky1 Sep 9, 2026
97d0161
fix: avoid caching URL scan fetch errors
adonesky1 Sep 9, 2026
21f97dd
fix: abort requests when phishing service is destroyed
adonesky1 Sep 10, 2026
e9242e8
fix: apply service policy to approval requests
adonesky1 Sep 10, 2026
53df4e3
fix: enforce phishing service return contracts
adonesky1 Sep 10, 2026
dec0fb7
fix: bound data service cache rehydration and validate persisted caches
adonesky1 Sep 10, 2026
723029d
fix: ignore hotlist diffs for unrecognized list types
adonesky1 Sep 10, 2026
9acaa9f
fix: validate phishing responses per entry instead of per response
adonesky1 Sep 10, 2026
acd27f1
fix: start data service queries synchronously when init was not called
adonesky1 Sep 10, 2026
d4c0676
fix: normalize EVM addresses in phishing scan requests and cache keys
adonesky1 Sep 10, 2026
62f02d1
fix: retry batched phishing requests as a whole and validate rehydrat…
adonesky1 Sep 10, 2026
a9d3e6d
docs: describe phishing data service validation and retry changes
adonesky1 Sep 10, 2026
d04bcaa
refactor: fetch approvals through the query cache and drop redundant …
adonesky1 Sep 11, 2026
337bbda
docs: consolidate the phishing-controller changelog
adonesky1 Sep 11, 2026
81378d3
Merge remote-tracking branch 'origin/main' into pr9914-review-fixes
adonesky1 Sep 11, 2026
2c421e3
refactor: expose the data service policy instead of wrapping execute
adonesky1 Sep 11, 2026
8d5a1c9
docs: explain the per-request abort controller in PhishingDataService
adonesky1 Sep 11, 2026
c6bd755
refactor: batch scan lookups with a single deferred flush
adonesky1 Sep 11, 2026
5f89fc1
fix: share the bounded rehydration wait across queries
adonesky1 Sep 11, 2026
cff89ad
docs: note that batched requests carry no per-query abort signal
adonesky1 Sep 11, 2026
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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -597,8 +597,10 @@ linkStyle default opacity:0.5
perps_controller --> transaction_controller;
phishing_controller --> address_book_controller;
phishing_controller --> base_controller;
phishing_controller --> base_data_service;
phishing_controller --> controller_utils;
phishing_controller --> messenger;
phishing_controller --> storage_service;
phishing_controller --> transaction_controller;
platform_api_docs --> utils;
polling_controller --> base_controller;
Expand Down
17 changes: 2 additions & 15 deletions eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -1326,27 +1326,14 @@
"count": 1
}
},
"packages/phishing-controller/src/CacheManager.test.ts": {
"@typescript-eslint/explicit-function-return-type": {
"count": 2
}
},
"packages/phishing-controller/src/CacheManager.ts": {
"@typescript-eslint/naming-convention": {
"count": 3
}
},
"packages/phishing-controller/src/PathTrie.ts": {
"@typescript-eslint/explicit-function-return-type": {
"count": 2
}
},
"packages/phishing-controller/src/PhishingController.ts": {
"@typescript-eslint/explicit-function-return-type": {
"count": 14
},
"@typescript-eslint/naming-convention": {
"count": 1
"count": 11
},
"@typescript-eslint/prefer-nullish-coalescing": {
"count": 6
Expand Down Expand Up @@ -1377,7 +1364,7 @@
},
"packages/phishing-controller/src/utils.ts": {
"@typescript-eslint/explicit-function-return-type": {
"count": 5
"count": 4
},
"@typescript-eslint/prefer-nullish-coalescing": {
"count": 1
Expand Down
11 changes: 11 additions & 0 deletions packages/base-data-service/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add a protected `policy` getter exposing the service's retry and circuit-breaker policy, so subclasses can run uncached requests under the same policy and observe its `onBreak`, `onDegraded`, and `onRetry` events ([#9914](https://github.com/MetaMask/core/pull/9914))
- Add `hydrationTimeout` and `shouldHydrateQuery` options to `PersistenceConfiguration`, and export `DEFAULT_HYDRATION_TIMEOUT` ([#9914](https://github.com/MetaMask/core/pull/9914))
- `hydrationTimeout` bounds how long queries wait for cache rehydration after `init` (default 1 second, measured once from the first waiting query), and `shouldHydrateQuery` filters persisted queries before they are restored into the cache

### Changed

- Bump `@metamask/utils` from `^11.12.0` to `^12.0.0` ([#10192](https://github.com/MetaMask/core/pull/10192))

### Fixed

- Wait for cache rehydration to finish before starting a query when `init` has been called, preventing persisted results from racing the first network request; the wait is bounded by `hydrationTimeout` so that a slow or hung storage read cannot block queries ([#9914](https://github.com/MetaMask/core/pull/9914))
- Discard persisted caches that fail shape validation instead of attempting to hydrate them ([#9914](https://github.com/MetaMask/core/pull/9914))

## [2.0.0]

### Changed
Expand Down
226 changes: 225 additions & 1 deletion packages/base-data-service/src/BaseDataService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ import {
TRANSACTIONS_PAGE_2_CURSOR,
TRANSACTIONS_PAGE_3_CURSOR,
} from '../tests/mocks.js';
import { STORAGE_SERVICE_KEY } from './BaseDataService.js';
import {
DEFAULT_HYDRATION_TIMEOUT,
STORAGE_SERVICE_KEY,
} from './BaseDataService.js';

const TEST_ADDRESS = '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520';

Expand Down Expand Up @@ -354,6 +357,19 @@ describe('BaseDataService', () => {
});
});

describe('policy', () => {
it('exposes the service policy to subclasses', async () => {
const service = new ExampleDataService(createServiceMessenger());
const policy = service.getPolicy();

expect(await policy.execute(() => 'ok')).toBe('ok');
expect(typeof policy.onBreak).toBe('function');
expect(typeof policy.onDegraded).toBe('function');

service.destroy();
});
});

describe('persistence', () => {
it('persists the cache using the StorageService', async () => {
const setItem = jest.fn();
Expand Down Expand Up @@ -506,6 +522,214 @@ describe('BaseDataService', () => {
expect(getItem).toHaveBeenCalledWith(serviceName, STORAGE_SERVICE_KEY);
});

it('waits for cache initialization before fetching a query', async () => {
cleanAll();
const networkScope = mockAssets();
const activityScope = mockTransactionsPage1();
let resolveGetItem: ((value: { result: null }) => void) | undefined;
const getItem = jest.fn(
() =>
new Promise<{ result: null }>((resolve) => {
resolveGetItem = resolve;
}),
);
const rootMessenger = createRootMessenger({
actionHandlers: {
'StorageService:getItem': getItem,
},
});
const messenger = createServiceMessenger(rootMessenger);
const service = new ExampleDataService(messenger);

service.init();
const resultPromise = service.getAssets(MOCK_ASSETS);
await new Promise(setImmediate);

expect(networkScope.isDone()).toBe(false);
resolveGetItem?.({ result: null });
expect(await resultPromise).toHaveLength(3);
expect(networkScope.isDone()).toBe(true);

expect(await service.getActivity(TEST_ADDRESS)).toHaveProperty('data');
expect(activityScope.isDone()).toBe(true);

service.destroy();
});

it('proceeds with a query when rehydration exceeds the hydration timeout', async () => {
cleanAll();
const networkScope = mockAssets();
const getItem = jest.fn(
() => new Promise<{ result: null }>(() => undefined),
);
const rootMessenger = createRootMessenger({
actionHandlers: {
'StorageService:getItem': getItem,
},
});
const messenger = createServiceMessenger(rootMessenger);
const service = new ExampleDataService(messenger);

service.init();
const resultPromise = service.getAssets(MOCK_ASSETS);
await new Promise(setImmediate);
expect(networkScope.isDone()).toBe(false);

jest.advanceTimersByTime(DEFAULT_HYDRATION_TIMEOUT);

expect(await resultPromise).toHaveLength(3);
expect(networkScope.isDone()).toBe(true);

service.destroy();
});

it('does not wait again once the hydration timeout has elapsed', async () => {
cleanAll();
const networkScope = mockAssets();
const getItem = jest.fn(
() => new Promise<{ result: null }>(() => undefined),
);
const rootMessenger = createRootMessenger({
actionHandlers: {
'StorageService:getItem': getItem,
},
});
const messenger = createServiceMessenger(rootMessenger);
const service = new ExampleDataService(messenger);

service.init();
const first = service.getAssets(MOCK_ASSETS);
await new Promise(setImmediate);
jest.advanceTimersByTime(DEFAULT_HYDRATION_TIMEOUT);
expect(await first).toHaveLength(3);
expect(networkScope.isDone()).toBe(true);

// A later query must not start a fresh wait while storage still hangs.
const secondScope = mockAssets();
await service.invalidateQueries({
queryKey: ['ExampleDataService:getAssets', MOCK_ASSETS],
});
expect(await service.getAssets(MOCK_ASSETS)).toHaveLength(3);
expect(secondScope.isDone()).toBe(true);

service.destroy();
});

it('honors a custom hydrationTimeout', async () => {
cleanAll();
const networkScope = mockAssets();
const getItem = jest.fn(
() => new Promise<{ result: null }>(() => undefined),
);
const rootMessenger = createRootMessenger({
actionHandlers: {
'StorageService:getItem': getItem,
},
});
const messenger = createServiceMessenger(rootMessenger);
const service = new ExampleDataService(messenger, {
persistenceConfig: { maxAge: 1000, hydrationTimeout: 50 },
});

service.init();
const resultPromise = service.getAssets(MOCK_ASSETS);
await new Promise(setImmediate);
expect(networkScope.isDone()).toBe(false);

jest.advanceTimersByTime(50);

expect(await resultPromise).toHaveLength(3);
expect(networkScope.isDone()).toBe(true);

service.destroy();
});

it('discards a persisted cache that fails shape validation', async () => {
const getItem = jest.fn().mockResolvedValue({
result: { state: { queries: 'not-an-array' } },
});
const removeItem = jest.fn();
const rootMessenger = createRootMessenger({
actionHandlers: {
'StorageService:getItem': getItem,
'StorageService:removeItem': removeItem,
},
});
const messenger = createServiceMessenger(rootMessenger);
const publishSpy = jest.spyOn(messenger, 'publish');
const service = new ExampleDataService(messenger);

service.init();
await new Promise(setImmediate);

expect(removeItem).toHaveBeenCalledWith(serviceName, STORAGE_SERVICE_KEY);
expect(publishSpy).not.toHaveBeenCalled();
});

it('skips persisted queries rejected by shouldHydrateQuery', async () => {
cleanAll();
const networkScope = mockAssets();
const getItem = jest.fn().mockResolvedValue({
result: {
state: {
queries: [
{
queryHash: hashKey([
'ExampleDataService:getAssets',
MOCK_ASSETS,
]),
queryKey: ['ExampleDataService:getAssets', MOCK_ASSETS],
state: {
data: [
{
assetId: 'eip155:1/slip44:60',
decimals: 18,
name: 'Ethereum',
symbol: 'ETH',
},
],
dataUpdateCount: 1,
dataUpdatedAt: Date.now(),
error: null,
errorUpdateCount: 0,
errorUpdatedAt: 0,
fetchFailureCount: 0,
fetchFailureReason: null,
fetchMeta: null,
fetchStatus: 'idle',
isInvalidated: false,
status: 'success',
},
},
],
mutations: [],
},
timestamp: Date.now(),
},
});
const rootMessenger = createRootMessenger({
actionHandlers: {
'StorageService:getItem': getItem,
},
});
const messenger = createServiceMessenger(rootMessenger);
const shouldHydrateQuery = jest.fn(() => false);
const service = new ExampleDataService(messenger, {
persistenceConfig: { maxAge: 1000, shouldHydrateQuery },
});

service.init();
await new Promise(setImmediate);

const result = await service.getAssets(MOCK_ASSETS);

expect(shouldHydrateQuery).toHaveBeenCalledTimes(1);
expect(result).toHaveLength(3);
expect(networkScope.isDone()).toBe(true);

service.destroy();
});

it('discards the cache if it has expired', async () => {
const getItem = jest.fn().mockResolvedValue({
result: {
Expand Down
Loading
Loading