From a299c4f2bb0ebe8de5eb87bf759ddaf3b07a86ec Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:28:32 +0800 Subject: [PATCH 1/9] feat: add agentic cli preferences (#8933) ## Explanation Adds Agentic CLI notification preferences to authenticated user storage and wires them through `NotificationServicesController`. ### `@metamask/authenticated-user-storage` - Adds `AgenticCliPreference` (`inAppNotificationsEnabled`, `pushNotificationsEnabled`) and an **optional** `agenticCli` field on `NotificationPreferences` (minor semver; the next major release should make it required). - Adds `DEFAULT_AGENTIC_CLI_PREFERENCES` (both flags default to `true`) and exports it from the package index. - **`getNotificationPreferences`**: legacy blobs that omit `agenticCli` are backfilled with a shallow copy of `DEFAULT_AGENTIC_CLI_PREFERENCES`, then validated against the full schema. When non-`null`, the returned object always includes `agenticCli` even though the type marks it optional. - **`putNotificationPreferences`**: relies on the TypeScript type for write shape; no additional runtime validation is performed on PUT. - Updates test mocks and adds coverage for legacy coercion and for ensuring returned defaults are not shared with the exported constant. ### `@metamask/notification-services-controller` - Re-exports `DEFAULT_AGENTIC_CLI_PREFERENCES` from `@metamask/authenticated-user-storage`. - Initializes `agenticCli` when building fresh notification preferences via `buildFreshPreferences`. - Agentic CLI notification delivery is gated by the Agentic backend using AUS `agenticCli` preferences; `NotificationServicesController` does not filter Agentic CLI notifications at fetch time (same as `perps` and `socialAI`). - Updates test helpers and expectations. ### Semver note This is a **minor** change: `agenticCli` is optional on `NotificationPreferences`, so existing consumers that read-modify-write via `getNotificationPreferences` do not need code changes. The next major release should make `agenticCli` required on the type. ## References - Related to Agentic CLI notification preference work ([#8933](https://github.com/MetaMask/core/pull/8933)) ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- > [!NOTE] > **Medium Risk** > Extending `NotificationPreferences` with a new required field is a breaking type change for consumers that build or spread preference objects without `agenticCli`. > > **Overview** > Adds **agentic CLI** notification settings to authenticated user storage: a new `AgenticCliPreference` type (in-app and push toggles) and a required `agenticCli` field on `NotificationPreferences`. > > Test mocks were updated so `MOCK_NOTIFICATION_PREFERENCES` includes sample `agenticCli` values. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit a732e095168c403eed4ae3f24fea5b5eba8551b4. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Gaurav Goel --- .../authenticated-user-storage/CHANGELOG.md | 5 +++ ...icated-user-storage-method-action-types.ts | 6 +++ .../src/authenticated-user-storage.test.ts | 41 ++++++++++++++++++- .../src/authenticated-user-storage.ts | 17 +++++++- .../authenticated-user-storage/src/index.ts | 6 ++- .../authenticated-user-storage/src/types.ts | 16 +++++++- .../src/validators.ts | 21 +++++++++- .../tests/mocks/authenticated-userstorage.ts | 12 ++++++ .../CHANGELOG.md | 7 ++++ .../NotificationServicesController.test.ts | 3 ++ .../NotificationServicesController.ts | 8 +++- .../src/index.ts | 1 + 12 files changed, 135 insertions(+), 8 deletions(-) diff --git a/packages/authenticated-user-storage/CHANGELOG.md b/packages/authenticated-user-storage/CHANGELOG.md index 747d3a9294..459a8223dd 100644 --- a/packages/authenticated-user-storage/CHANGELOG.md +++ b/packages/authenticated-user-storage/CHANGELOG.md @@ -12,6 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `getAssetsWatchlist` and `setAssetsWatchlist` methods to `AuthenticatedUserStorageService` for managing the authenticated user's assets-watchlist, along with corresponding messenger actions (`AuthenticatedUserStorageService:getAssetsWatchlist`, `AuthenticatedUserStorageService:setAssetsWatchlist`), the `AssetsWatchlistBlob` type, and the `ASSETS_WATCHLIST_MAX_ASSETS` constant ([#8836](https://github.com/MetaMask/core/pull/8836)) - `getAssetsWatchlist` returns the assets-watchlist blob or `null` on 404, mirroring `getNotificationPreferences`. - `setAssetsWatchlist` writes the full blob and enforces a maximum of `ASSETS_WATCHLIST_MAX_ASSETS` (100) assets before sending the request, via a superstruct `size` constraint on the write-side schema. +- Add `AgenticCliPreference` type and optional `agenticCli` field to `NotificationPreferences` for Agentic CLI notification preferences ([#8933](https://github.com/MetaMask/core/pull/8933)) + - `agenticCli` is optional on the type for this release; the next major release should make it required. + - `getNotificationPreferences` backfills legacy blobs that omit `agenticCli` with `DEFAULT_AGENTIC_CLI_PREFERENCES`, then validates the result against the full schema. + - `putNotificationPreferences` relies on the TypeScript type for write shape; no runtime validation is performed on PUT. +- Add `DEFAULT_AGENTIC_CLI_PREFERENCES` for Agentic CLI notification preferences ([#8933](https://github.com/MetaMask/core/pull/8933)) ### Changed diff --git a/packages/authenticated-user-storage/src/authenticated-user-storage-method-action-types.ts b/packages/authenticated-user-storage/src/authenticated-user-storage-method-action-types.ts index b400de367c..ff574c20e1 100644 --- a/packages/authenticated-user-storage/src/authenticated-user-storage-method-action-types.ts +++ b/packages/authenticated-user-storage/src/authenticated-user-storage-method-action-types.ts @@ -39,6 +39,12 @@ export type AuthenticatedUserStorageServiceRevokeDelegationAction = { /** * Returns the notification preferences for the authenticated user. * + * Legacy payloads that omit `agenticCli` are coerced with + * {@link DEFAULT_AGENTIC_CLI_PREFERENCES} on read. When this method returns + * a non-`null` value, `agenticCli` is always present (backfilled), even + * though {@link NotificationPreferences} marks it optional until the next + * major release. + * * @returns The notification preferences object, or `null` if none have been * set (404). */ diff --git a/packages/authenticated-user-storage/src/authenticated-user-storage.test.ts b/packages/authenticated-user-storage/src/authenticated-user-storage.test.ts index 6b8ed45cda..df6703bcd9 100644 --- a/packages/authenticated-user-storage/src/authenticated-user-storage.test.ts +++ b/packages/authenticated-user-storage/src/authenticated-user-storage.test.ts @@ -19,6 +19,7 @@ import { MOCK_DELEGATION_RESPONSE, MOCK_DELEGATION_SUBMISSION, MOCK_INVALID_ASSETS_WATCHLIST_BLOB, + MOCK_LEGACY_NOTIFICATION_PREFERENCES, MOCK_NOTIFICATION_PREFERENCES, MOCK_ASSETS_WATCHLIST_BLOB, MOCK_ASSETS_WATCHLIST_URL, @@ -30,7 +31,10 @@ import { } from './authenticated-user-storage'; import type { Environment } from './env'; import { getUserStorageApiUrl } from './env'; -import { ASSETS_WATCHLIST_MAX_ASSETS } from './validators'; +import { + ASSETS_WATCHLIST_MAX_ASSETS, + DEFAULT_AGENTIC_CLI_PREFERENCES, +} from './validators'; const MOCK_ACCESS_TOKEN = 'mock-access-token'; @@ -199,6 +203,41 @@ describe('AuthenticatedUserStorageService', () => { 'Failed to get notification preferences: 500', ); }); + + it('coerces legacy payloads that omit agenticCli', async () => { + handleMockGetNotificationPreferences({ + status: 200, + body: MOCK_LEGACY_NOTIFICATION_PREFERENCES, + }); + const { service } = createService(); + + const result = await service.getNotificationPreferences(); + + expect(result).toStrictEqual({ + ...MOCK_LEGACY_NOTIFICATION_PREFERENCES, + agenticCli: DEFAULT_AGENTIC_CLI_PREFERENCES, + }); + }); + + it('does not mutate DEFAULT_AGENTIC_CLI_PREFERENCES when coercing legacy payloads', async () => { + handleMockGetNotificationPreferences({ + status: 200, + body: MOCK_LEGACY_NOTIFICATION_PREFERENCES, + }); + const { service } = createService(); + + const result = await service.getNotificationPreferences(); + + expect(result).not.toBeNull(); + if (!result) { + throw new Error('Result is null'); + } + result.agenticCli.inAppNotificationsEnabled = false; + + expect(DEFAULT_AGENTIC_CLI_PREFERENCES.inAppNotificationsEnabled).toBe( + true, + ); + }); }); describe('putNotificationPreferences', () => { diff --git a/packages/authenticated-user-storage/src/authenticated-user-storage.ts b/packages/authenticated-user-storage/src/authenticated-user-storage.ts index 2dd3bb29ad..09885cc3e9 100644 --- a/packages/authenticated-user-storage/src/authenticated-user-storage.ts +++ b/packages/authenticated-user-storage/src/authenticated-user-storage.ts @@ -24,6 +24,7 @@ import { assertAssetsWatchlistBlobForWrite, assertDelegationResponseArray, assertNotificationPreferences, + DEFAULT_AGENTIC_CLI_PREFERENCES, } from './validators'; // === GENERAL === @@ -271,6 +272,12 @@ export class AuthenticatedUserStorageService extends BaseDataService< /** * Returns the notification preferences for the authenticated user. * + * Legacy payloads that omit `agenticCli` are coerced with + * {@link DEFAULT_AGENTIC_CLI_PREFERENCES} on read. When this method returns + * a non-`null` value, `agenticCli` is always present (backfilled), even + * though {@link NotificationPreferences} marks it optional until the next + * major release. + * * @returns The notification preferences object, or `null` if none have been * set (404). */ @@ -302,8 +309,14 @@ export class AuthenticatedUserStorageService extends BaseDataService< return null; } - assertNotificationPreferences(data); - return data; + // backfill agenticCli preferences if it is undefined + const backfilledData = { + ...data, + agenticCli: data.agenticCli ?? { ...DEFAULT_AGENTIC_CLI_PREFERENCES }, + }; + + assertNotificationPreferences(backfilledData); + return backfilledData; } /** diff --git a/packages/authenticated-user-storage/src/index.ts b/packages/authenticated-user-storage/src/index.ts index 74bc6d2624..a711354315 100644 --- a/packages/authenticated-user-storage/src/index.ts +++ b/packages/authenticated-user-storage/src/index.ts @@ -2,7 +2,10 @@ export { getAuthenticatedStorageUrl, AuthenticatedUserStorageService, } from './authenticated-user-storage'; -export { ASSETS_WATCHLIST_MAX_ASSETS } from './validators'; +export { + ASSETS_WATCHLIST_MAX_ASSETS, + DEFAULT_AGENTIC_CLI_PREFERENCES, +} from './validators'; export type { AuthenticatedUserStorageActions, AuthenticatedUserStorageCacheUpdatedEvent, @@ -35,6 +38,7 @@ export type { PerpsWatchlistMarkets, PerpsPreference, SocialAIPreference, + AgenticCliPreference, NotificationPreferences, AssetsWatchlistBlob, ClientType, diff --git a/packages/authenticated-user-storage/src/types.ts b/packages/authenticated-user-storage/src/types.ts index b57c7e6aaa..3d48fbeeb3 100644 --- a/packages/authenticated-user-storage/src/types.ts +++ b/packages/authenticated-user-storage/src/types.ts @@ -69,6 +69,11 @@ export type WalletActivityAccount = { enabled: boolean; }; +export type AgenticCliPreference = { + inAppNotificationsEnabled: boolean; + pushNotificationsEnabled: boolean; +}; + export type WalletActivityPreference = { inAppNotificationsEnabled: boolean; pushNotificationsEnabled: boolean; @@ -103,12 +108,21 @@ export type SocialAIPreference = { mutedTraderProfileIds: string[]; }; -/** Notification preferences for the authenticated user. */ +/** + * Notification preferences for the authenticated user. + * + * `agenticCli` is optional on this type for the current minor release. + * {@link AuthenticatedUserStorageService.getNotificationPreferences} always + * backfills it when absent from stored data. The next major release should + * make `agenticCli` required on this type. + */ export type NotificationPreferences = { walletActivity: WalletActivityPreference; marketing: MarketingPreference; perps: PerpsPreference; socialAI: SocialAIPreference; + /** Optional until the next major release; always backfilled on read when absent. */ + agenticCli?: AgenticCliPreference; }; // --------------------------------------------------------------------------- diff --git a/packages/authenticated-user-storage/src/validators.ts b/packages/authenticated-user-storage/src/validators.ts index 0a9b406b9d..6ec05a7080 100644 --- a/packages/authenticated-user-storage/src/validators.ts +++ b/packages/authenticated-user-storage/src/validators.ts @@ -13,7 +13,11 @@ import { type, } from '@metamask/superstruct'; -import type { DelegationResponse, NotificationPreferences } from './types'; +import type { + AgenticCliPreference, + DelegationResponse, + NotificationPreferences, +} from './types'; /** * Matches a 0x-prefixed hex string with zero or more hex digits. @@ -91,13 +95,28 @@ const SocialAIPreferenceSchema = type({ mutedTraderProfileIds: array(string()), }); +const AgenticCliPreferenceSchema = type({ + inAppNotificationsEnabled: boolean(), + pushNotificationsEnabled: boolean(), +}); + const NotificationPreferencesSchema = type({ walletActivity: WalletActivityPreferenceSchema, marketing: MarketingPreferenceSchema, perps: PerpsPreferenceSchema, socialAI: SocialAIPreferenceSchema, + agenticCli: AgenticCliPreferenceSchema, }); +/** + * Default Agentic CLI notification preferences applied when coercing legacy + * notification-preference blobs that omit `agenticCli`. + */ +export const DEFAULT_AGENTIC_CLI_PREFERENCES: AgenticCliPreference = { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, +}; + /** * Maximum number of entries allowed in an assets-watchlist on write. Reads * are lenient: a server payload exceeding this size will still validate as diff --git a/packages/authenticated-user-storage/tests/mocks/authenticated-userstorage.ts b/packages/authenticated-user-storage/tests/mocks/authenticated-userstorage.ts index 871f5f5149..99435e043a 100644 --- a/packages/authenticated-user-storage/tests/mocks/authenticated-userstorage.ts +++ b/packages/authenticated-user-storage/tests/mocks/authenticated-userstorage.ts @@ -68,6 +68,18 @@ export const MOCK_NOTIFICATION_PREFERENCES: NotificationPreferences = { 'e8f2a1b3-5c4d-4e6f-8a9b-2c3d4e5f6a7b', ], }, + agenticCli: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: false, + }, +}; + +/** Legacy notification preferences blob without `agenticCli`. */ +export const MOCK_LEGACY_NOTIFICATION_PREFERENCES: NotificationPreferences = { + walletActivity: MOCK_NOTIFICATION_PREFERENCES.walletActivity, + marketing: MOCK_NOTIFICATION_PREFERENCES.marketing, + perps: MOCK_NOTIFICATION_PREFERENCES.perps, + socialAI: MOCK_NOTIFICATION_PREFERENCES.socialAI, }; export const MOCK_ASSETS_WATCHLIST_BLOB: AssetsWatchlistBlob = { diff --git a/packages/notification-services-controller/CHANGELOG.md b/packages/notification-services-controller/CHANGELOG.md index d4f1d5484c..b7be611c26 100644 --- a/packages/notification-services-controller/CHANGELOG.md +++ b/packages/notification-services-controller/CHANGELOG.md @@ -7,8 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `DEFAULT_AGENTIC_CLI_PREFERENCES` and initialize `agenticCli` when building fresh notification preferences via `NotificationServicesController` ([#8933](https://github.com/MetaMask/core/pull/8933)) + - Re-export `DEFAULT_AGENTIC_CLI_PREFERENCES` from `@metamask/authenticated-user-storage`. + ### Changed +- Agentic CLI notification delivery is gated by the Agentic backend using AUS `agenticCli` preferences; `NotificationServicesController` does not filter Agentic CLI notifications at fetch time (same as `perps` and `socialAI`) ([#8933](https://github.com/MetaMask/core/pull/8933)) + - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) - Bump `@metamask/controller-utils` from `^12.1.1` to `^12.2.0` ([#9083](https://github.com/MetaMask/core/pull/9083)) - Bump `@metamask/profile-sync-controller` from `^28.1.1` to `^28.2.0` ([#9119](https://github.com/MetaMask/core/pull/9119)) diff --git a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts index 52514d76ce..159784fafa 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts @@ -44,6 +44,7 @@ import { } from './mocks/mock-feature-announcements'; import { createMockNotificationEthSent } from './mocks/mock-raw-notifications'; import { + DEFAULT_AGENTIC_CLI_PREFERENCES, DEFAULT_PERPS_PREFERENCES, DEFAULT_SOCIAL_AI_PREFERENCES, NotificationServicesController, @@ -105,6 +106,7 @@ const prefsFromAddresses = ( pushNotificationsEnabled: true, mutedTraderProfileIds: [], }, + agenticCli: { ...DEFAULT_AGENTIC_CLI_PREFERENCES }, }); const prefsFromAddressesWithMarketingInAppNotifications = ( @@ -565,6 +567,7 @@ describe('NotificationServicesController', () => { }, perps: { ...DEFAULT_PERPS_PREFERENCES }, socialAI: { ...DEFAULT_SOCIAL_AI_PREFERENCES }, + agenticCli: { ...DEFAULT_AGENTIC_CLI_PREFERENCES }, }); expect(mockEnablePushNotifications).toHaveBeenCalledWith([ ADDRESS_1.toLowerCase(), diff --git a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts index bbc558c4f3..42455ba950 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts @@ -6,6 +6,7 @@ import type { SocialAIPreference, WalletActivityAccount, } from '@metamask/authenticated-user-storage'; +import { DEFAULT_AGENTIC_CLI_PREFERENCES } from '@metamask/authenticated-user-storage'; import type { ControllerGetStateAction, ControllerStateChangeEvent, @@ -244,6 +245,8 @@ export const DEFAULT_SOCIAL_AI_PREFERENCES: Required = { mutedTraderProfileIds: [], }; +export { DEFAULT_AGENTIC_CLI_PREFERENCES } from '@metamask/authenticated-user-storage'; + /** * Builds wallet-activity preferences from the keyring's current accounts. * @@ -301,8 +304,8 @@ const buildWalletActivityAccountsFromTriggerConfig = async ( /** * Builds a fresh `NotificationPreferences` blob using hardcoded defaults for - * Perps and Social AI, the supplied wallet-activity accounts and the user's - * marketing/product-announcement flags. + * Perps, Social AI, and Agentic CLI, the supplied wallet-activity accounts and + * the user's marketing/product-announcement flags. * * @param walletActivityAccounts - The wallet-activity account config to initialize. * @param hasMarketingConsent - Whether marketing push notifications should be enabled. @@ -325,6 +328,7 @@ const buildFreshPreferences = ( }, perps: { ...DEFAULT_PERPS_PREFERENCES }, socialAI: { ...DEFAULT_SOCIAL_AI_PREFERENCES }, + agenticCli: { ...DEFAULT_AGENTIC_CLI_PREFERENCES }, }); const MESSENGER_EXPOSED_METHODS = [ diff --git a/packages/notification-services-controller/src/index.ts b/packages/notification-services-controller/src/index.ts index f2985ab0fb..572012cb6b 100644 --- a/packages/notification-services-controller/src/index.ts +++ b/packages/notification-services-controller/src/index.ts @@ -1,6 +1,7 @@ export * as NotificationServicesController from './NotificationServicesController'; export * as NotificationServicesPushController from './NotificationServicesPushController'; export { + DEFAULT_AGENTIC_CLI_PREFERENCES, DEFAULT_PERPS_PREFERENCES, DEFAULT_SOCIAL_AI_PREFERENCES, } from './NotificationServicesController'; From 76d5240a89c80d521f6b2ca1d4a7ae3528076ba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Santos?= Date: Wed, 17 Jun 2026 10:45:23 +0200 Subject: [PATCH 2/9] feat(social-controllers): expose optional 7-day per-chain breakdown (#9165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Explanation The Social Leaderboard surfaces in MetaMask Mobile are switching their PnL displays from the 30-day window to the 7-day window (TSA-766). The trader-profile "Return" headline sums the **per-chain** PnL breakdown (rather than the global stat) so it includes Hyperliquid/perps and stays consistent with the position list. `TraderStats` already exposed scalar 7-day fields (`pnl7d`, `winRate7d`, `roiPercent7d`), but `PerChainBreakdown` only had the 30-day per-chain maps — so a 7-day, Hyperliquid-inclusive headline wasn't expressible. This adds the 7-day per-chain breakdown to `PerChainBreakdown` (and its struct), alongside the matching social-api change that populates it (Clicker already returns these windows). ## References - Mobile ticket: TSA-766 (Use 7d PnL) - social-api counterpart: `va-mmcx-social-api` — exposes `perChainPnl7d` / `perChainRoi7d` / `perChainVolume7d` on the profile response and chain-scopes the leaderboard 7-day fields. ## Changes - **Added**: optional `perChainPnl7d` (`Record`), `perChainRoi7d` (`Record`), and `perChainVolume7d` (`Record`) to the `PerChainBreakdown` type and `PerChainBreakdownStruct`. - The unsuffixed fields (`perChainPnl`, `perChainRoi`, `perChainVolume`) remain the **30-day** window. - New fields are **optional** so the controller stays backward compatible with social-api versions that only return the 30-day breakdown. ## Non-breaking Purely additive. The response struct uses `superstruct`'s non-exhaustive `type()`, and the new struct fields are `optional()`, so existing 30-day-only responses continue to validate unchanged. No public export was removed, renamed, or had its signature changed. ## Test plan - `yarn workspace @metamask/social-controllers run jest --no-coverage SocialService.test.ts` — added cases asserting the 7-day per-chain breakdown round-trips, and that a profile omitting it still validates (`perChainPnl7d` is `undefined`). 45/45 pass. - `yarn workspace @metamask/social-controllers run build` — type check passes. - Changelog updated + `changelog:validate` passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- packages/social-controllers/CHANGELOG.md | 4 ++ .../src/SocialService.test.ts | 45 +++++++++++++++++++ .../social-controllers/src/SocialService.ts | 3 ++ .../social-controllers/src/social-types.ts | 10 +++++ 4 files changed, 62 insertions(+) diff --git a/packages/social-controllers/CHANGELOG.md b/packages/social-controllers/CHANGELOG.md index 12e08b6f8d..d7a4239729 100644 --- a/packages/social-controllers/CHANGELOG.md +++ b/packages/social-controllers/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add optional 7-day per-chain fields to the `PerChainBreakdown` type (and `PerChainBreakdownStruct`): `perChainPnl7d` (`Record`), `perChainRoi7d` (`Record`), and `perChainVolume7d` (`Record`) — exposing the 7-day Hyperliquid/per-chain breakdown alongside the existing 30-day fields. The unsuffixed fields (`perChainPnl`, `perChainRoi`, `perChainVolume`) remain the 30-day window; the new fields are optional for backward compatibility with social-api versions that only return the 30-day breakdown ([#9165](https://github.com/MetaMask/core/pull/9165)) + ## [2.3.0] ### Added diff --git a/packages/social-controllers/src/SocialService.test.ts b/packages/social-controllers/src/SocialService.test.ts index 4ddf666f2e..795f6fc256 100644 --- a/packages/social-controllers/src/SocialService.test.ts +++ b/packages/social-controllers/src/SocialService.test.ts @@ -404,6 +404,51 @@ describe('SocialService', () => { expect(result.stats).toStrictEqual({}); }); + + it('accepts and returns the optional 7-day per-chain breakdown', async () => { + const withPerChain7d = { + ...mockProfileResponse, + perChainBreakdown: { + perChainPnl: { base: 30000, hyperliquid: 900000 }, + perChainRoi: { base: 2.5, hyperliquid: null }, + perChainVolume: { base: 100000, hyperliquid: 0 }, + perChainPnl7d: { base: 5000, hyperliquid: 120000 }, + perChainRoi7d: { base: 1.1, hyperliquid: null }, + perChainVolume7d: { base: 20000, hyperliquid: 0 }, + }, + }; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(withPerChain7d), + }); + + const service = createService(); + const result = await service.fetchTraderProfile({ + addressOrId: '0x1234', + }); + + expect(result.perChainBreakdown).toStrictEqual( + withPerChain7d.perChainBreakdown, + ); + }); + + it('accepts a profile without the optional 7-day per-chain breakdown', async () => { + // The 30-day-only shape older social-api versions return. + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockProfileResponse), + }); + + const service = createService(); + const result = await service.fetchTraderProfile({ + addressOrId: '0x1234', + }); + + expect(result.perChainBreakdown.perChainPnl7d).toBeUndefined(); + }); }); describe('fetchOpenPositions', () => { diff --git a/packages/social-controllers/src/SocialService.ts b/packages/social-controllers/src/SocialService.ts index 3401fe7bf1..1d64f24f15 100644 --- a/packages/social-controllers/src/SocialService.ts +++ b/packages/social-controllers/src/SocialService.ts @@ -134,6 +134,9 @@ const PerChainBreakdownStruct = structType({ perChainPnl: record(string(), number()), perChainRoi: record(string(), nullable(number())), perChainVolume: record(string(), number()), + perChainPnl7d: optional(record(string(), number())), + perChainRoi7d: optional(record(string(), nullable(number()))), + perChainVolume7d: optional(record(string(), number())), }); const TraderProfileResponseStruct = structType({ diff --git a/packages/social-controllers/src/social-types.ts b/packages/social-controllers/src/social-types.ts index f9e5a57158..168072cd5d 100644 --- a/packages/social-controllers/src/social-types.ts +++ b/packages/social-controllers/src/social-types.ts @@ -123,6 +123,16 @@ export type PerChainBreakdown = { /** ROI can be null for chains with no trading activity (zero cost-basis). */ perChainRoi: Record; perChainVolume: Record; + /** + * 7-day per-chain PnL in USD. Optional: older social-api versions only + * return the 30-day breakdown (`perChainPnl`). The unsuffixed fields above + * remain the 30-day window for backward compatibility. + */ + perChainPnl7d?: Record; + /** 7-day per-chain ROI. Null for chains with no trading activity. */ + perChainRoi7d?: Record; + /** 7-day per-chain volume in USD. */ + perChainVolume7d?: Record; }; /** From 6ba77faa4caef5d56c7aaa342c10938d2520ad40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Santos?= Date: Wed, 17 Jun 2026 11:35:20 +0200 Subject: [PATCH 3/9] Release/1047.0.0 (#9166) ## Explanation ## References ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- package.json | 2 +- packages/social-controllers/CHANGELOG.md | 5 ++++- packages/social-controllers/package.json | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 72e5fd1f45..cbeba5061a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "1046.0.0", + "version": "1047.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/social-controllers/CHANGELOG.md b/packages/social-controllers/CHANGELOG.md index d7a4239729..85f9209b36 100644 --- a/packages/social-controllers/CHANGELOG.md +++ b/packages/social-controllers/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.3.1] + ### Added - Add optional 7-day per-chain fields to the `PerChainBreakdown` type (and `PerChainBreakdownStruct`): `perChainPnl7d` (`Record`), `perChainRoi7d` (`Record`), and `perChainVolume7d` (`Record`) — exposing the 7-day Hyperliquid/per-chain breakdown alongside the existing 30-day fields. The unsuffixed fields (`perChainPnl`, `perChainRoi`, `perChainVolume`) remain the 30-day window; the new fields are optional for backward compatibility with social-api versions that only return the 30-day breakdown ([#9165](https://github.com/MetaMask/core/pull/9165)) @@ -97,7 +99,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `unfollowTrader` — unfollows traders and removes addresses from state - `updateFollowing` — fetches following list and replaces addresses in state -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/social-controllers@2.3.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/social-controllers@2.3.1...HEAD +[2.3.1]: https://github.com/MetaMask/core/compare/@metamask/social-controllers@2.3.0...@metamask/social-controllers@2.3.1 [2.3.0]: https://github.com/MetaMask/core/compare/@metamask/social-controllers@2.2.1...@metamask/social-controllers@2.3.0 [2.2.1]: https://github.com/MetaMask/core/compare/@metamask/social-controllers@2.2.0...@metamask/social-controllers@2.2.1 [2.2.0]: https://github.com/MetaMask/core/compare/@metamask/social-controllers@2.1.0...@metamask/social-controllers@2.2.0 diff --git a/packages/social-controllers/package.json b/packages/social-controllers/package.json index cdc681f86e..776e45b31a 100644 --- a/packages/social-controllers/package.json +++ b/packages/social-controllers/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/social-controllers", - "version": "2.3.0", + "version": "2.3.1", "description": "A collection of social related controllers", "keywords": [ "Ethereum", From 9dcab57ce892ea057ad857debd408621fff79a62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20G=C3=B6ktu=C4=9F=20Poyraz?= Date: Wed, 17 Jun 2026 11:49:45 +0200 Subject: [PATCH 4/9] fix(transaction-controller): set isExternalSign when isGasFeeSponsored is confirmed by simulation (#9148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary When `isGasFeeSponsored` is confirmed `true` by the simulation response, `isExternalSign` is now also set to `true` on the transaction meta. ## Fix Accounts using the Money Account keyring cannot execute `KeyringController:signTransaction` — the keyring only supports EIP-7702 authorization and message signing. Transactions from these accounts that are gas-sponsored (e.g. the Card link ERC-20 approval on Monad) would crash with: ``` KeyringController - The keyring for the current address does not support the method signTransaction. ``` Setting `isExternalSign = true` alongside `isGasFeeSponsored = true` skips the local signing step, allowing Sentinel Relay to handle submission via the sponsorship path. https://github.com/user-attachments/assets/37e22c82-0724-4204-ac4e-6d5b45998d7a ## Changes - `TransactionController`: after persisting `isGasFeeSponsored` from simulation, set `isExternalSign = true` when sponsored --- packages/transaction-controller/CHANGELOG.md | 4 + .../src/TransactionController.test.ts | 88 +++++++++++++++++++ .../src/TransactionController.ts | 9 +- 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index e08cc10730..58c59ff39a 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Set `isExternalSign` to `true` when `isGasFeeSponsored` is confirmed by simulation, so gas-sponsored transactions from accounts that cannot locally sign (e.g. Money Account keyring) skip `KeyringController:signTransaction` ([#9148](https://github.com/MetaMask/core/pull/9148)) + ## [68.0.0] ### Changed diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index 9b55f8c3a9..5af2e7e58a 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -2578,6 +2578,94 @@ describe('TransactionController', () => { expect(controller.state.transactions[0].isGasFeeSponsored).toBe(false); }); + + it('sets isExternalSign to true when transaction is sponsored', async () => { + getGasFeeTokensMock.mockResolvedValueOnce({ + gasFeeTokens: [GAS_FEE_TOKEN_MOCK], + isGasFeeSponsored: true, + }); + + const { controller } = setupController(); + + await controller.addTransaction( + { + from: ACCOUNT_MOCK, + to: ACCOUNT_MOCK, + }, + { + networkClientId: NETWORK_CLIENT_ID_MOCK, + }, + ); + + await flushPromises(); + + expect(controller.state.transactions[0].isExternalSign).toBe(true); + }); + + it('does not set isExternalSign when transaction is not sponsored', async () => { + getGasFeeTokensMock.mockResolvedValueOnce({ + gasFeeTokens: [GAS_FEE_TOKEN_MOCK], + isGasFeeSponsored: false, + }); + + const { controller } = setupController(); + + await controller.addTransaction( + { + from: ACCOUNT_MOCK, + to: ACCOUNT_MOCK, + }, + { + networkClientId: NETWORK_CLIENT_ID_MOCK, + }, + ); + + await flushPromises(); + + expect(controller.state.transactions[0].isExternalSign).toBeUndefined(); + }); + + it('sets isExternalSign to true immediately when isGasFeeSponsored is passed in options', async () => { + const { controller } = setupController(); + + await controller.addTransaction( + { + from: ACCOUNT_MOCK, + to: ACCOUNT_MOCK, + }, + { + networkClientId: NETWORK_CLIENT_ID_MOCK, + isGasFeeSponsored: true, + }, + ); + + expect(controller.state.transactions[0].isExternalSign).toBe(true); + }); + + it('preserves isGasFeeSponsored and isExternalSign when passed in options even if simulation returns not sponsored', async () => { + getGasFeeTokensMock.mockResolvedValueOnce({ + gasFeeTokens: [], + isGasFeeSponsored: false, + }); + + const { controller } = setupController(); + + await controller.addTransaction( + { + from: ACCOUNT_MOCK, + to: ACCOUNT_MOCK, + }, + { + networkClientId: NETWORK_CLIENT_ID_MOCK, + isGasFeeSponsored: true, + }, + ); + + await flushPromises(); + + expect(controller.state.transactions[0].isGasFeeSponsored).toBe(true); + expect(controller.state.transactions[0].isExternalSign).toBe(true); + }); }); describe('with isStateOnly', () => { diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 646399c373..442d388d74 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -1129,6 +1129,7 @@ export class TransactionController extends BaseController< isGasFeeTokenIgnoredIfBalance, isGasFeeIncluded, isGasFeeSponsored, + ...(isGasFeeSponsored ? { isExternalSign: true } : {}), // To avoid the property to be set as undefined. ...(excludeNativeTokenForFee === undefined ? {} @@ -4077,7 +4078,13 @@ export class TransactionController extends BaseController< }, (txMeta) => { txMeta.gasFeeTokens = gasFeeTokens; - txMeta.isGasFeeSponsored = isGasFeeSponsored; + txMeta.isGasFeeSponsored = + txMeta.isGasFeeSponsored ?? isGasFeeSponsored; + + if (txMeta.isGasFeeSponsored) { + txMeta.isExternalSign = true; + } + txMeta.gasUsed = gasUsed; if (!this.#isBalanceChangesSkipped(txMeta)) { From 10a632ff9393fa5bade8bde55468e0d21a7aba35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20G=C3=B6ktu=C4=9F=20Poyraz?= Date: Wed, 17 Jun 2026 12:57:16 +0200 Subject: [PATCH 5/9] fix(transaction-pay-controller): sync transaction metadata when fiat payment has no payment token (#9158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary When a fiat payment method is selected, `paymentToken` is not present. The previous guard in `syncTransaction` used `||` which required **both** to be present, blocking the fiat-only path entirely. Changed the guard from `&&` to `||` so that having either `paymentToken` or `selectedFiatPayment` is sufficient to proceed. ## Changes - `syncTransaction`: guard changed from `!paymentToken || !selectedFiatPayment` → `!paymentToken && !selectedFiatPayment` - Added test: syncs `tx.metamaskPay` when fiat payment is set but no `paymentToken` - Updated existing test description to reflect new semantics --------- Co-authored-by: Matthew Walsh --- .../transaction-pay-controller/CHANGELOG.md | 4 +++ .../src/utils/quotes.test.ts | 26 ++++++++++++++++++- .../src/utils/quotes.ts | 10 ++++--- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 17116e6f03..5cfd756c33 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Sync transaction metadata when fiat payment is selected but no payment token is present ([#9158](https://github.com/MetaMask/core/pull/9158)) + ## [23.8.0] ### Changed diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts index 69fe267549..a4b047aeae 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -566,7 +566,7 @@ describe('Quotes Utils', () => { expect(strategy.getQuotes).toHaveBeenCalled(); }); - it('clears state if no payment token', async () => { + it('clears state if no payment token and no fiat payment', async () => { await run({ transactionData: { ...TRANSACTION_DATA_MOCK, @@ -772,6 +772,30 @@ describe('Quotes Utils', () => { }); }); + it('updates metrics in metadata for fiat payment with no payment token', async () => { + await run({ + transactionData: { + ...TRANSACTION_DATA_MOCK, + paymentToken: undefined, + fiatPayment: { selectedPaymentMethodId: 'card-123' }, + }, + }); + + const transactionMetaMock = {} as TransactionMeta; + updateTransactionMock.mock.calls[0][1](transactionMetaMock); + + expect(transactionMetaMock).toMatchObject({ + metamaskPay: { + bridgeFeeFiat: TOTALS_MOCK.fees.provider.usd, + chainId: undefined, + networkFeeFiat: TOTALS_MOCK.fees.sourceNetwork.estimate.usd, + targetFiat: TOTALS_MOCK.targetAmount.usd, + tokenAddress: undefined, + totalFiat: TOTALS_MOCK.total.usd, + }, + }); + }); + it('uses provider fee directly as bridgeFeeFiat even when providerFiat breakdown exists', async () => { calculateTotalsMock.mockReturnValue({ ...TOTALS_MOCK, diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index 098911a2de..c8d0d99134 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -163,6 +163,7 @@ export async function updateQuotes( syncTransaction({ batchTransactions, + selectedFiatPayment: fiatPayment?.selectedPaymentMethodId, hasQuotes: quotes.length > 0, isPostQuote, messenger: messenger as never, @@ -203,6 +204,7 @@ export async function updateQuotes( * @param request.isPostQuote - Whether this is a post-quote flow. * @param request.messenger - Messenger instance. * @param request.paymentToken - Payment token (source for standard flows, destination for post-quote). + * @param request.selectedFiatPayment - Selected fiat payment method ID. * @param request.totals - Calculated totals. * @param request.transactionId - ID of the transaction to sync. */ @@ -212,10 +214,12 @@ function syncTransaction({ isPostQuote, messenger, paymentToken, + selectedFiatPayment, totals, transactionId, }: { batchTransactions: BatchTransaction[]; + selectedFiatPayment?: string; hasQuotes: boolean; isPostQuote?: boolean; messenger: TransactionPayControllerMessenger; @@ -223,7 +227,7 @@ function syncTransaction({ totals: TransactionPayTotals; transactionId: string; }): void { - if (!paymentToken) { + if (!paymentToken && !selectedFiatPayment) { return; } @@ -247,11 +251,11 @@ function syncTransaction({ tx.metamaskPay = { bridgeFeeFiat: totals.fees.provider.usd, - chainId: paymentToken.chainId, + chainId: paymentToken?.chainId, isPostQuote, networkFeeFiat: totals.fees.sourceNetwork.estimate.usd, targetFiat: totals.targetAmount.usd, - tokenAddress: paymentToken.address, + tokenAddress: paymentToken?.address, totalFiat: totals.total.usd, }; }, From f2f6bff7364aa5b01078d6f581efe1f8aef8fd01 Mon Sep 17 00:00:00 2001 From: Matthew Grainger <46547583+Matt561@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:26:37 -0400 Subject: [PATCH 6/9] feat: use pending blockTag for balance calls in money-account-balance-service (#9163) ## Explanation Update `@metamask/money-account-balance-service` to use `pending` blockTag for balance calls ## References ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- .../CHANGELOG.md | 5 ++ .../src/money-account-balance-service.test.ts | 61 ++++++++++++++----- .../src/money-account-balance-service.ts | 21 +++++-- 3 files changed, 67 insertions(+), 20 deletions(-) diff --git a/packages/money-account-balance-service/CHANGELOG.md b/packages/money-account-balance-service/CHANGELOG.md index d7f35a0df1..21d05266bb 100644 --- a/packages/money-account-balance-service/CHANGELOG.md +++ b/packages/money-account-balance-service/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Fetch on-chain Money account balances at the `pending` block tag instead of `latest`, so a balance refetch triggered by `TransactionController:transactionConfirmed` returns the post-transaction balance immediately rather than stale data for up to ~20 seconds. ([#9163](https://github.com/MetaMask/core/pull/9163)) + - Applies to `getMoneyAccountBalance`, `getMusdBalance`, `getVmusdBalance`, and `getMusdEquivalentValue`. As a result these reads now reflect pending (mempool-inclusive) state. `getExchangeRate` and the on-chain `Accountant.base()` token-address lookup intentionally remain on `latest`. + ## [2.0.0] ### Added diff --git a/packages/money-account-balance-service/src/money-account-balance-service.test.ts b/packages/money-account-balance-service/src/money-account-balance-service.test.ts index d26b7c0813..9c4d0fb6fb 100644 --- a/packages/money-account-balance-service/src/money-account-balance-service.test.ts +++ b/packages/money-account-balance-service/src/money-account-balance-service.test.ts @@ -820,6 +820,29 @@ describe('MoneyAccountBalanceService', () => { 'client-at-index-0', ); }); + + it('reads the ERC-20 balance at the pending block tag', async () => { + const mockBalanceOf = jest + .fn() + .mockResolvedValue({ toString: () => '5000000' }); + MockContract.mockImplementation( + () => ({ balanceOf: mockBalanceOf }) as unknown as Contract, + ); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: { + ...MOCK_VAULT_CONFIG, + underlyingToken: MOCK_UNDERLYING_TOKEN_ADDRESS, + }, + }, + }); + + await service.getMusdBalance(MOCK_ACCOUNT_ADDRESS); + + expect(mockBalanceOf).toHaveBeenCalledWith(MOCK_ACCOUNT_ADDRESS, { + blockTag: 'pending', + }); + }); }); // ---------------------------------------------------------- @@ -1011,6 +1034,7 @@ describe('MoneyAccountBalanceService', () => { MOCK_ACCOUNT_ADDRESS, MOCK_VAULT_ADDRESS, MOCK_ACCOUNTANT_ADDRESS, + { blockTag: 'pending' }, ); }); @@ -1139,17 +1163,21 @@ describe('MoneyAccountBalanceService', () => { await service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS); - // A single batched request containing exactly the two balance reads. - expect(aggregate3).toHaveBeenCalledWith([ - expect.objectContaining({ - target: MOCK_UNDERLYING_TOKEN_ADDRESS, - allowFailure: false, - }), - expect.objectContaining({ - target: MOCK_LENS_ADDRESS, - allowFailure: false, - }), - ]); + // A single batched request containing exactly the two balance reads, + // read at the pending block tag. + expect(aggregate3).toHaveBeenCalledWith( + [ + expect.objectContaining({ + target: MOCK_UNDERLYING_TOKEN_ADDRESS, + allowFailure: false, + }), + expect.objectContaining({ + target: MOCK_LENS_ADDRESS, + allowFailure: false, + }), + ], + { blockTag: 'pending' }, + ); // The Multicall3 contract is instantiated at the canonical address. expect(MockContract).toHaveBeenCalledWith( MULTICALL3_ADDRESS_BY_CHAIN_ID[MOCK_VAULT_CONFIG.chainId], @@ -1180,10 +1208,13 @@ describe('MoneyAccountBalanceService', () => { expect.anything(), ); // ...and the resolved underlying token is used as the mUSD read target. - expect(aggregate3).toHaveBeenCalledWith([ - expect.objectContaining({ target: MOCK_UNDERLYING_TOKEN_ADDRESS }), - expect.objectContaining({ target: MOCK_LENS_ADDRESS }), - ]); + expect(aggregate3).toHaveBeenCalledWith( + [ + expect.objectContaining({ target: MOCK_UNDERLYING_TOKEN_ADDRESS }), + expect.objectContaining({ target: MOCK_LENS_ADDRESS }), + ], + { blockTag: 'pending' }, + ); }); it('is also callable via the messenger action', async () => { diff --git a/packages/money-account-balance-service/src/money-account-balance-service.ts b/packages/money-account-balance-service/src/money-account-balance-service.ts index 23f62dd80a..7a3692ea2f 100644 --- a/packages/money-account-balance-service/src/money-account-balance-service.ts +++ b/packages/money-account-balance-service/src/money-account-balance-service.ts @@ -61,6 +61,13 @@ type Multicall3Result = { returnData: string; }; +/** + * ethers `CallOverrides` used for BALANCE reads (mUSD, vmUSD, Lens). + * + * We deliberately read at `pending` rather than `latest` to bypass the provider's block cache middleware. + */ +const PENDING_READ_OVERRIDES = { blockTag: 'pending' } as const; + /** * The name of the {@link MoneyAccountBalanceService}, used to namespace the * service's actions and events. @@ -438,7 +445,10 @@ export class MoneyAccountBalanceService extends BaseDataService< ): Promise { const provider = this.#getProvider(chainId); const contract = new Contract(contractAddress, abiERC20, provider); - const balance = await contract.balanceOf(accountAddress); + const balance = await contract.balanceOf( + accountAddress, + PENDING_READ_OVERRIDES, + ); return balance.toString(); } @@ -569,10 +579,10 @@ export class MoneyAccountBalanceService extends BaseDataService< provider, ); const [musdResult, vmusdResult] = - (await multicall3.callStatic.aggregate3(calls)) as [ - Multicall3Result, - Multicall3Result, - ]; + (await multicall3.callStatic.aggregate3( + calls, + PENDING_READ_OVERRIDES, + )) as [Multicall3Result, Multicall3Result]; const musdBalanceBN = erc20.interface.decodeFunctionResult( 'balanceOf', @@ -669,6 +679,7 @@ export class MoneyAccountBalanceService extends BaseDataService< accountAddress, boringVault, accountantAddress, + PENDING_READ_OVERRIDES, ); return { balanceOfInAssets: balanceOfInAssets.toString() }; From e86587758a6e7e2583b7441cfc948443c58d4021 Mon Sep 17 00:00:00 2001 From: cryptodev-2s <109512101+cryptodev-2s@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:39:46 +0200 Subject: [PATCH 7/9] chore: remove outdated migrate-tags guide (#9171) ## Explanation The `docs/processes/migrate-tags.md` guide and its companion `scripts/migrate-tags.sh` script are overkill for what's actually needed: a single tag in `core` for the latest version released from the source repo, so `action-publish-release` doesn't try to re-release it. This PR: - Removes `docs/processes/migrate-tags.md`, the dead link in `docs/README.md`, and the now-orphaned `scripts/migrate-tags.sh`. - Adds a short "tag the latest source-repo release in core" step to `docs/processes/package-migration-process-guide.md` under PR#6, with the `git tag -a @metamask/@ ` command. ## References None. ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- docs/README.md | 1 - docs/processes/migrate-tags.md | 169 ------------------ .../package-migration-process-guide.md | 11 ++ scripts/migrate-tags.sh | 165 ----------------- 4 files changed, 11 insertions(+), 335 deletions(-) delete mode 100644 docs/processes/migrate-tags.md delete mode 100755 scripts/migrate-tags.sh diff --git a/docs/README.md b/docs/README.md index 81c0239766..07d284c743 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,7 +22,6 @@ Hi! Welcome to the contributor documentation for the `core` monorepo. - [Building packages](./processes/building.md) - [Adding new packages to the monorepo](./processes/adding-new-packages.md) - [Migrating external packages to the monorepo](./processes/package-migration-process-guide.md) - - [Migrating tags](./processes/migrate-tags.md) ## Code guidelines diff --git a/docs/processes/migrate-tags.md b/docs/processes/migrate-tags.md deleted file mode 100644 index 58a7d83612..0000000000 --- a/docs/processes/migrate-tags.md +++ /dev/null @@ -1,169 +0,0 @@ -# Migrating tags from other repos - -When migrating libraries into the core monorepo, the original git history is transferred using the `git-filter-repo` tool (instructions [here](./package-migration-process-guide.md)), but tags attached to release commits are excluded from the process. This is because the tag names (`v[major].[minor].[patch]`) first need to be adjusted to conform to the scheme used by the core repo (`@metamask/@[major].[minor].[patch]`). - -The `./scripts/migrate-tags.sh` script automates the process of enumerating the tags and associated release commit messages in the original repo, searching the migrated git history in the core repo's `merged-packages/` directory for each commit message, creating tags with correctly-formatted names and attaching them to the found release commits, and pushing those tags to the core repo. - -## A. Preparations - -- The migration target package must be inside of the `merged-packages/` directory with its git history fully migrated. -- The script must be run from the root directory of the core repo. -- The `/tmp/` directory used during the git history migration process should still be accessible. If not, perform steps 1-5 of [these instructions](https://github.com/MetaMask/core/issues/1079#issuecomment-1700126302) before proceeding. -- If the script isn't executable, run `chmod +x ./scripts/migrate-tags.sh`. -- By default, this script will run in "dry mode", printing out all pairs of release commit hashes and prefixed tag names, but not modifying the local or remote repo in any way. To override this and actually create/push tags, run the script with a `--no-dry-run` flag appended at the end. - -## B. Options - -- `` (required). - - Only supply the package directory name. Exclude the `@metamask/` namespace. -- `-r`, `--remote` (optional): the git remote repo where the tags will be pushed. - - Default if omitted: "test". -- `-v`, `--version-before-package-rename` (optional) - - Default if omitted: `0.0.0`. - - **If `-v` is not passed, all tag names will be prepended with the `@metamask/` namespace.** -- `-t`, `--tag-prefix-before-package-rename` (optional) - - Default if omitted: `` supplied in the first argument. -- `-d`, `--tmp-dir` (optional) - - Default if omitted: `/tmp` - - Specifies the temporary directory where `git-filter-repo` was applied to a clone of the original repo. -- `-p`, `--sed-pattern` (optional): sed pattern for extracting version numbers from the original repo's tag names. - - Default if omitted: `'s/^v//'` - - If the original tag names follow a different naming scheme than `v[major].[minor].[patch]`, adjust this setting. -- `--no-dry-run` (optional): - - Default if omitted: `false`. - - If not specified, the script will run in "dry run" mode. The script will print out all pairs of release commit hashes and prefixed tag names, but without modifying the local or remote repo in any way. - - **This flag MUST be enabled for tags to be created and pushed.** - - Make sure to specify the correct remote repo where the tags will be pushed by using the `-r` flag. - -## C. Usage - -### 1. General Case (package never renamed) - -- For most cases, you will only need to specify the `` as the first argument. - -```shell -> ./scripts/migrate-tags.sh eth-json-rpc-provider -``` - -```output -328a43ed @metamask/eth-json-rpc-provider@1.0.0 -06c41f6a @metamask/eth-json-rpc-provider@1.0.1 -de124c41 @metamask/eth-json-rpc-provider@2.0.0 -0aa45a9a @metamask/eth-json-rpc-provider@2.1.0 -d3a9f01c @metamask/eth-json-rpc-provider@2.2.0 -``` - -### 2. Renamed Package - -- If the migration target package has been renamed, specify the `-v`, `--version-before-package-rename` option. - -```shell -> ./scripts/migrate-tags.sh json-rpc-engine -v 6.1.0 -``` - -```output -67c7fee5 @metamask/json-rpc-engine@7.2.0 -23aa8d9e @metamask/json-rpc-engine@7.1.1 -76394323 @metamask/json-rpc-engine@7.1.0 -22ff65e0 @metamask/json-rpc-engine@7.0.0 -c753c16c @metamask/json-rpc-engine@7.0.0 -670d8dd7 json-rpc-engine@6.1.0 -9646dc26 json-rpc-engine@6.0.0 -... -``` - -- The above output shows two `7.0.0` entries. If any duplicate release commits are found, the script will create and push tags only on the most recent commit. -- The user has the option to supply a custom regex pattern under `-p` to narrow down the search results for the release commits. - -### 3. Package will be Renamed on the first Post-Migration Release - -- If the migration target package will be renamed after the migration, **specify the latest release version** in `-v`. - -```shell -> ./scripts/migrate-tags.sh json-rpc-middleware-stream -v 5.0.1 -``` - -```output -38c007a3 json-rpc-middleware-stream@5.0.1 -c34b1704 json-rpc-middleware-stream@5.0.0 -8c6b70e5 json-rpc-middleware-stream@4.2.3 -f7290013 json-rpc-middleware-stream@4.2.2 -e08455ca json-rpc-middleware-stream@4.2.1 -d90fe43d json-rpc-middleware-stream@4.2.0 -... -``` - -### 4. Non-Dry Mode - -- To override dry run mode and actually create/push tags, run the script with a `--no-dry-run` flag at the end. -- Make sure to specify the correct remote repo where the tags will be pushed by using the `-r` flag. - -```shell -> ./scripts/migrate-tags.sh json-rpc-middleware-stream -v 5.0.1 -r origin --no-dry-run -``` - -```output -Total 0 (delta 0), reused 0 (delta 0), pack-reused 0 -To https://github.com/[USERNAME]/[FORKNAME] - * [new tag] json-rpc-middleware-stream@5.0.1 -> json-rpc-middleware-stream@5.0.1 -Total 0 (delta 0), reused 0 (delta 0), pack-reused 0 -To https://github.com/[USERNAME]/[FORKNAME] - * [new tag] json-rpc-middleware-stream@5.0.0 -> json-rpc-middleware-stream@5.0.0 -Total 0 (delta 0), reused 0 (delta 0), pack-reused 0 - -... - -To https://github.com/[USERNAME]/[FORKNAME] - * [new tag] json-rpc-middleware-stream@2.0.0 -> json-rpc-middleware-stream@2.0.0 -Total 0 (delta 0), reused 0 (delta 0), pack-reused 0 -To https://github.com/[USERNAME]/[FORKNAME] - * [new tag] json-rpc-middleware-stream@1.0.1 -> json-rpc-middleware-stream@1.0.1 -``` - -## D. Verify - -- Check whether the tags have correctly been pushed to the remote repo. - -```shell -> git ls-remote --tags origin | grep 'json-rpc-engine' -``` - -```output -22ff65e0f76710188b527bd5d3f81dd2103c5514 refs/tags/@metamask/json-rpc-engine@7.0.0 -7639432339e60767a8239d681911375833bc3839 refs/tags/@metamask/json-rpc-engine@7.1.0 -23aa8d9e59d9275c0725cb0264057e082034dae9 refs/tags/@metamask/json-rpc-engine@7.1.1 -67c7fee5141f6c0bb2f459c1cb3062c02bbf6a15 refs/tags/@metamask/json-rpc-engine@7.2.0 -304f6efa4d1be2460c9d0bec48224cefcf7fd208 refs/tags/json-rpc-engine@1.0.0 -4909d7fd95a555a7ae18cb1f9840db4fe1f3c85d refs/tags/json-rpc-engine@2.0.0 -93e2b7224f7370468466e2e5e29a2c10da016b11 refs/tags/json-rpc-engine@2.1.0 -286c2716a7b856b95f74d64edd9e653728dd031c refs/tags/json-rpc-engine@2.2.0 -... -``` - -## E. Troubleshooting - -> [!WARNING] -> DO NOT run this script on the core repo until the results have been tested on a fork. - -The following commands should NOT be run on the core repo unless something has gone very wrong. - -### 1. Delete remote tags - -**WARNING**: Proceed with EXTREME CAUTION - -```shell -> git ls-remote --tags | grep '' | cut -f2 | sed 's|refs/tags/||g' | xargs git push --delete -``` - -- ALWAYS create a backup clone repo in advance and delete local tags AFTER remote tags. -- If something goes wrong, try `git push ` to push the local tags to remote. -- If the local tags have been deleted, push the unaltered tags in the backup clone repo to remote. -- If this fails, ask a teammate who has the correct tags on local to push them to remote. - -### 2. Delete local tags - -```shell -> git tag | grep '' | xargs git tag --delete -``` - -- If anything goes wrong, run `git pull --all` and the tags in the remote repo will be restored to local. diff --git a/docs/processes/package-migration-process-guide.md b/docs/processes/package-migration-process-guide.md index 872900d980..31b4d676a0 100644 --- a/docs/processes/package-migration-process-guide.md +++ b/docs/processes/package-migration-process-guide.md @@ -65,6 +65,17 @@ This document outlines the process for migrating a MetaMask library into the cor - [Example PR](https://github.com/MetaMask/core/pull/1872) +#### After PR#6 lands: tag the latest source-repo release in core + +`action-publish-release` checks GitHub for an existing tag before publishing. If the latest version published from the source repo has no matching tag in `core`, the action will try to release that version again and fail. Create a tag in `core` for the last source-repo release and push it to `origin`: + +```shell +git tag -a @metamask/@ +git push origin @metamask/@ +``` + +Find the release commit SHA with `git log --oneline merged-packages/` and match the release commit message from the source repo. + ### **[PR#7]** 2. Reset the CHANGELOG, adding a link to the old repository - Create a fresh CHANGELOG file with no releases diff --git a/scripts/migrate-tags.sh b/scripts/migrate-tags.sh deleted file mode 100755 index 772aeb7411..0000000000 --- a/scripts/migrate-tags.sh +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env bash - -source "$PWD/scripts/semver.sh" - -remote='test' -version_before_package_rename='0.0.0' -tag_prefix_before_package_rename="$1" -tmp_dir='/tmp' -sed_pattern='s/^v//' -dry_run=true - -print-usage() { - cat <&2 - elif [[ $dry_run == true ]]; then - echo "$commit"$'\t'"$tag_name"$'\t'"$message" - else - echo "Creating tag '$tag_name'..." - git tag "$tag_name" "$commit" - git push "$remote" "$tag_name" - fi - done <<<"$(get-commit-tagname-pairs)" -} - -main From 5ed6ac532e34ea01a4192772062c686b5e5c05bc Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 17 Jun 2026 15:54:12 +0200 Subject: [PATCH 8/9] fix(rpc-service): Consider all Infura HTTP errors as service failures except 400 and 429 (#9123) ## Explanation Consider all HTTP errors thrown by `RpcService` when using Infura as service policy failures except if the HTTP status code is `400` or `429`. Previously we would only consider `5xx` HTTP status codes as failures that could trip the circuit. ## References https://consensyssoftware.atlassian.net/browse/WPC-1111 ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --------- Co-authored-by: cryptodev-2s <109512101+cryptodev-2s@users.noreply.github.com> --- packages/controller-utils/CHANGELOG.md | 4 + .../src/create-service-policy.test.ts | 112 ++++++++++++++++++ .../src/create-service-policy.ts | 12 +- packages/network-controller/CHANGELOG.md | 1 + .../src/rpc-service/rpc-service.test.ts | 83 +++++++++++++ .../src/rpc-service/rpc-service.ts | 37 +++++- 6 files changed, 243 insertions(+), 6 deletions(-) diff --git a/packages/controller-utils/CHANGELOG.md b/packages/controller-utils/CHANGELOG.md index e20fed6015..4bce11d590 100644 --- a/packages/controller-utils/CHANGELOG.md +++ b/packages/controller-utils/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Allow overriding `isServiceFailure` in `createServicePolicy` ([#9123](https://github.com/MetaMask/core/pull/9123)) + ### Changed - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) diff --git a/packages/controller-utils/src/create-service-policy.test.ts b/packages/controller-utils/src/create-service-policy.test.ts index 2d38112fe7..27c5fcb7d1 100644 --- a/packages/controller-utils/src/create-service-policy.test.ts +++ b/packages/controller-utils/src/create-service-policy.test.ts @@ -3574,6 +3574,118 @@ describe('createServicePolicy', () => { await expect(policy.execute(mockService)).rejects.toThrow('failure'); }); }); + + describe('using a custom isServiceFailure predicate', () => { + it('opens the circuit when the predicate treats the error as a service failure', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onBreakListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + isServiceFailure: () => true, + }); + policy.onBreak(onBreakListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + jest.runAllTimersAsync(); + await ignoreRejection(promise); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + expect(onBreakListener).toHaveBeenCalledWith({ error }); + }); + + it('never opens the circuit when the predicate does not treat the error as a service failure', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onBreakListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + isServiceFailure: () => false, + }); + policy.onBreak(onBreakListener); + + // Execute more times than the max consecutive failures so that the + // circuit would open if these errors were counted as failures. + for (let i = 0; i < maxConsecutiveFailures + 1; i++) { + const promise = policy.execute(mockService); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + jest.runAllTimersAsync(); + await ignoreRejection(promise); + } + + expect(onBreakListener).not.toHaveBeenCalled(); + }); + + it('calls the predicate with the error thrown by the service', async () => { + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const isServiceFailure = jest.fn(() => true); + const policy = createServicePolicy({ + maxConsecutiveFailures: DEFAULT_MAX_RETRIES + 1, + isServiceFailure, + }); + + const promise = policy.execute(mockService); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + jest.runAllTimersAsync(); + await ignoreRejection(promise); + + expect(isServiceFailure).toHaveBeenCalledWith(error); + }); + }); + + describe('using the default isServiceFailure predicate', () => { + it('opens the circuit for an error with an HTTP status >= 500', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + const error = Object.assign(new Error('failure'), { httpStatus: 500 }); + const mockService = jest.fn(() => { + throw error; + }); + const onBreakListener = jest.fn(); + const policy = createServicePolicy({ maxConsecutiveFailures }); + policy.onBreak(onBreakListener); + + const promise = policy.execute(mockService); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + jest.runAllTimersAsync(); + await ignoreRejection(promise); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + }); + + it('never opens the circuit for an error with an HTTP status < 500', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + const error = Object.assign(new Error('failure'), { httpStatus: 400 }); + const mockService = jest.fn(() => { + throw error; + }); + const onBreakListener = jest.fn(); + const policy = createServicePolicy({ maxConsecutiveFailures }); + policy.onBreak(onBreakListener); + + // Execute more times than the max consecutive failures so that the + // circuit would open if these errors were counted as failures. + for (let i = 0; i < maxConsecutiveFailures + 1; i++) { + const promise = policy.execute(mockService); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + jest.runAllTimersAsync(); + await ignoreRejection(promise); + } + + expect(onBreakListener).not.toHaveBeenCalled(); + }); + }); }); /** diff --git a/packages/controller-utils/src/create-service-policy.ts b/packages/controller-utils/src/create-service-policy.ts index 0c27fdf615..b7e5a91a14 100644 --- a/packages/controller-utils/src/create-service-policy.ts +++ b/packages/controller-utils/src/create-service-policy.ts @@ -53,6 +53,10 @@ export type CreateServicePolicyOptions = { * regarded as degraded (affecting when `onDegraded` is called). */ degradedThreshold?: number; + /** + * Predicate function for when an error should be considered a service failure. + */ + isServiceFailure?: (error: unknown) => boolean; /** * The maximum number of times that the service is allowed to fail before * pausing further retries. @@ -189,7 +193,7 @@ export const DEFAULT_CIRCUIT_BREAK_DURATION = 30 * 60 * 1000; */ export const DEFAULT_DEGRADED_THRESHOLD = 5_000; -const isServiceFailure = (error: unknown): boolean => { +const defaultIsServiceFailure = (error: unknown): boolean => { if ( typeof error === 'object' && error !== null && @@ -199,8 +203,9 @@ const isServiceFailure = (error: unknown): boolean => { return error.httpStatus >= 500; } - // If the error is not an object, or doesn't have a numeric code property, - // consider it a service failure (e.g., network errors, timeouts, etc.) + // If the error is not an object, or doesn't have a numeric httpStatus + // property, consider it a service failure (e.g., network errors, timeouts, + // etc.) return true; }; @@ -283,6 +288,7 @@ export function createServicePolicy( circuitBreakDuration = DEFAULT_CIRCUIT_BREAK_DURATION, degradedThreshold = DEFAULT_DEGRADED_THRESHOLD, backoff = new ExponentialBackoff(), + isServiceFailure = defaultIsServiceFailure, } = options; let availabilityStatus: AvailabilityStatus = AVAILABILITY_STATUSES.Unknown; diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index e648f0cf36..a203ae6398 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The constructor argument `isRpcFailoverEnabled` is no longer available. - `RemoteFeatureFlagController:stateChange` and `RemoteFeatureFlagController:getState` are now required. - Drop `async-mutex` dependency, which was no longer used in source ([#9064](https://github.com/MetaMask/core/pull/9064)) +- Consider all Infura HTTP errors as service failures except `400` and `429` ([#9123](https://github.com/MetaMask/core/pull/9123)) ### Removed diff --git a/packages/network-controller/src/rpc-service/rpc-service.test.ts b/packages/network-controller/src/rpc-service/rpc-service.test.ts index 9c4da444b2..309296cab7 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.test.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.test.ts @@ -344,6 +344,89 @@ describe('RpcService', () => { }); }); + describe('treating errors as service failures', () => { + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + + describe('when the endpoint is an Infura URL', () => { + const endpointUrl = 'https://mainnet.infura.io'; + + it.each([400, 429])( + 'does not break the circuit when the endpoint responds with %d', + async (httpStatus) => { + nock(endpointUrl) + .post('/', jsonRpcRequest) + .times(3) + .reply(httpStatus); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + policyOptions: { maxConsecutiveFailures: 2 }, + }); + + // Make more requests than the max consecutive failures so that the + // circuit would open if these errors were treated as failures. + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + + expect(service.getCircuitState()).toBe(CircuitState.Closed); + }, + ); + + it.each([401, 500])( + 'breaks the circuit when the endpoint responds with %d', + async (httpStatus) => { + nock(endpointUrl) + .post('/', jsonRpcRequest) + .times(2) + .reply(httpStatus); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + policyOptions: { maxConsecutiveFailures: 2 }, + }); + + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + + expect(service.getCircuitState()).toBe(CircuitState.Open); + }, + ); + }); + + describe('when the endpoint is not an Infura URL', () => { + const endpointUrl = 'https://rpc.example.chain'; + + it('does not break the circuit for a 4xx response that is not a server error', async () => { + nock(endpointUrl).post('/', jsonRpcRequest).times(3).reply(401); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + policyOptions: { maxConsecutiveFailures: 2 }, + }); + + // Make more requests than the max consecutive failures so that the + // circuit would open if these errors were treated as failures. + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + + expect(service.getCircuitState()).toBe(CircuitState.Closed); + }); + }); + }); + describe('request', () => { // NOTE: Keep this list synced with CONNECTION_ERRORS describe.each([ diff --git a/packages/network-controller/src/rpc-service/rpc-service.ts b/packages/network-controller/src/rpc-service/rpc-service.ts index c9904f43e6..6e6cba3509 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.ts @@ -67,10 +67,13 @@ export type RpcServiceOptions = { */ logger?: Pick; /** - * Options to pass to `createServicePolicy`. Note that `retryFilterPolicy` is - * not accepted, as it is overwritten. See {@link createServicePolicy}. + * Options to pass to `createServicePolicy`. Note that `retryFilterPolicy` and `isServiceFailure` + * are not accepted, as they are overwritten. See {@link createServicePolicy}. */ - policyOptions?: Omit; + policyOptions?: Omit< + CreateServicePolicyOptions, + 'retryFilterPolicy' | 'isServiceFailure' + >; /** * A function that checks if the user is currently offline. If it returns true, * connection errors will not be retried, preventing degraded and break @@ -284,6 +287,31 @@ function stripCredentialsFromUrl(url: URL): URL { return strippedUrl; } +const INFURA_NON_FAILURE_HTTP_STATUS_CODES = [400, 429]; + +/** + * Predicate function that determines if an error from Infura is treated as a service failure. + * + * @param error - The error. + * @returns True if the error should be treated as a service policy failure. Most errors are treated like failures, + * with the exception of certain HTTP status codes. + */ +function isServiceFailureInfura(error: unknown): boolean { + if ( + typeof error === 'object' && + error !== null && + hasProperty(error, 'httpStatus') && + typeof error.httpStatus === 'number' + ) { + return !INFURA_NON_FAILURE_HTTP_STATUS_CODES.includes(error.httpStatus); + } + + // If the error is not an object, or doesn't have a numeric httpStatus + // property, consider it a service failure (e.g., network errors, timeouts, + // etc.) + return true; +} + /** * This class is responsible for making a request to an endpoint that implements * the JSON-RPC protocol. It is designed to gracefully handle network and server @@ -368,10 +396,13 @@ export class RpcService { this.endpointUrl = stripCredentialsFromUrl(normalizedUrl); this.#logger = logger; + const isInfura = normalizedUrl.hostname.endsWith('.infura.io'); + this.#policy = createServicePolicy({ maxRetries: DEFAULT_MAX_RETRIES, maxConsecutiveFailures: DEFAULT_MAX_CONSECUTIVE_FAILURES, ...policyOptions, + isServiceFailure: isInfura ? isServiceFailureInfura : undefined, retryFilterPolicy: handleWhen((error) => { // If user is offline, don't retry any errors // This prevents degraded/break callbacks from being triggered From f9add2c0ddfa8e526c3f2fa8f93fc5bfa751e8a1 Mon Sep 17 00:00:00 2001 From: cryptodev-2s <109512101+cryptodev-2s@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:59:34 +0200 Subject: [PATCH 9/9] chore: remove orphaned semver.sh helper (#9172) ## Explanation `scripts/semver.sh` was only sourced by `scripts/migrate-tags.sh`, which is being removed in #9171. With that script gone, `semver.sh` is orphaned, so this PR deletes it too. ## References - Follow-up to #9171 ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- scripts/semver.sh | 107 ---------------------------------------------- 1 file changed, 107 deletions(-) delete mode 100644 scripts/semver.sh diff --git a/scripts/semver.sh b/scripts/semver.sh deleted file mode 100644 index 3f6d139571..0000000000 --- a/scripts/semver.sh +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env bash - -# Source: https://github.com/cloudflare/semver_bash/blob/master/semver.sh - -function semverParseInto() { - local RE='[^0-9]*\([0-9]*\)[.]\([0-9]*\)[.]\([0-9]*\)\([0-9A-Za-z-]*\)' - #MAJOR - eval $2=$(echo $1 | sed -e "s#$RE#\1#") - #MINOR - eval $3=$(echo $1 | sed -e "s#$RE#\2#") - #MINOR - eval $4=$(echo $1 | sed -e "s#$RE#\3#") - #SPECIAL - eval $5=$(echo $1 | sed -e "s#$RE#\4#") -} - -function semverEQ() { - local MAJOR_A=0 - local MINOR_A=0 - local PATCH_A=0 - local SPECIAL_A=0 - - local MAJOR_B=0 - local MINOR_B=0 - local PATCH_B=0 - local SPECIAL_B=0 - - semverParseInto $1 MAJOR_A MINOR_A PATCH_A SPECIAL_A - semverParseInto $2 MAJOR_B MINOR_B PATCH_B SPECIAL_B - - if [ $MAJOR_A -ne $MAJOR_B ]; then - return 1 - fi - - if [ $MINOR_A -ne $MINOR_B ]; then - return 1 - fi - - if [ $PATCH_A -ne $PATCH_B ]; then - return 1 - fi - - if [[ "_$SPECIAL_A" != "_$SPECIAL_B" ]]; then - return 1 - fi - - return 0 - -} - -function semverLT() { - local MAJOR_A=0 - local MINOR_A=0 - local PATCH_A=0 - local SPECIAL_A=0 - - local MAJOR_B=0 - local MINOR_B=0 - local PATCH_B=0 - local SPECIAL_B=0 - - semverParseInto $1 MAJOR_A MINOR_A PATCH_A SPECIAL_A - semverParseInto $2 MAJOR_B MINOR_B PATCH_B SPECIAL_B - - if [ $MAJOR_A -lt $MAJOR_B ]; then - return 0 - fi - - if [[ $MAJOR_A -le $MAJOR_B && $MINOR_A -lt $MINOR_B ]]; then - return 0 - fi - - if [[ $MAJOR_A -le $MAJOR_B && $MINOR_A -le $MINOR_B && $PATCH_A -lt $PATCH_B ]]; then - return 0 - fi - - if [[ "_$SPECIAL_A" == "_" ]] && [[ "_$SPECIAL_B" == "_" ]]; then - return 1 - fi - if [[ "_$SPECIAL_A" == "_" ]] && [[ "_$SPECIAL_B" != "_" ]]; then - return 1 - fi - if [[ "_$SPECIAL_A" != "_" ]] && [[ "_$SPECIAL_B" == "_" ]]; then - return 0 - fi - - if [[ "_$SPECIAL_A" < "_$SPECIAL_B" ]]; then - return 0 - fi - - return 1 - -} - -function semverGT() { - semverEQ $1 $2 - local EQ=$? - - semverLT $1 $2 - local LT=$? - - if [ $EQ -ne 0 ] && [ $LT -ne 0 ]; then - return 0 - else - return 1 - fi -}