From 9435f1f6f493e9c808fdfbb2cefc303dde785163 Mon Sep 17 00:00:00 2001 From: Antonio Regadas Date: Thu, 28 May 2026 11:24:49 +0100 Subject: [PATCH 1/5] chore: relax type --- packages/ai-controllers/CHANGELOG.md | 6 + .../src/AiDigestService.test.ts | 165 +++++++++++++++++- .../ai-controllers/src/AiDigestService.ts | 27 +-- .../ai-controllers/src/ai-digest-types.ts | 16 +- 4 files changed, 196 insertions(+), 18 deletions(-) diff --git a/packages/ai-controllers/CHANGELOG.md b/packages/ai-controllers/CHANGELOG.md index d09b75317e..4af7a8084d 100644 --- a/packages/ai-controllers/CHANGELOG.md +++ b/packages/ai-controllers/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- `AiDigestService`: do not reject the entire market overview when individual `relatedAsset` entries are missing non-essential fields (`name`, `sourceAssetId`); assets missing `symbol` are filtered out, and trends with zero valid assets are removed ([#TBD](https://github.com/MetaMask/core/pull/TBD)). + - `RelatedAsset.name` is now optional (`string | undefined`); clients should fall back to `symbol` when absent. + - `RelatedAsset.sourceAssetId` is now optional (`string | undefined`); clients must not use it as a sole React key. + ### Changed - Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) diff --git a/packages/ai-controllers/src/AiDigestService.test.ts b/packages/ai-controllers/src/AiDigestService.test.ts index 780036ef67..2d471354e1 100644 --- a/packages/ai-controllers/src/AiDigestService.test.ts +++ b/packages/ai-controllers/src/AiDigestService.test.ts @@ -784,7 +784,7 @@ describe('AiDigestService', () => { trends: [ { ...mockMarketOverview.trends[0], - relatedAssets: [{ name: 'Bitcoin' }], // missing required fields + relatedAssets: [{ name: 'Bitcoin' }], // missing required symbol }, ], }), @@ -799,6 +799,169 @@ describe('AiDigestService', () => { ); }); + it('returns overview when an asset is missing sourceAssetId', async () => { + const assetWithoutSourceId = { + name: 'Bitcoin', + symbol: 'BTC', + caip19: ['bip122:000000000019d6689c085ae165831e93/slip44:0'], + hlPerpsMarket: ['BTC'], + // sourceAssetId intentionally absent + }; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + ...mockMarketOverview, + trends: [ + { + ...mockMarketOverview.trends[0], + relatedAssets: [assetWithoutSourceId], + }, + ], + }), + }); + + const service = new AiDigestService({ + baseUrl: 'http://test.com/api/v1', + }); + const result = await service.fetchMarketOverview(); + + expect(result?.trends).toHaveLength(1); + expect(result?.trends[0].relatedAssets).toHaveLength(1); + expect(result?.trends[0].relatedAssets[0].symbol).toBe('BTC'); + expect(result?.trends[0].relatedAssets[0].sourceAssetId).toBeUndefined(); + }); + + it('returns overview when an asset is missing name', async () => { + const assetWithoutName = { + symbol: 'ETH', + sourceAssetId: 'ethereum', + caip19: ['eip155:1/slip44:60'], + hlPerpsMarket: ['ETH'], + // name intentionally absent + }; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + ...mockMarketOverview, + trends: [ + { + ...mockMarketOverview.trends[0], + relatedAssets: [assetWithoutName], + }, + ], + }), + }); + + const service = new AiDigestService({ + baseUrl: 'http://test.com/api/v1', + }); + const result = await service.fetchMarketOverview(); + + expect(result?.trends).toHaveLength(1); + expect(result?.trends[0].relatedAssets).toHaveLength(1); + expect(result?.trends[0].relatedAssets[0].symbol).toBe('ETH'); + expect(result?.trends[0].relatedAssets[0].name).toBeUndefined(); + }); + + it('strips assets with empty symbol and retains the trend when other valid assets remain', async () => { + const validAsset = { + name: 'Bitcoin', + symbol: 'BTC', + sourceAssetId: 'bitcoin', + }; + const emptySymbolAsset = { + name: 'Bad', + symbol: '', + sourceAssetId: 'bad', + }; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + ...mockMarketOverview, + trends: [ + { + ...mockMarketOverview.trends[0], + relatedAssets: [emptySymbolAsset, validAsset], + }, + ], + }), + }); + + const service = new AiDigestService({ + baseUrl: 'http://test.com/api/v1', + }); + const result = await service.fetchMarketOverview(); + + expect(result?.trends).toHaveLength(1); + expect(result?.trends[0].relatedAssets).toHaveLength(1); + expect(result?.trends[0].relatedAssets[0].symbol).toBe('BTC'); + }); + + it('drops a trend whose every asset has an empty symbol, but retains other trends', async () => { + const goodTrend = { + ...mockMarketOverview.trends[0], + title: 'Good trend', + relatedAssets: [{ name: 'Bitcoin', symbol: 'BTC', sourceAssetId: 'bitcoin' }], + }; + const badTrend = { + ...mockMarketOverview.trends[0], + title: 'Bad trend', + relatedAssets: [{ name: 'Bad', symbol: '', sourceAssetId: 'bad' }], + }; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + ...mockMarketOverview, + trends: [goodTrend, badTrend], + }), + }); + + const service = new AiDigestService({ + baseUrl: 'http://test.com/api/v1', + }); + const result = await service.fetchMarketOverview(); + + expect(result?.trends).toHaveLength(1); + expect(result?.trends[0].title).toBe('Good trend'); + }); + + it('returns an overview with empty trends array when every trend is filtered out', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + ...mockMarketOverview, + trends: [ + { + ...mockMarketOverview.trends[0], + relatedAssets: [{ name: 'Bad', symbol: '', sourceAssetId: 'bad' }], + }, + ], + }), + }); + + const service = new AiDigestService({ + baseUrl: 'http://test.com/api/v1', + }); + const result = await service.fetchMarketOverview(); + + expect(result).not.toBeNull(); + expect(result?.trends).toStrictEqual([]); + }); + it('accepts additional unknown fields in payload', async () => { const withExtras = { ...mockMarketOverview, diff --git a/packages/ai-controllers/src/AiDigestService.ts b/packages/ai-controllers/src/AiDigestService.ts index 022ef0fb63..ebcb17698b 100644 --- a/packages/ai-controllers/src/AiDigestService.ts +++ b/packages/ai-controllers/src/AiDigestService.ts @@ -86,10 +86,10 @@ const MarketInsightsDigestEnvelopeStruct = structType({ // Market Overview structs const RelatedAssetStruct = structType({ - name: string(), + name: optional(string()), symbol: string(), caip19: optional(array(string())), - sourceAssetId: string(), + sourceAssetId: optional(string()), hlPerpsMarket: optional(array(string())), }); @@ -113,24 +113,27 @@ const MarketOverviewReportEnvelopeStruct = structType({ report: MarketOverviewStruct, }); -const normalizeRelatedAssets = (raw: MarketOverview): MarketOverview => ({ +const filterAndNormalizeRelatedAssets = ( + raw: MarketOverview, +): MarketOverview => ({ ...raw, - trends: raw.trends.map((trend) => ({ - ...trend, - relatedAssets: trend.relatedAssets.map((asset) => ({ - ...asset, - caip19: asset.caip19 ?? [], - })), - })), + trends: raw.trends + .map((trend) => ({ + ...trend, + relatedAssets: trend.relatedAssets + .filter((asset) => Boolean(asset.symbol)) + .map((asset) => ({ ...asset, caip19: asset.caip19 ?? [] })), + })) + .filter((trend) => trend.relatedAssets.length > 0), }); const getNormalizedMarketOverview = (value: unknown): MarketOverview | null => { if (is(value, MarketOverviewStruct)) { - return normalizeRelatedAssets(value); + return filterAndNormalizeRelatedAssets(value); } if (is(value, MarketOverviewReportEnvelopeStruct)) { - return normalizeRelatedAssets(value.report); + return filterAndNormalizeRelatedAssets(value.report); } return null; diff --git a/packages/ai-controllers/src/ai-digest-types.ts b/packages/ai-controllers/src/ai-digest-types.ts index f68d2c9098..8f3f14b59b 100644 --- a/packages/ai-controllers/src/ai-digest-types.ts +++ b/packages/ai-controllers/src/ai-digest-types.ts @@ -132,9 +132,12 @@ export type MarketOverviewEntry = { * Returned by the `/market-overview` API as a rich object. */ export type RelatedAsset = { - /** Human-readable asset name (e.g. "Bitcoin") */ - name: string; - /** Ticker symbol (e.g. "BTC") */ + /** + * Human-readable asset name (e.g. "Bitcoin"). Optional — clients must fall + * back to `symbol` when absent. + */ + name?: string; + /** Ticker symbol (e.g. "BTC"). The only field guaranteed to be present. */ symbol: string; /** * CAIP-19 identifiers for this asset across chains. May be absent for @@ -142,8 +145,11 @@ export type RelatedAsset = { * normalises missing values to `[]`. */ caip19?: string[]; - /** Canonical source asset identifier (e.g. "bitcoin") */ - sourceAssetId: string; + /** + * Canonical source asset identifier (e.g. "bitcoin"). Optional — may be + * absent when the API pipeline cannot enrich the asset record. + */ + sourceAssetId?: string; /** * Optional HyperLiquid market identifiers for this asset (e.g. `BTC`, `ETH`, * `xyz:TSLA`). Covers both regular crypto tokens that trade on HyperLiquid From f4b7fbb4abb8440c4682d62a692998d7a6249020 Mon Sep 17 00:00:00 2001 From: Antonio Regadas Date: Thu, 28 May 2026 11:28:07 +0100 Subject: [PATCH 2/5] chore: update changelog --- packages/ai-controllers/CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ai-controllers/CHANGELOG.md b/packages/ai-controllers/CHANGELOG.md index 4af7a8084d..c9e3c8002a 100644 --- a/packages/ai-controllers/CHANGELOG.md +++ b/packages/ai-controllers/CHANGELOG.md @@ -9,9 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- `AiDigestService`: do not reject the entire market overview when individual `relatedAsset` entries are missing non-essential fields (`name`, `sourceAssetId`); assets missing `symbol` are filtered out, and trends with zero valid assets are removed ([#TBD](https://github.com/MetaMask/core/pull/TBD)). - - `RelatedAsset.name` is now optional (`string | undefined`); clients should fall back to `symbol` when absent. - - `RelatedAsset.sourceAssetId` is now optional (`string | undefined`); clients must not use it as a sole React key. +- `AiDigestService`: do not reject the entire market overview when individual `relatedAsset` entries are missing non-essential fields (`name`, `sourceAssetId`); assets missing `symbol` are filtered out, and trends with zero valid assets are removed ([#8920](https://github.com/MetaMask/core/pull/8920)). + - `RelatedAsset.name` is now optional (`string | undefined`); clients should fall back to `symbol` when absent ([#8920](https://github.com/MetaMask/core/pull/8920)). + - `RelatedAsset.sourceAssetId` is now optional (`string | undefined`); clients must not use it as a sole React key ([#8920](https://github.com/MetaMask/core/pull/8920)). ### Changed From 4dcacc92d08a66c83d57f8e523e9f6cf50077116 Mon Sep 17 00:00:00 2001 From: Antonio Regadas Date: Thu, 28 May 2026 11:48:39 +0100 Subject: [PATCH 3/5] chore: update changelog and lint fix --- packages/ai-controllers/CHANGELOG.md | 4 +--- packages/ai-controllers/src/AiDigestService.test.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/ai-controllers/CHANGELOG.md b/packages/ai-controllers/CHANGELOG.md index c9e3c8002a..5074c5cb70 100644 --- a/packages/ai-controllers/CHANGELOG.md +++ b/packages/ai-controllers/CHANGELOG.md @@ -9,9 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- `AiDigestService`: do not reject the entire market overview when individual `relatedAsset` entries are missing non-essential fields (`name`, `sourceAssetId`); assets missing `symbol` are filtered out, and trends with zero valid assets are removed ([#8920](https://github.com/MetaMask/core/pull/8920)). - - `RelatedAsset.name` is now optional (`string | undefined`); clients should fall back to `symbol` when absent ([#8920](https://github.com/MetaMask/core/pull/8920)). - - `RelatedAsset.sourceAssetId` is now optional (`string | undefined`); clients must not use it as a sole React key ([#8920](https://github.com/MetaMask/core/pull/8920)). +- `AiDigestService.fetchMarketOverview` no longer throws when a `relatedAsset` is missing non-essential fields. `RelatedAsset.name` and `RelatedAsset.sourceAssetId` are now optional; assets missing `symbol` are filtered out and trends with zero valid assets are dropped ([#8920](https://github.com/MetaMask/core/pull/8920)). ### Changed diff --git a/packages/ai-controllers/src/AiDigestService.test.ts b/packages/ai-controllers/src/AiDigestService.test.ts index 2d471354e1..65cc00e2bb 100644 --- a/packages/ai-controllers/src/AiDigestService.test.ts +++ b/packages/ai-controllers/src/AiDigestService.test.ts @@ -910,7 +910,9 @@ describe('AiDigestService', () => { const goodTrend = { ...mockMarketOverview.trends[0], title: 'Good trend', - relatedAssets: [{ name: 'Bitcoin', symbol: 'BTC', sourceAssetId: 'bitcoin' }], + relatedAssets: [ + { name: 'Bitcoin', symbol: 'BTC', sourceAssetId: 'bitcoin' }, + ], }; const badTrend = { ...mockMarketOverview.trends[0], @@ -947,7 +949,9 @@ describe('AiDigestService', () => { trends: [ { ...mockMarketOverview.trends[0], - relatedAssets: [{ name: 'Bad', symbol: '', sourceAssetId: 'bad' }], + relatedAssets: [ + { name: 'Bad', symbol: '', sourceAssetId: 'bad' }, + ], }, ], }), From 856ef80e57515ea0ac12d71d27f1c3241e0489f7 Mon Sep 17 00:00:00 2001 From: Antonio Regadas Date: Thu, 28 May 2026 11:54:32 +0100 Subject: [PATCH 4/5] chore: update changelog --- packages/ai-controllers/CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ai-controllers/CHANGELOG.md b/packages/ai-controllers/CHANGELOG.md index 5074c5cb70..df37faf0a2 100644 --- a/packages/ai-controllers/CHANGELOG.md +++ b/packages/ai-controllers/CHANGELOG.md @@ -7,15 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Fixed - -- `AiDigestService.fetchMarketOverview` no longer throws when a `relatedAsset` is missing non-essential fields. `RelatedAsset.name` and `RelatedAsset.sourceAssetId` are now optional; assets missing `symbol` are filtered out and trends with zero valid assets are dropped ([#8920](https://github.com/MetaMask/core/pull/8920)). - ### Changed - Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) - Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) +### Fixed + +- `AiDigestService.fetchMarketOverview` no longer throws when a `relatedAsset` is missing non-essential fields. `RelatedAsset.name` and `RelatedAsset.sourceAssetId` are now optional; assets missing `symbol` are filtered out and trends with zero valid assets are dropped ([#8920](https://github.com/MetaMask/core/pull/8920)). + ## [0.6.3] ### Fixed From 82c7647d1757cf7b4d54e7e19538dab10331f125 Mon Sep 17 00:00:00 2001 From: Antonio Regadas Date: Thu, 28 May 2026 15:16:04 +0100 Subject: [PATCH 5/5] chore: Revert filterAndNormalizeRelatedAssets back to normalizeRelatedAssets --- packages/ai-controllers/CHANGELOG.md | 2 +- .../src/AiDigestService.test.ts | 97 ------------------- .../ai-controllers/src/AiDigestService.ts | 23 ++--- 3 files changed, 11 insertions(+), 111 deletions(-) diff --git a/packages/ai-controllers/CHANGELOG.md b/packages/ai-controllers/CHANGELOG.md index df37faf0a2..7596a29633 100644 --- a/packages/ai-controllers/CHANGELOG.md +++ b/packages/ai-controllers/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- `AiDigestService.fetchMarketOverview` no longer throws when a `relatedAsset` is missing non-essential fields. `RelatedAsset.name` and `RelatedAsset.sourceAssetId` are now optional; assets missing `symbol` are filtered out and trends with zero valid assets are dropped ([#8920](https://github.com/MetaMask/core/pull/8920)). +- `RelatedAsset.name` and `RelatedAsset.sourceAssetId` are now optional in both the superstruct schema and TypeScript type, so a `relatedAsset` missing either field no longer causes `fetchMarketOverview` to throw `API_INVALID_RESPONSE` ([#8920](https://github.com/MetaMask/core/pull/8920)). ## [0.6.3] diff --git a/packages/ai-controllers/src/AiDigestService.test.ts b/packages/ai-controllers/src/AiDigestService.test.ts index 65cc00e2bb..7640b57abe 100644 --- a/packages/ai-controllers/src/AiDigestService.test.ts +++ b/packages/ai-controllers/src/AiDigestService.test.ts @@ -869,103 +869,6 @@ describe('AiDigestService', () => { expect(result?.trends[0].relatedAssets[0].name).toBeUndefined(); }); - it('strips assets with empty symbol and retains the trend when other valid assets remain', async () => { - const validAsset = { - name: 'Bitcoin', - symbol: 'BTC', - sourceAssetId: 'bitcoin', - }; - const emptySymbolAsset = { - name: 'Bad', - symbol: '', - sourceAssetId: 'bad', - }; - - mockFetch.mockResolvedValue({ - ok: true, - status: 200, - json: () => - Promise.resolve({ - ...mockMarketOverview, - trends: [ - { - ...mockMarketOverview.trends[0], - relatedAssets: [emptySymbolAsset, validAsset], - }, - ], - }), - }); - - const service = new AiDigestService({ - baseUrl: 'http://test.com/api/v1', - }); - const result = await service.fetchMarketOverview(); - - expect(result?.trends).toHaveLength(1); - expect(result?.trends[0].relatedAssets).toHaveLength(1); - expect(result?.trends[0].relatedAssets[0].symbol).toBe('BTC'); - }); - - it('drops a trend whose every asset has an empty symbol, but retains other trends', async () => { - const goodTrend = { - ...mockMarketOverview.trends[0], - title: 'Good trend', - relatedAssets: [ - { name: 'Bitcoin', symbol: 'BTC', sourceAssetId: 'bitcoin' }, - ], - }; - const badTrend = { - ...mockMarketOverview.trends[0], - title: 'Bad trend', - relatedAssets: [{ name: 'Bad', symbol: '', sourceAssetId: 'bad' }], - }; - - mockFetch.mockResolvedValue({ - ok: true, - status: 200, - json: () => - Promise.resolve({ - ...mockMarketOverview, - trends: [goodTrend, badTrend], - }), - }); - - const service = new AiDigestService({ - baseUrl: 'http://test.com/api/v1', - }); - const result = await service.fetchMarketOverview(); - - expect(result?.trends).toHaveLength(1); - expect(result?.trends[0].title).toBe('Good trend'); - }); - - it('returns an overview with empty trends array when every trend is filtered out', async () => { - mockFetch.mockResolvedValue({ - ok: true, - status: 200, - json: () => - Promise.resolve({ - ...mockMarketOverview, - trends: [ - { - ...mockMarketOverview.trends[0], - relatedAssets: [ - { name: 'Bad', symbol: '', sourceAssetId: 'bad' }, - ], - }, - ], - }), - }); - - const service = new AiDigestService({ - baseUrl: 'http://test.com/api/v1', - }); - const result = await service.fetchMarketOverview(); - - expect(result).not.toBeNull(); - expect(result?.trends).toStrictEqual([]); - }); - it('accepts additional unknown fields in payload', async () => { const withExtras = { ...mockMarketOverview, diff --git a/packages/ai-controllers/src/AiDigestService.ts b/packages/ai-controllers/src/AiDigestService.ts index ebcb17698b..23240521c3 100644 --- a/packages/ai-controllers/src/AiDigestService.ts +++ b/packages/ai-controllers/src/AiDigestService.ts @@ -113,27 +113,24 @@ const MarketOverviewReportEnvelopeStruct = structType({ report: MarketOverviewStruct, }); -const filterAndNormalizeRelatedAssets = ( - raw: MarketOverview, -): MarketOverview => ({ +const normalizeRelatedAssets = (raw: MarketOverview): MarketOverview => ({ ...raw, - trends: raw.trends - .map((trend) => ({ - ...trend, - relatedAssets: trend.relatedAssets - .filter((asset) => Boolean(asset.symbol)) - .map((asset) => ({ ...asset, caip19: asset.caip19 ?? [] })), - })) - .filter((trend) => trend.relatedAssets.length > 0), + trends: raw.trends.map((trend) => ({ + ...trend, + relatedAssets: trend.relatedAssets.map((asset) => ({ + ...asset, + caip19: asset.caip19 ?? [], + })), + })), }); const getNormalizedMarketOverview = (value: unknown): MarketOverview | null => { if (is(value, MarketOverviewStruct)) { - return filterAndNormalizeRelatedAssets(value); + return normalizeRelatedAssets(value); } if (is(value, MarketOverviewReportEnvelopeStruct)) { - return filterAndNormalizeRelatedAssets(value.report); + return normalizeRelatedAssets(value.report); } return null;