Skip to content

Commit 6172557

Browse files
committed
Merge branch 'main' into cc/feat/snap-account-service-snap-rpc-methods
2 parents 94b00b0 + 9047286 commit 6172557

50 files changed

Lines changed: 2248 additions & 3660 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@metamask/core-monorepo",
3-
"version": "1097.0.0",
3+
"version": "1101.0.0",
44
"private": true,
55
"description": "Monorepo for packages shared between MetaMask clients",
66
"repository": {

packages/ai-controllers/CHANGELOG.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.8.0]
11+
12+
### Added
13+
14+
- Add `AiDigestController.fetchFrontPageItem(id)` (and the underlying `AiDigestService.fetchFrontPageItem`) which fetches a single market overview "front page" by id from `GET /market-overview/front-page/:id`, exposed via the `AiDigestController:fetchFrontPageItem` messenger action ([#9394](https://github.com/MetaMask/core/pull/9394))
15+
- Add `MarketOverviewFrontPage` and `MarketOverviewItem` types ([#9394](https://github.com/MetaMask/core/pull/9394))
16+
1017
### Changed
1118

1219
- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074))
@@ -112,7 +119,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
112119
- Removes `fetchDigest`, `clearDigest`, and `clearAllDigests` actions from the controller action surface.
113120
- Removes `DigestData`/`DigestEntry` types and the `digests` state branch.
114121

115-
[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/ai-controllers@0.7.0...HEAD
122+
[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/ai-controllers@0.8.0...HEAD
123+
[0.8.0]: https://github.com/MetaMask/core/compare/@metamask/ai-controllers@0.7.0...@metamask/ai-controllers@0.8.0
116124
[0.7.0]: https://github.com/MetaMask/core/compare/@metamask/ai-controllers@0.6.3...@metamask/ai-controllers@0.7.0
117125
[0.6.3]: https://github.com/MetaMask/core/compare/@metamask/ai-controllers@0.6.2...@metamask/ai-controllers@0.6.3
118126
[0.6.2]: https://github.com/MetaMask/core/compare/@metamask/ai-controllers@0.6.1...@metamask/ai-controllers@0.6.2

packages/ai-controllers/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@metamask/ai-controllers",
3-
"version": "0.7.0",
3+
"version": "0.8.0",
44
"description": "A collection of AI-related controllers",
55
"keywords": [
66
"Ethereum",

packages/ai-controllers/src/AiDigestController-method-action-types.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,25 @@ export type AiDigestControllerFetchMarketOverviewAction = {
3232
handler: AiDigestController['fetchMarketOverview'];
3333
};
3434

35+
/**
36+
* Fetches a single market overview front page by id.
37+
*
38+
* Unlike the market overview report (which only returns the latest items),
39+
* this resolves an older item that has since dropped out of the report, so
40+
* clients can render it directly (e.g. from a deep link).
41+
*
42+
* @param id - The front-page identifier (UUID).
43+
* @returns The market overview front page, or `null` if none exists.
44+
*/
45+
export type AiDigestControllerFetchFrontPageItemAction = {
46+
type: `AiDigestController:fetchFrontPageItem`;
47+
handler: AiDigestController['fetchFrontPageItem'];
48+
};
49+
3550
/**
3651
* Union of all AiDigestController action types.
3752
*/
3853
export type AiDigestControllerMethodActions =
3954
| AiDigestControllerFetchMarketInsightsAction
40-
| AiDigestControllerFetchMarketOverviewAction;
55+
| AiDigestControllerFetchMarketOverviewAction
56+
| AiDigestControllerFetchFrontPageItemAction;

packages/ai-controllers/src/AiDigestController.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
DigestService,
1313
MarketInsightsReport,
1414
MarketOverview,
15+
MarketOverviewFrontPage,
1516
} from '.';
1617

1718
const mockReport: MarketInsightsReport = {
@@ -30,6 +31,21 @@ const mockOverview: MarketOverview = {
3031
trends: [],
3132
};
3233

34+
const mockFrontPage: MarketOverviewFrontPage = {
35+
id: 'a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
36+
item: {
37+
title: 'Institutional adoption',
38+
description: 'Institutional players continue accumulating.',
39+
category: 'macro',
40+
impact: 'positive',
41+
articles: [],
42+
relatedAssets: [],
43+
},
44+
ctaTitle: 'Majors steady as volatility cools',
45+
ctaDescription: 'Bitcoin and Ethereum held firm as funding rates normalized.',
46+
createdAt: '2026-02-11T10:32:52.403Z',
47+
};
48+
3349
const createMessenger = (): AiDigestControllerMessenger =>
3450
new Messenger({
3551
namespace: 'AiDigestController',
@@ -38,6 +54,7 @@ const createMessenger = (): AiDigestControllerMessenger =>
3854
const createService = (overrides?: Partial<DigestService>): DigestService => ({
3955
searchDigest: jest.fn().mockResolvedValue(mockReport),
4056
fetchMarketOverview: jest.fn().mockResolvedValue(mockOverview),
57+
fetchFrontPageItem: jest.fn().mockResolvedValue(mockFrontPage),
4158
...overrides,
4259
});
4360

@@ -262,3 +279,65 @@ describe('AiDigestController (market overview)', () => {
262279
expect(controller.state.marketOverview).toBeNull();
263280
});
264281
});
282+
283+
describe('AiDigestController (front page)', () => {
284+
it('registers fetchFrontPageItem action on messenger', async () => {
285+
const digestService = createService();
286+
const messenger = createMessenger();
287+
const controller = new AiDigestController({ messenger, digestService });
288+
289+
const result = await messenger.call(
290+
'AiDigestController:fetchFrontPageItem',
291+
mockFrontPage.id,
292+
);
293+
294+
expect(result).toStrictEqual(mockFrontPage);
295+
expect(digestService.fetchFrontPageItem).toHaveBeenCalledWith(
296+
mockFrontPage.id,
297+
);
298+
// The front page is not cached, so controller state is left untouched.
299+
expect(controller.state).toStrictEqual({
300+
marketInsights: {},
301+
marketOverview: null,
302+
});
303+
});
304+
305+
it('delegates to the service and returns the front page', async () => {
306+
const digestService = createService();
307+
const controller = new AiDigestController({
308+
messenger: createMessenger(),
309+
digestService,
310+
});
311+
312+
const result = await controller.fetchFrontPageItem(mockFrontPage.id);
313+
314+
expect(result).toStrictEqual(mockFrontPage);
315+
});
316+
317+
it('returns null when the service returns null', async () => {
318+
const digestService = createService({
319+
fetchFrontPageItem: jest.fn().mockResolvedValue(null),
320+
});
321+
const controller = new AiDigestController({
322+
messenger: createMessenger(),
323+
digestService,
324+
});
325+
326+
const result = await controller.fetchFrontPageItem(mockFrontPage.id);
327+
328+
expect(result).toBeNull();
329+
});
330+
331+
it('throws for an empty id without calling the service', async () => {
332+
const digestService = createService();
333+
const controller = new AiDigestController({
334+
messenger: createMessenger(),
335+
digestService,
336+
});
337+
338+
await expect(controller.fetchFrontPageItem('')).rejects.toThrow(
339+
AiDigestControllerErrorMessage.INVALID_FRONT_PAGE_ID,
340+
);
341+
expect(digestService.fetchFrontPageItem).not.toHaveBeenCalled();
342+
});
343+
});

packages/ai-controllers/src/AiDigestController.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
MarketInsightsEntry,
2020
MarketOverview,
2121
MarketOverviewEntry,
22+
MarketOverviewFrontPage,
2223
} from './ai-digest-types';
2324
import type { AiDigestControllerMethodActions } from './AiDigestController-method-action-types';
2425

@@ -75,6 +76,7 @@ const aiDigestControllerMetadata: StateMetadata<AiDigestControllerState> = {
7576
const MESSENGER_EXPOSED_METHODS = [
7677
'fetchMarketInsights',
7778
'fetchMarketOverview',
79+
'fetchFrontPageItem',
7880
] as const;
7981

8082
export class AiDigestController extends BaseController<
@@ -188,6 +190,26 @@ export class AiDigestController extends BaseController<
188190
return data;
189191
}
190192

193+
/**
194+
* Fetches a single market overview front page by id.
195+
*
196+
* Unlike the market overview report (which only returns the latest items),
197+
* this resolves an older item that has since dropped out of the report, so
198+
* clients can render it directly (e.g. from a deep link).
199+
*
200+
* @param id - The front-page identifier (UUID).
201+
* @returns The market overview front page, or `null` if none exists.
202+
*/
203+
async fetchFrontPageItem(
204+
id: string,
205+
): Promise<MarketOverviewFrontPage | null> {
206+
if (!id) {
207+
throw new Error(AiDigestControllerErrorMessage.INVALID_FRONT_PAGE_ID);
208+
}
209+
210+
return this.#digestService.fetchFrontPageItem(id);
211+
}
212+
191213
/**
192214
* Evicts stale (TTL expired) and oldest entries (FIFO) if cache exceeds max size.
193215
*

packages/ai-controllers/src/AiDigestService.test.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,4 +895,156 @@ describe('AiDigestService', () => {
895895
expect(result).toStrictEqual(withExtras);
896896
});
897897
});
898+
899+
describe('fetchFrontPageItem', () => {
900+
const mockRelatedAsset = {
901+
name: 'Bitcoin',
902+
symbol: 'BTC',
903+
caip19: ['bip122:000000000019d6689c085ae165831e93/slip44:0'],
904+
sourceAssetId: 'bitcoin',
905+
hlPerpsMarket: ['BTC'],
906+
};
907+
908+
const mockFrontPage = {
909+
id: 'a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
910+
item: {
911+
title: 'Institutional adoption',
912+
description: 'Institutional players continue accumulating.',
913+
category: 'macro',
914+
impact: 'positive',
915+
articles: [
916+
{
917+
title: 'Crypto adoption rises',
918+
url: 'https://example.com/crypto-adoption',
919+
source: 'example.com',
920+
date: '2026-02-16',
921+
},
922+
],
923+
relatedAssets: [mockRelatedAsset],
924+
},
925+
ctaTitle: 'Majors steady as volatility cools',
926+
ctaDescription:
927+
'Bitcoin and Ethereum held firm as funding rates normalized.',
928+
createdAt: '2026-02-16T10:00:00.000Z',
929+
};
930+
931+
it('fetches a front page item from the correct endpoint', async () => {
932+
mockFetch.mockResolvedValue({
933+
ok: true,
934+
status: 200,
935+
json: () => Promise.resolve(mockFrontPage),
936+
});
937+
938+
const service = new AiDigestService({
939+
baseUrl: 'http://test.com/api/v1',
940+
});
941+
const result = await service.fetchFrontPageItem(mockFrontPage.id);
942+
943+
expect(result).toStrictEqual(mockFrontPage);
944+
expect(mockFetch).toHaveBeenCalledWith(
945+
`http://test.com/api/v1/market-overview/front-page/${mockFrontPage.id}`,
946+
);
947+
});
948+
949+
it('url-encodes the id in the request path', async () => {
950+
mockFetch.mockResolvedValue({
951+
ok: true,
952+
status: 200,
953+
json: () => Promise.resolve(mockFrontPage),
954+
});
955+
956+
const service = new AiDigestService({
957+
baseUrl: 'http://test.com/api/v1',
958+
});
959+
await service.fetchFrontPageItem('a b/c');
960+
961+
expect(mockFetch).toHaveBeenCalledWith(
962+
'http://test.com/api/v1/market-overview/front-page/a%20b%2Fc',
963+
);
964+
});
965+
966+
it('returns null when API returns 404', async () => {
967+
mockFetch.mockResolvedValue({ ok: false, status: 404 });
968+
969+
const service = new AiDigestService({
970+
baseUrl: 'http://test.com/api/v1',
971+
});
972+
const result = await service.fetchFrontPageItem(mockFrontPage.id);
973+
974+
expect(result).toBeNull();
975+
});
976+
977+
it('throws on non-404 non-ok response', async () => {
978+
mockFetch.mockResolvedValue({ ok: false, status: 500 });
979+
980+
const service = new AiDigestService({
981+
baseUrl: 'http://test.com/api/v1',
982+
});
983+
984+
await expect(
985+
service.fetchFrontPageItem(mockFrontPage.id),
986+
).rejects.toThrow('API request failed: 500');
987+
});
988+
989+
it('throws when the item schema is invalid', async () => {
990+
mockFetch.mockResolvedValue({
991+
ok: true,
992+
status: 200,
993+
json: () =>
994+
Promise.resolve({ ...mockFrontPage, item: 'not-an-object' }),
995+
});
996+
997+
const service = new AiDigestService({
998+
baseUrl: 'http://test.com/api/v1',
999+
});
1000+
1001+
await expect(
1002+
service.fetchFrontPageItem(mockFrontPage.id),
1003+
).rejects.toThrow(AiDigestControllerErrorMessage.API_INVALID_RESPONSE);
1004+
});
1005+
1006+
it('throws when ctaTitle is missing', async () => {
1007+
const { ctaTitle: _ctaTitle, ...withoutCtaTitle } = mockFrontPage;
1008+
1009+
mockFetch.mockResolvedValue({
1010+
ok: true,
1011+
status: 200,
1012+
json: () => Promise.resolve(withoutCtaTitle),
1013+
});
1014+
1015+
const service = new AiDigestService({
1016+
baseUrl: 'http://test.com/api/v1',
1017+
});
1018+
1019+
await expect(
1020+
service.fetchFrontPageItem(mockFrontPage.id),
1021+
).rejects.toThrow(AiDigestControllerErrorMessage.API_INVALID_RESPONSE);
1022+
});
1023+
1024+
it('normalises missing caip19 on the item to []', async () => {
1025+
const perpsOnlyAsset = {
1026+
name: 'ETHFI',
1027+
symbol: 'ETHFI',
1028+
sourceAssetId: '',
1029+
hlPerpsMarket: ['ETHFI'],
1030+
};
1031+
1032+
mockFetch.mockResolvedValue({
1033+
ok: true,
1034+
status: 200,
1035+
json: () =>
1036+
Promise.resolve({
1037+
...mockFrontPage,
1038+
item: { ...mockFrontPage.item, relatedAssets: [perpsOnlyAsset] },
1039+
}),
1040+
});
1041+
1042+
const service = new AiDigestService({
1043+
baseUrl: 'http://test.com/api/v1',
1044+
});
1045+
const result = await service.fetchFrontPageItem(mockFrontPage.id);
1046+
1047+
expect(result?.item.relatedAssets[0].caip19).toStrictEqual([]);
1048+
});
1049+
});
8981050
});

0 commit comments

Comments
 (0)