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
-}