From 620cd4d9f2faf261f97d739e57a8e8d6622a1080 Mon Sep 17 00:00:00 2001
From: Vinicius Stevam <45455812+vinistevam@users.noreply.github.com>
Date: Thu, 13 Nov 2025 08:47:06 +0000
Subject: [PATCH 1/9] feat: Report coverage latency in shield metrics (#7133)
## Explanation
This PR adds latency reporting to shield coverage and signature coverage
results. The latency (in milliseconds) is included as part of the
`metrics` property in each coverage response, allowing downstream
consumers and analytics systems to track and analyze the performance of
coverage checks.
Unit tests have been updated to verify correct latency values in the
metrics.
### Changes
- Add `metrics.latency` to coverage and signature coverage responses.
- Update backend and test logic to use `Date.now()` for latency
measurements.
- Extend unit test coverage to validate latency reporting in result
metrics.
## References
Fixes https://github.com/MetaMask/MetaMask-planning/issues/6136
## 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/contributing.md#updating-changelogs),
highlighting breaking changes as necessary
- [x] I've prepared draft pull requests for clients and consumer
packages to resolve any breaking changes
---
> [!NOTE]
> Add `metrics.latency` to coverage and signature coverage responses,
measuring end-to-end latency (including retries) and update tests and
changelog accordingly.
>
> - **Backend**:
> - Add `metrics.latency` to `GetCoverageResultResponse` and
`CoverageResult` returned by `ShieldRemoteBackend`.
> - Measure end-to-end latency in `#getCoverageResult` using
`Date.now()`, including retries/delays via polling policy.
> - **Types**:
> - Extend `CoverageResult` and `GetCoverageResultResponse` with
`metrics.latency` in `src/types.ts` and `src/backend.ts`.
> - **Tests**:
> - Update `src/backend.test.ts` to assert presence and correctness of
`metrics.latency` for both transaction and signature coverage, including
retry-delay scenarios.
> - **Changelog**:
> - Document addition of latency metrics in
`packages/shield-controller/CHANGELOG.md`.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
fc9d432fa0dfb329f4892beb93e70a1ca1bd74eb. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---------
Co-authored-by: Tuna
Co-authored-by: Chaitanya Potti
---
packages/shield-controller/CHANGELOG.md | 4 +
.../shield-controller/src/backend.test.ts | 115 +++++++++++++++++-
packages/shield-controller/src/backend.ts | 26 +++-
packages/shield-controller/src/types.ts | 3 +
4 files changed, 141 insertions(+), 7 deletions(-)
diff --git a/packages/shield-controller/CHANGELOG.md b/packages/shield-controller/CHANGELOG.md
index fcd14bc3abe..eef13f145e6 100644
--- a/packages/shield-controller/CHANGELOG.md
+++ b/packages/shield-controller/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- Added metrics in the Shield coverage response to track the latency ( [#7133](https://github.com/MetaMask/core/pull/7133))
+
## [2.0.0]
### Changed
diff --git a/packages/shield-controller/src/backend.test.ts b/packages/shield-controller/src/backend.test.ts
index 79adef1327a..b5777f6bbc7 100644
--- a/packages/shield-controller/src/backend.test.ts
+++ b/packages/shield-controller/src/backend.test.ts
@@ -51,6 +51,10 @@ function setup({
}
describe('ShieldRemoteBackend', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
it('should check coverage', async () => {
const { backend, fetchMock, getAccessToken } = setup();
@@ -70,14 +74,24 @@ describe('ShieldRemoteBackend', () => {
const txMeta = generateMockTxMeta();
const coverageResult = await backend.checkCoverage({ txMeta });
- expect(coverageResult).toStrictEqual({ coverageId, ...result });
+ expect({
+ coverageId: coverageResult.coverageId,
+ message: result.message,
+ reasonCode: result.reasonCode,
+ status: result.status,
+ }).toStrictEqual({
+ coverageId,
+ ...result,
+ });
+ expect(typeof coverageResult.metrics.latency).toBe('number');
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(getAccessToken).toHaveBeenCalledTimes(2);
});
it('should check coverage with delay', async () => {
+ const pollInterval = 100;
const { backend, fetchMock, getAccessToken } = setup({
- getCoverageResultPollInterval: 100,
+ getCoverageResultPollInterval: pollInterval,
});
// Mock init coverage check.
@@ -101,13 +115,38 @@ describe('ShieldRemoteBackend', () => {
} as unknown as Response);
const txMeta = generateMockTxMeta();
+
+ // generateMockTxMeta also use Date.now() to set the time, only do this after generateMockTxMeta
+ // Mock Date.now() to control latency measurement
+ // Simulate latency that includes the retry delay (poll interval + processing time)
+ let callCount = 0;
+ const startTime = 1000;
+ const expectedLatency = pollInterval + 50; // poll interval + processing time
+ const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => {
+ callCount += 1;
+ // First call: start of #getCoverageResult
+ if (callCount === 1) {
+ return startTime;
+ }
+ // Final call: end of #getCoverageResult (after retry delay)
+ return startTime + expectedLatency;
+ });
+
const coverageResult = await backend.checkCoverage({ txMeta });
- expect(coverageResult).toStrictEqual({
+
+ expect(coverageResult).toMatchObject({
coverageId,
- ...result,
+ status: result.status,
+ message: result.message,
+ reasonCode: result.reasonCode,
});
+ expect(coverageResult.metrics.latency).toBe(expectedLatency);
+ // Latency should include the retry delay (at least the poll interval)
+ expect(coverageResult.metrics.latency).toBeGreaterThanOrEqual(pollInterval);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(getAccessToken).toHaveBeenCalledTimes(2);
+
+ nowSpy.mockRestore();
});
it('should throw on init coverage check failure', async () => {
@@ -187,6 +226,66 @@ describe('ShieldRemoteBackend', () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
+ it('returns latency in coverageResult', async () => {
+ const { backend, fetchMock } = setup();
+
+ fetchMock.mockResolvedValueOnce({
+ status: 200,
+ json: jest.fn().mockResolvedValue({ coverageId: 'coverageId' }),
+ } as unknown as Response);
+
+ const result = { status: 'covered', message: 'ok', reasonCode: 'E104' };
+ fetchMock.mockResolvedValueOnce({
+ status: 200,
+ json: jest.fn().mockResolvedValue(result),
+ } as unknown as Response);
+
+ let nowValue = 1000;
+ const latencyMs = 123;
+ const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => {
+ const val = nowValue;
+ nowValue += latencyMs;
+ return val;
+ });
+
+ const txMeta = generateMockTxMeta();
+ const coverageResult = await backend.checkCoverage({ txMeta });
+ expect(coverageResult.metrics.latency).toBe(latencyMs);
+
+ nowSpy.mockRestore();
+ });
+
+ it('returns latency in signatureCoverageResult', async () => {
+ const { backend, fetchMock } = setup();
+
+ fetchMock.mockResolvedValueOnce({
+ status: 200,
+ json: jest.fn().mockResolvedValue({ coverageId: 'coverageId' }),
+ } as unknown as Response);
+
+ const result = { status: 'covered', message: 'ok', reasonCode: 'E104' };
+ fetchMock.mockResolvedValueOnce({
+ status: 200,
+ json: jest.fn().mockResolvedValue(result),
+ } as unknown as Response);
+
+ let nowValue = 2000;
+ const latencyMs = 456;
+ const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => {
+ const val = nowValue;
+ nowValue += latencyMs;
+ return val;
+ });
+
+ const signatureRequest = generateMockSignatureRequest();
+ const coverageResult = await backend.checkSignatureCoverage({
+ signatureRequest,
+ });
+ expect(coverageResult.metrics.latency).toBe(latencyMs);
+
+ nowSpy.mockRestore();
+ });
+
describe('checkSignatureCoverage', () => {
it('should check signature coverage', async () => {
const { backend, fetchMock, getAccessToken } = setup();
@@ -209,10 +308,16 @@ describe('ShieldRemoteBackend', () => {
const coverageResult = await backend.checkSignatureCoverage({
signatureRequest,
});
- expect(coverageResult).toStrictEqual({
+ expect({
+ coverageId: coverageResult.coverageId,
+ message: result.message,
+ reasonCode: result.reasonCode,
+ status: result.status,
+ }).toStrictEqual({
coverageId,
...result,
});
+ expect(typeof coverageResult.metrics.latency).toBe('number');
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(getAccessToken).toHaveBeenCalledTimes(2);
});
diff --git a/packages/shield-controller/src/backend.ts b/packages/shield-controller/src/backend.ts
index a748bedcde0..0db6e677ef2 100644
--- a/packages/shield-controller/src/backend.ts
+++ b/packages/shield-controller/src/backend.ts
@@ -57,6 +57,9 @@ export type GetCoverageResultResponse = {
message?: string;
reasonCode?: string;
status: CoverageStatus;
+ metrics: {
+ latency?: number;
+ };
};
export class ShieldRemoteBackend implements ShieldBackend {
@@ -117,6 +120,7 @@ export class ShieldRemoteBackend implements ShieldBackend {
message: coverageResult.message,
reasonCode: coverageResult.reasonCode,
status: coverageResult.status,
+ metrics: coverageResult.metrics,
};
}
@@ -143,6 +147,7 @@ export class ShieldRemoteBackend implements ShieldBackend {
message: coverageResult.message,
reasonCode: coverageResult.reasonCode,
status: coverageResult.status,
+ metrics: coverageResult.metrics,
};
}
@@ -220,6 +225,9 @@ export class ShieldRemoteBackend implements ShieldBackend {
const headers = await this.#createHeaders();
+ // Start measuring total end-to-end latency including retries and delays
+ const startTime = Date.now();
+
const getCoverageResultFn = async (signal: AbortSignal) => {
const res = await this.#fetch(coverageResultUrl, {
method: 'POST',
@@ -227,8 +235,10 @@ export class ShieldRemoteBackend implements ShieldBackend {
body: JSON.stringify(reqBody),
signal,
});
+
if (res.status === 200) {
- return (await res.json()) as GetCoverageResultResponse;
+ // Return the result without latency here - we'll add total latency after polling completes
+ return (await res.json()) as Omit;
}
// parse the error message from the response body
@@ -242,7 +252,19 @@ export class ShieldRemoteBackend implements ShieldBackend {
throw new HttpError(res.status, errorMessage);
};
- return this.#pollingPolicy.start(requestId, getCoverageResultFn);
+ const result = await this.#pollingPolicy.start(
+ requestId,
+ getCoverageResultFn,
+ );
+
+ // Calculate total end-to-end latency including all retries and delays
+ const now = Date.now();
+ const totalLatency = now - startTime;
+
+ return {
+ ...result,
+ metrics: { latency: totalLatency },
+ } as GetCoverageResultResponse;
}
async #createHeaders() {
diff --git a/packages/shield-controller/src/types.ts b/packages/shield-controller/src/types.ts
index ab00daabd85..1c9a911c691 100644
--- a/packages/shield-controller/src/types.ts
+++ b/packages/shield-controller/src/types.ts
@@ -6,6 +6,9 @@ export type CoverageResult = {
message?: string;
reasonCode?: string;
status: CoverageStatus;
+ metrics: {
+ latency?: number;
+ };
};
export const coverageStatuses = ['covered', 'malicious', 'unknown'] as const;
From 9b84201fc17fa592dcfdb701762e987eecaa1269 Mon Sep 17 00:00:00 2001
From: Nguyen Anh Tu
Date: Thu, 13 Nov 2025 16:10:20 +0700
Subject: [PATCH 2/9] Relase/676.0.0 (#7142)
## Explanation
Minor releases of shield-controller
## 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/contributing.md#updating-changelogs),
highlighting breaking changes as necessary
- [x] I've prepared draft pull requests for clients and consumer
packages to resolve any breaking changes
---
> [!NOTE]
> Publishes shield-controller 2.1.0 adding latency metrics and updates
monorepo version to 676.0.0.
>
> - **Packages**:
> - `@metamask/shield-controller` v2.1.0
> - Added latency metrics in Shield coverage responses.
> - Updated changelog entries and links.
> - Monorepo `package.json`: version bumped to `676.0.0`.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
4d83b16d5c4da459309c08b34bd8747656ea401c. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
package.json | 2 +-
packages/shield-controller/CHANGELOG.md | 5 ++++-
packages/shield-controller/package.json | 2 +-
3 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/package.json b/package.json
index b2615bed4ce..b36d2aa0c99 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@metamask/core-monorepo",
- "version": "675.0.0",
+ "version": "676.0.0",
"private": true,
"description": "Monorepo for packages shared between MetaMask clients",
"repository": {
diff --git a/packages/shield-controller/CHANGELOG.md b/packages/shield-controller/CHANGELOG.md
index eef13f145e6..30fa896757b 100644
--- a/packages/shield-controller/CHANGELOG.md
+++ b/packages/shield-controller/CHANGELOG.md
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [2.1.0]
+
### Added
- Added metrics in the Shield coverage response to track the latency ( [#7133](https://github.com/MetaMask/core/pull/7133))
@@ -128,7 +130,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Initial release of the shield-controller package ([#6137](https://github.com/MetaMask/core/pull/6137)
-[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@2.0.0...HEAD
+[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@2.1.0...HEAD
+[2.1.0]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@2.0.0...@metamask/shield-controller@2.1.0
[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@1.2.0...@metamask/shield-controller@2.0.0
[1.2.0]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@1.1.0...@metamask/shield-controller@1.2.0
[1.1.0]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@1.0.0...@metamask/shield-controller@1.1.0
diff --git a/packages/shield-controller/package.json b/packages/shield-controller/package.json
index e220f2f009d..2bc2b31ec71 100644
--- a/packages/shield-controller/package.json
+++ b/packages/shield-controller/package.json
@@ -1,6 +1,6 @@
{
"name": "@metamask/shield-controller",
- "version": "2.0.0",
+ "version": "2.1.0",
"description": "Controller handling shield transaction coverage logic",
"keywords": [
"MetaMask",
From 01702fec9a59f8731523174f649f1e113e70d8bc Mon Sep 17 00:00:00 2001
From: Matthew Walsh
Date: Thu, 13 Nov 2025 09:18:33 +0000
Subject: [PATCH 3/9] feat: gas fee token option (#7123)
## Explanation
Support providing `gasFeeToken` when adding a transaction or transaction
batch.
## References
Related to
[#6173](https://github.com/MetaMask/MetaMask-planning/issues/6173)
## 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/contributing.md#updating-changelogs),
highlighting breaking changes as necessary
- [x] I've prepared draft pull requests for clients and consumer
packages to resolve any breaking changes
---
> [!NOTE]
> Adds optional gas fee token support to transaction APIs and
validates/removes it before publish based on native balance, with
supporting balance utilities and tests.
>
> - **API/Types**:
> - Add optional `gasFeeToken` to `addTransaction` and
`addTransactionBatch`.
> - Extend `TransactionMeta` with `isGasFeeTokenIgnoredIfBalance`;
initialize `selectedGasFeeToken` and flag on add.
> - **Publish Flow**:
> - New `checkGasFeeTokenBeforePublish` ensures native balance
sufficiency; unsets `selectedGasFeeToken` and clears `isExternalSign` if
native balance covers gas; fetches tokens if missing; throws if invalid
selection.
> - Refactor `TransactionController` to call this check and add
`#getGasFeeTokens` helper.
> - **Utils**:
> - Add `balance` module (`getNativeBalance`,
`isNativeBalanceSufficientForGas`) using `bignumber.js`; use it in
`balance-changes`.
> - Enhance `gas-fee-tokens` to build auth lists and parse sponsorship;
add tests.
> - **Batch**:
> - Pass `gasFeeToken` through `addTransactionBatch` path.
> - **Misc**:
> - Update tests; lower Jest function coverage threshold; add
`bignumber.js` dependency; update changelog.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
2ffc7e8568047a11bb61a39257c77363b9ed382f. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
packages/transaction-controller/CHANGELOG.md | 5 +
.../transaction-controller/jest.config.js | 2 +-
packages/transaction-controller/package.json | 1 +
.../src/TransactionController.test.ts | 2 +
.../src/TransactionController.ts | 48 ++++++--
packages/transaction-controller/src/types.ts | 9 ++
.../src/utils/balance-changes.test.ts | 2 +-
.../src/utils/balance-changes.ts | 10 +-
.../src/utils/balance.test.ts | 68 +++++++++++
.../src/utils/balance.ts | 52 +++++++++
.../transaction-controller/src/utils/batch.ts | 2 +
.../src/utils/gas-fee-tokens.test.ts | 109 ++++++++++++++++--
.../src/utils/gas-fee-tokens.ts | 77 +++++++++++++
yarn.lock | 1 +
14 files changed, 358 insertions(+), 30 deletions(-)
create mode 100644 packages/transaction-controller/src/utils/balance.test.ts
create mode 100644 packages/transaction-controller/src/utils/balance.ts
diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md
index d1bccf9fb33..64f82485329 100644
--- a/packages/transaction-controller/CHANGELOG.md
+++ b/packages/transaction-controller/CHANGELOG.md
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- Add optional `gasFeeToken` property to `addTransaction` and `addTransactionBatch` methods ([#7123](https://github.com/MetaMask/core/pull/7123))
+ - Also add optional `gasFeeToken` and `isGasFeeTokenIgnoredIfBalance` properties to `TransactionMeta`.
+
## [61.2.0]
### Added
diff --git a/packages/transaction-controller/jest.config.js b/packages/transaction-controller/jest.config.js
index 4a6ed4accc0..4b75427e9d2 100644
--- a/packages/transaction-controller/jest.config.js
+++ b/packages/transaction-controller/jest.config.js
@@ -18,7 +18,7 @@ module.exports = merge(baseConfig, {
coverageThreshold: {
global: {
branches: 91.76,
- functions: 93.24,
+ functions: 92.76,
lines: 96.83,
statements: 96.82,
},
diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json
index 22e5c16a5a1..ab734742f42 100644
--- a/packages/transaction-controller/package.json
+++ b/packages/transaction-controller/package.json
@@ -64,6 +64,7 @@
"@metamask/rpc-errors": "^7.0.2",
"@metamask/utils": "^11.8.1",
"async-mutex": "^0.5.0",
+ "bignumber.js": "^9.1.2",
"bn.js": "^5.2.1",
"eth-method-registry": "^4.0.0",
"fast-json-patch": "^3.1.1",
diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts
index e9c865d50e7..3591a166ed3 100644
--- a/packages/transaction-controller/src/TransactionController.test.ts
+++ b/packages/transaction-controller/src/TransactionController.test.ts
@@ -1693,10 +1693,12 @@ describe('TransactionController', () => {
isFirstTimeInteraction: undefined,
isGasFeeIncluded: undefined,
isGasFeeSponsored: undefined,
+ isGasFeeTokenIgnoredIfBalance: false,
nestedTransactions: undefined,
networkClientId: NETWORK_CLIENT_ID_MOCK,
origin: undefined,
securityAlertResponse: undefined,
+ selectedGasFeeToken: undefined,
sendFlowHistory: expect.any(Array),
status: TransactionStatus.unapproved as const,
time: expect.any(Number),
diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts
index b89a718bafc..3fa98332ac1 100644
--- a/packages/transaction-controller/src/TransactionController.ts
+++ b/packages/transaction-controller/src/TransactionController.ts
@@ -137,7 +137,10 @@ import {
import { validateConfirmedExternalTransaction } from './utils/external-transactions';
import { updateFirstTimeInteraction } from './utils/first-time-interaction';
import { addGasBuffer, estimateGas, updateGas } from './utils/gas';
-import { getGasFeeTokens } from './utils/gas-fee-tokens';
+import {
+ checkGasFeeTokenBeforePublish,
+ getGasFeeTokens,
+} from './utils/gas-fee-tokens';
import { updateGasFees } from './utils/gas-fees';
import { getGasFeeFlow } from './utils/gas-flow';
import {
@@ -1215,6 +1218,7 @@ export class TransactionController extends BaseController<
batchId,
deviceConfirmedOn,
disableGasBuffer,
+ gasFeeToken,
isGasFeeIncluded,
isGasFeeSponsored,
method,
@@ -1315,6 +1319,7 @@ export class TransactionController extends BaseController<
deviceConfirmedOn,
disableGasBuffer,
id: random(),
+ isGasFeeTokenIgnoredIfBalance: Boolean(gasFeeToken),
isGasFeeIncluded,
isGasFeeSponsored,
isFirstTimeInteraction: undefined,
@@ -1322,6 +1327,7 @@ export class TransactionController extends BaseController<
networkClientId,
origin,
securityAlertResponse,
+ selectedGasFeeToken: gasFeeToken,
status: TransactionStatus.unapproved as const,
time: Date.now(),
txParams,
@@ -3114,6 +3120,20 @@ export class TransactionController extends BaseController<
clearApprovingTransactionId = () =>
this.#approvingTransactionIds.delete(transactionId);
+ const { networkClientId } = transactionMeta;
+ const ethQuery = this.#getEthQuery({ networkClientId });
+
+ await checkGasFeeTokenBeforePublish({
+ ethQuery,
+ fetchGasFeeTokens: async (tx) =>
+ (await this.#getGasFeeTokens(tx)).gasFeeTokens,
+ transaction: transactionMeta,
+ updateTransaction: (txId, fn) =>
+ this.#updateTransactionInternal({ transactionId: txId }, fn),
+ });
+
+ transactionMeta = this.#getTransactionOrThrow(transactionId);
+
const [nonce, releaseNonce] = await getNextNonce(
transactionMeta,
(address: string) =>
@@ -3165,9 +3185,6 @@ export class TransactionController extends BaseController<
return ApprovalState.NotApproved;
}
- const { networkClientId } = transactionMeta;
- const ethQuery = this.#getEthQuery({ networkClientId });
-
let preTxBalance: string | undefined;
const shouldUpdatePreTxBalance =
transactionMeta.type === TransactionType.swap;
@@ -4255,14 +4272,8 @@ export class TransactionController extends BaseController<
};
}
- const gasFeeTokensResponse = await getGasFeeTokens({
- chainId,
- getSimulationConfig: this.#getSimulationConfig,
- isEIP7702GasFeeTokensEnabled: this.#isEIP7702GasFeeTokensEnabled,
- messenger: this.messenger,
- publicKeyEIP7702: this.#publicKeyEIP7702,
- transactionMeta,
- });
+ const gasFeeTokensResponse = await this.#getGasFeeTokens(transactionMeta);
+
gasFeeTokens = gasFeeTokensResponse?.gasFeeTokens ?? [];
isGasFeeSponsored = gasFeeTokensResponse?.isGasFeeSponsored ?? false;
}
@@ -4637,4 +4648,17 @@ export class TransactionController extends BaseController<
return { transactionHash };
}
+
+ async #getGasFeeTokens(transaction: TransactionMeta) {
+ const { chainId } = transaction;
+
+ return await getGasFeeTokens({
+ chainId,
+ getSimulationConfig: this.#getSimulationConfig,
+ isEIP7702GasFeeTokensEnabled: this.#isEIP7702GasFeeTokensEnabled,
+ messenger: this.messenger,
+ publicKeyEIP7702: this.#publicKeyEIP7702,
+ transactionMeta: transaction,
+ });
+ }
}
diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts
index b23a567785f..b41c9462b16 100644
--- a/packages/transaction-controller/src/types.ts
+++ b/packages/transaction-controller/src/types.ts
@@ -268,6 +268,9 @@ export type TransactionMeta = {
/** Whether MetaMask will be compensated for the gas fee by the transaction. */
isGasFeeIncluded?: boolean;
+ /** Whether the `selectedGasFeeToken` is only used if the user has insufficient native balance. */
+ isGasFeeTokenIgnoredIfBalance?: boolean;
+
/** Whether the intent of the transaction was achieved via an alternate route or chain. */
isIntentComplete?: boolean;
@@ -1723,6 +1726,9 @@ export type TransactionBatchRequest = {
/** Address of the account to submit the transaction batch. */
from: Hex;
+ /** Address of an ERC-20 token to pay for the gas fee, if the user has insufficient native balance. */
+ gasFeeToken?: Hex;
+
/** Whether MetaMask will be compensated for the gas fee by the transaction. */
isGasFeeIncluded?: boolean;
@@ -2061,6 +2067,9 @@ export type AddTransactionOptions = {
/** Whether to disable the gas estimation buffer. */
disableGasBuffer?: boolean;
+ /** Address of an ERC-20 token to pay for the gas fee, if the user has insufficient native balance. */
+ gasFeeToken?: Hex;
+
/** Whether MetaMask will be compensated for the gas fee by the transaction. */
isGasFeeIncluded?: boolean;
diff --git a/packages/transaction-controller/src/utils/balance-changes.test.ts b/packages/transaction-controller/src/utils/balance-changes.test.ts
index 9e92e619209..ed507af2796 100644
--- a/packages/transaction-controller/src/utils/balance-changes.test.ts
+++ b/packages/transaction-controller/src/utils/balance-changes.test.ts
@@ -282,7 +282,7 @@ function mockParseLog({
}
}
-describe('Simulation Utils', () => {
+describe('Balance Change Utils', () => {
const simulateTransactionsMock = jest.mocked(simulateTransactions);
const queryMock = jest.mocked(query);
diff --git a/packages/transaction-controller/src/utils/balance-changes.ts b/packages/transaction-controller/src/utils/balance-changes.ts
index c8c01ddc243..a49404535a9 100644
--- a/packages/transaction-controller/src/utils/balance-changes.ts
+++ b/packages/transaction-controller/src/utils/balance-changes.ts
@@ -1,11 +1,12 @@
import type { Fragment, LogDescription, Result } from '@ethersproject/abi';
import { Interface } from '@ethersproject/abi';
-import { hexToBN, query, toHex } from '@metamask/controller-utils';
+import { hexToBN, toHex } from '@metamask/controller-utils';
import type EthQuery from '@metamask/eth-query';
import { abiERC20, abiERC721, abiERC1155 } from '@metamask/metamask-eth-abis';
import { createModuleLogger, type Hex } from '@metamask/utils';
import BN from 'bn.js';
+import { getNativeBalance } from './balance';
import { simulateTransactions } from '../api/simulation-api';
import type {
SimulationResponseLog,
@@ -726,11 +727,8 @@ async function baseRequest({
log('Required balance', requiredBalanceHex);
- const currentBalanceHex = (await query(ethQuery, 'getBalance', [
- from,
- 'latest',
- ])) as Hex;
-
+ const { balanceRaw } = await getNativeBalance(from, ethQuery);
+ const currentBalanceHex = toHex(balanceRaw);
const currentBalanceBN = hexToBN(currentBalanceHex);
log('Current balance', currentBalanceHex);
diff --git a/packages/transaction-controller/src/utils/balance.test.ts b/packages/transaction-controller/src/utils/balance.test.ts
new file mode 100644
index 00000000000..01800c54d78
--- /dev/null
+++ b/packages/transaction-controller/src/utils/balance.test.ts
@@ -0,0 +1,68 @@
+import { query, toHex } from '@metamask/controller-utils';
+import type EthQuery from '@metamask/eth-query';
+
+import { getNativeBalance, isNativeBalanceSufficientForGas } from './balance';
+import type { TransactionMeta } from '..';
+
+jest.mock('@metamask/controller-utils', () => ({
+ ...jest.requireActual('@metamask/controller-utils'),
+ query: jest.fn(),
+}));
+
+const ETH_QUERY_MOCK = {} as EthQuery;
+const BALANCE_MOCK = '21000000000000';
+
+const TRANSACTION_META_MOCK = {
+ txParams: {
+ from: '0x1234',
+ gas: toHex(21000),
+ maxFeePerGas: toHex(1000000000), // 1 Gwei
+ },
+} as TransactionMeta;
+
+describe('Balance Utils', () => {
+ const queryMock = jest.mocked(query);
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+
+ queryMock.mockResolvedValue(toHex(BALANCE_MOCK));
+ });
+
+ describe('getNativeBalance', () => {
+ it('returns native balance', async () => {
+ const result = await getNativeBalance('0x1234', ETH_QUERY_MOCK);
+
+ expect(result).toStrictEqual({
+ balanceRaw: BALANCE_MOCK,
+ balanceHuman: '0.000021',
+ });
+ });
+ });
+
+ describe('isNativeBalanceSufficientForGas', () => {
+ it('returns true if balance is sufficient for gas', async () => {
+ const result = await isNativeBalanceSufficientForGas(
+ TRANSACTION_META_MOCK,
+ ETH_QUERY_MOCK,
+ );
+
+ expect(result).toBe(true);
+ });
+
+ it('returns false if balance is insufficient for gas', async () => {
+ const result = await isNativeBalanceSufficientForGas(
+ {
+ ...TRANSACTION_META_MOCK,
+ txParams: {
+ ...TRANSACTION_META_MOCK.txParams,
+ gas: toHex(21001),
+ },
+ },
+ ETH_QUERY_MOCK,
+ );
+
+ expect(result).toBe(false);
+ });
+ });
+});
diff --git a/packages/transaction-controller/src/utils/balance.ts b/packages/transaction-controller/src/utils/balance.ts
new file mode 100644
index 00000000000..1a362d60c36
--- /dev/null
+++ b/packages/transaction-controller/src/utils/balance.ts
@@ -0,0 +1,52 @@
+import { query } from '@metamask/controller-utils';
+import type EthQuery from '@metamask/eth-query';
+import type { Hex } from '@metamask/utils';
+import { BigNumber } from 'bignumber.js';
+
+import type { TransactionMeta } from '..';
+
+/**
+ * Get the native balance for an address.
+ *
+ * @param address - Address to get the balance for.
+ * @param ethQuery - EthQuery instance to use.
+ * @returns Balance in both human-readable and raw format.
+ */
+export async function getNativeBalance(address: Hex, ethQuery: EthQuery) {
+ const balanceRawHex = (await query(ethQuery, 'getBalance', [
+ address,
+ 'latest',
+ ])) as Hex;
+
+ const balanceRaw = new BigNumber(balanceRawHex).toString(10);
+ const balanceHuman = new BigNumber(balanceRaw).shiftedBy(-18).toString(10);
+
+ return {
+ balanceHuman,
+ balanceRaw,
+ };
+}
+
+/**
+ * Determine if the native balance is sufficient to cover max gas cost.
+ *
+ * @param transaction - Transaction metadata.
+ * @param ethQuery - EthQuery instance.
+ * @returns True if the native balance is sufficient, false otherwise.
+ */
+export async function isNativeBalanceSufficientForGas(
+ transaction: TransactionMeta,
+ ethQuery: EthQuery,
+): Promise {
+ const from = transaction.txParams.from as Hex;
+
+ const gasCostRawValue = new BigNumber(
+ transaction.txParams.gas ?? '0x0',
+ ).multipliedBy(
+ transaction.txParams.maxFeePerGas ?? transaction.txParams.gasPrice ?? '0x0',
+ );
+
+ const { balanceRaw } = await getNativeBalance(from, ethQuery);
+
+ return gasCostRawValue.isLessThanOrEqualTo(balanceRaw);
+}
diff --git a/packages/transaction-controller/src/utils/batch.ts b/packages/transaction-controller/src/utils/batch.ts
index 3e5ead32db8..b0b3385d3b7 100644
--- a/packages/transaction-controller/src/utils/batch.ts
+++ b/packages/transaction-controller/src/utils/batch.ts
@@ -289,6 +289,7 @@ async function addTransactionBatchWith7702(
const {
batchId: batchIdOverride,
from,
+ gasFeeToken,
networkClientId,
origin,
requireApproval,
@@ -400,6 +401,7 @@ async function addTransactionBatchWith7702(
const { result } = await addTransaction(txParams, {
batchId,
+ gasFeeToken,
isGasFeeIncluded: userRequest.isGasFeeIncluded,
isGasFeeSponsored: userRequest.isGasFeeSponsored,
nestedTransactions,
diff --git a/packages/transaction-controller/src/utils/gas-fee-tokens.test.ts b/packages/transaction-controller/src/utils/gas-fee-tokens.test.ts
index febdf38b2c4..50579834418 100644
--- a/packages/transaction-controller/src/utils/gas-fee-tokens.test.ts
+++ b/packages/transaction-controller/src/utils/gas-fee-tokens.test.ts
@@ -1,10 +1,17 @@
+import type EthQuery from '@metamask/eth-query';
+import type { Hex } from '@metamask/utils';
import { cloneDeep } from 'lodash';
+import { isNativeBalanceSufficientForGas } from './balance';
import { doesChainSupportEIP7702 } from './eip7702';
import { getEIP7702UpgradeContractAddress } from './feature-flags';
import type { GetGasFeeTokensRequest } from './gas-fee-tokens';
-import { getGasFeeTokens } from './gas-fee-tokens';
+import {
+ checkGasFeeTokenBeforePublish,
+ getGasFeeTokens,
+} from './gas-fee-tokens';
import type {
+ GasFeeToken,
GetSimulationConfig,
TransactionControllerMessenger,
TransactionMeta,
@@ -14,32 +21,41 @@ import { simulateTransactions } from '../api/simulation-api';
jest.mock('../api/simulation-api');
jest.mock('./eip7702');
jest.mock('./feature-flags');
+jest.mock('./balance');
const CHAIN_ID_MOCK = '0x1';
-const TOKEN_ADDRESS_1_MOCK = '0x1234567890abcdef1234567890abcdef12345678';
+const TOKEN_ADDRESS_1_MOCK =
+ '0x1234567890abcdef1234567890abcdef12345678' as Hex;
const TOKEN_ADDRESS_2_MOCK = '0xabcdef1234567890abcdef1234567890abcdef12';
const UPGRADE_CONTRACT_ADDRESS_MOCK =
'0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef';
+const TRANSACTION_META_MOCK = {
+ txParams: {
+ from: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef',
+ to: '0x1234567890abcdef1234567890abcdef1234567a',
+ value: '0x1000000000000000000',
+ data: '0x',
+ },
+} as TransactionMeta;
+
const REQUEST_MOCK: GetGasFeeTokensRequest = {
chainId: CHAIN_ID_MOCK,
isEIP7702GasFeeTokensEnabled: jest.fn().mockResolvedValue(true),
getSimulationConfig: jest.fn(),
messenger: {} as TransactionControllerMessenger,
publicKeyEIP7702: '0x123',
- transactionMeta: {
- txParams: {
- from: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef',
- to: '0x1234567890abcdef1234567890abcdef1234567a',
- value: '0x1000000000000000000',
- data: '0x',
- },
- } as TransactionMeta,
+ transactionMeta: TRANSACTION_META_MOCK,
};
describe('Gas Fee Tokens Utils', () => {
const simulateTransactionsMock = jest.mocked(simulateTransactions);
const doesChainSupportEIP7702Mock = jest.mocked(doesChainSupportEIP7702);
+
+ const isNativeBalanceSufficientForGasMock = jest.mocked(
+ isNativeBalanceSufficientForGas,
+ );
+
const getEIP7702UpgradeContractAddressMock = jest.mocked(
getEIP7702UpgradeContractAddress,
);
@@ -50,6 +66,8 @@ describe('Gas Fee Tokens Utils', () => {
getEIP7702UpgradeContractAddressMock.mockReturnValue(
UPGRADE_CONTRACT_ADDRESS_MOCK,
);
+
+ isNativeBalanceSufficientForGasMock.mockResolvedValue(false);
});
describe('getGasFeeTokens', () => {
@@ -376,4 +394,75 @@ describe('Gas Fee Tokens Utils', () => {
);
});
});
+
+ describe('checkGasFeeTokenBeforePublish', () => {
+ let request: Parameters[0];
+
+ beforeEach(() => {
+ request = {
+ ethQuery: {} as EthQuery,
+ fetchGasFeeTokens: jest.fn(),
+ transaction: cloneDeep(TRANSACTION_META_MOCK),
+ updateTransaction: jest.fn(),
+ };
+ });
+
+ it('throws if gas fee token not found in gas fee tokens', async () => {
+ request.transaction.isGasFeeTokenIgnoredIfBalance = true;
+ request.transaction.selectedGasFeeToken = TOKEN_ADDRESS_1_MOCK;
+ request.transaction.gasFeeTokens = [];
+
+ await expect(checkGasFeeTokenBeforePublish(request)).rejects.toThrow(
+ 'Gas fee token not found and insufficient native balance',
+ );
+ });
+
+ it('updates gas fee tokens if undefined', async () => {
+ request.transaction.isGasFeeTokenIgnoredIfBalance = true;
+ request.transaction.selectedGasFeeToken = TOKEN_ADDRESS_1_MOCK;
+ request.transaction.gasFeeTokens = undefined;
+
+ jest.mocked(request.fetchGasFeeTokens).mockResolvedValueOnce([
+ {
+ tokenAddress: TOKEN_ADDRESS_1_MOCK,
+ } as GasFeeToken,
+ ]);
+
+ await checkGasFeeTokenBeforePublish(request);
+
+ expect(request.fetchGasFeeTokens).toHaveBeenCalledTimes(1);
+ });
+
+ it('removes selected gas fee token if native balance sufficient', async () => {
+ request.transaction.isGasFeeTokenIgnoredIfBalance = true;
+ request.transaction.selectedGasFeeToken = TOKEN_ADDRESS_1_MOCK;
+ request.transaction.isExternalSign = true;
+
+ isNativeBalanceSufficientForGasMock.mockResolvedValueOnce(true);
+
+ await checkGasFeeTokenBeforePublish(request);
+
+ jest
+ .mocked(request.updateTransaction)
+ .mock.calls[0][1](request.transaction);
+
+ expect(request.transaction.selectedGasFeeToken).toBeUndefined();
+ expect(request.transaction.isExternalSign).toBe(false);
+ });
+
+ it('does nothing if no selected gas fee token', async () => {
+ await checkGasFeeTokenBeforePublish(request);
+
+ expect(request.updateTransaction).not.toHaveBeenCalled();
+ });
+
+ it('does nothing if not ignoring gas fee token when native balance sufficient', async () => {
+ request.transaction.selectedGasFeeToken = TOKEN_ADDRESS_1_MOCK;
+ request.transaction.isGasFeeTokenIgnoredIfBalance = false;
+
+ await checkGasFeeTokenBeforePublish(request);
+
+ expect(request.updateTransaction).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/packages/transaction-controller/src/utils/gas-fee-tokens.ts b/packages/transaction-controller/src/utils/gas-fee-tokens.ts
index b317bec336e..73d10e6c86a 100644
--- a/packages/transaction-controller/src/utils/gas-fee-tokens.ts
+++ b/packages/transaction-controller/src/utils/gas-fee-tokens.ts
@@ -1,7 +1,9 @@
+import type EthQuery from '@metamask/eth-query';
import { rpcErrors } from '@metamask/rpc-errors';
import type { Hex } from '@metamask/utils';
import { createModuleLogger } from '@metamask/utils';
+import { isNativeBalanceSufficientForGas } from './balance';
import { ERROR_MESSAGE_NO_UPGRADE_CONTRACT } from './batch';
import { ERROR_MESSGE_PUBLIC_KEY, doesChainSupportEIP7702 } from './eip7702';
import { getEIP7702UpgradeContractAddress } from './feature-flags';
@@ -115,6 +117,81 @@ export async function getGasFeeTokens({
}
}
+/**
+ * Check and update gas fee token selection before publishing a transaction.
+ *
+ * @param request - Request object.
+ * @param request.ethQuery - EthQuery instance.
+ * @param request.fetchGasFeeTokens - Function to fetch gas fee tokens.
+ * @param request.transaction - Transaction metadata.
+ * @param request.updateTransaction - Function to update the transaction.
+ */
+export async function checkGasFeeTokenBeforePublish({
+ ethQuery,
+ fetchGasFeeTokens,
+ transaction,
+ updateTransaction,
+}: {
+ ethQuery: EthQuery;
+ fetchGasFeeTokens: (transaction: TransactionMeta) => Promise;
+ transaction: TransactionMeta;
+ updateTransaction: (
+ transactionId: string,
+ fn: (tx: TransactionMeta) => void,
+ ) => void;
+}) {
+ const { gasFeeTokens, isGasFeeTokenIgnoredIfBalance, selectedGasFeeToken } =
+ transaction;
+
+ if (!selectedGasFeeToken || !isGasFeeTokenIgnoredIfBalance) {
+ return;
+ }
+
+ const hasNativeBalance = await isNativeBalanceSufficientForGas(
+ transaction,
+ ethQuery,
+ );
+
+ if (hasNativeBalance) {
+ log(
+ 'Ignoring gas fee token before publish due to sufficient native balance',
+ );
+
+ updateTransaction(transaction.id, (tx) => {
+ tx.isExternalSign = false;
+ tx.selectedGasFeeToken = undefined;
+ });
+
+ return;
+ }
+
+ updateTransaction(transaction.id, (tx) => {
+ tx.isExternalSign = true;
+ });
+
+ let finalGasFeeTokens = gasFeeTokens;
+
+ if (finalGasFeeTokens === undefined) {
+ const newGasFeeTokens = await fetchGasFeeTokens(transaction);
+
+ updateTransaction(transaction.id, (tx) => {
+ tx.gasFeeTokens = newGasFeeTokens;
+ });
+
+ log('Updated gas fee tokens before publish', newGasFeeTokens);
+
+ finalGasFeeTokens = newGasFeeTokens;
+ }
+
+ if (
+ !finalGasFeeTokens?.some(
+ (t) => t.tokenAddress.toLowerCase() === selectedGasFeeToken.toLowerCase(),
+ )
+ ) {
+ throw new Error('Gas fee token not found and insufficient native balance');
+ }
+}
+
/**
* Extract gas fee tokens from a simulation response.
*
diff --git a/yarn.lock b/yarn.lock
index 9f7926f4abd..257d3964e0a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5086,6 +5086,7 @@ __metadata:
"@types/jest": "npm:^27.4.1"
"@types/node": "npm:^16.18.54"
async-mutex: "npm:^0.5.0"
+ bignumber.js: "npm:^9.1.2"
bn.js: "npm:^5.2.1"
deepmerge: "npm:^4.2.2"
eth-method-registry: "npm:^4.0.0"
From 9dadb54eb3109741df05d7ac873663aa57280932 Mon Sep 17 00:00:00 2001
From: Prithpal Sooriya
Date: Thu, 13 Nov 2025 11:54:46 +0000
Subject: [PATCH 4/9] Revert "Relase/676.0.0 (#7142)" (#7144)
This reverts commit 9b84201fc17fa592dcfdb701762e987eecaa1269.
## Explanation
This release used an invalid commit name (Relase/676.0.0) so the publish
CI script did not run.
Reverting and will add this back as a fast follow.
## 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/contributing.md#updating-changelogs),
highlighting breaking changes as necessary
- [x] I've prepared draft pull requests for clients and consumer
packages to resolve any breaking changes
---
> [!NOTE]
> Rolls back release versions (core to 675.0.0, shield-controller to
2.0.0) and updates shield-controller changelog accordingly.
>
> - **Versions**:
> - Roll back root `package.json` from `676.0.0` to `675.0.0`.
> - Roll back `packages/shield-controller/package.json` from `2.1.0` to
`2.0.0`.
> - **Changelog (`packages/shield-controller/CHANGELOG.md`)**:
> - Remove `2.1.0` section; keep its entry under `Unreleased`.
> - Update compare links to reference `2.0.0` and drop `2.1.0` link.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
b9b1b52fbcc95e63fa3a64ec359c4f0461364a87. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
package.json | 2 +-
packages/shield-controller/CHANGELOG.md | 5 +----
packages/shield-controller/package.json | 2 +-
3 files changed, 3 insertions(+), 6 deletions(-)
diff --git a/package.json b/package.json
index b36d2aa0c99..b2615bed4ce 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@metamask/core-monorepo",
- "version": "676.0.0",
+ "version": "675.0.0",
"private": true,
"description": "Monorepo for packages shared between MetaMask clients",
"repository": {
diff --git a/packages/shield-controller/CHANGELOG.md b/packages/shield-controller/CHANGELOG.md
index 30fa896757b..eef13f145e6 100644
--- a/packages/shield-controller/CHANGELOG.md
+++ b/packages/shield-controller/CHANGELOG.md
@@ -7,8 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
-## [2.1.0]
-
### Added
- Added metrics in the Shield coverage response to track the latency ( [#7133](https://github.com/MetaMask/core/pull/7133))
@@ -130,8 +128,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Initial release of the shield-controller package ([#6137](https://github.com/MetaMask/core/pull/6137)
-[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@2.1.0...HEAD
-[2.1.0]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@2.0.0...@metamask/shield-controller@2.1.0
+[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@2.0.0...HEAD
[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@1.2.0...@metamask/shield-controller@2.0.0
[1.2.0]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@1.1.0...@metamask/shield-controller@1.2.0
[1.1.0]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@1.0.0...@metamask/shield-controller@1.1.0
diff --git a/packages/shield-controller/package.json b/packages/shield-controller/package.json
index 2bc2b31ec71..e220f2f009d 100644
--- a/packages/shield-controller/package.json
+++ b/packages/shield-controller/package.json
@@ -1,6 +1,6 @@
{
"name": "@metamask/shield-controller",
- "version": "2.1.0",
+ "version": "2.0.0",
"description": "Controller handling shield transaction coverage logic",
"keywords": [
"MetaMask",
From bfc765841b8b9e8ca42aa623f4edc38d6eb3b1d2 Mon Sep 17 00:00:00 2001
From: Prithpal Sooriya
Date: Thu, 13 Nov 2025 12:02:53 +0000
Subject: [PATCH 5/9] Release/676.0.0 (#7145)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PR adds back
https://github.com/MetaMask/core/commit/9b84201fc17fa592dcfdb701762e987eecaa1269
## Explanation
Minor releases of shield-controller
## 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/contributing.md#updating-changelogs),
highlighting breaking changes as necessary
- [x] I've prepared draft pull requests for clients and consumer
packages to resolve any breaking changes
---
> [!NOTE]
> Bumps monorepo to 676.0.0 and releases `@metamask/shield-controller`
2.1.0 adding latency metrics to coverage response.
>
> - **Release**
> - Bump monorepo version in `package.json` to `676.0.0`.
> - **Packages — `@metamask/shield-controller`**
> - Version bump to `2.1.0` in
`packages/shield-controller/package.json`.
> - Changelog update: add entry for `2.1.0` with latency metrics in
coverage response; update compare links in
`packages/shield-controller/CHANGELOG.md`.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
1eec9d780f610ad4433a8fe30937d66b0d73b497. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
package.json | 2 +-
packages/shield-controller/CHANGELOG.md | 5 ++++-
packages/shield-controller/package.json | 2 +-
3 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/package.json b/package.json
index b2615bed4ce..b36d2aa0c99 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@metamask/core-monorepo",
- "version": "675.0.0",
+ "version": "676.0.0",
"private": true,
"description": "Monorepo for packages shared between MetaMask clients",
"repository": {
diff --git a/packages/shield-controller/CHANGELOG.md b/packages/shield-controller/CHANGELOG.md
index eef13f145e6..30fa896757b 100644
--- a/packages/shield-controller/CHANGELOG.md
+++ b/packages/shield-controller/CHANGELOG.md
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [2.1.0]
+
### Added
- Added metrics in the Shield coverage response to track the latency ( [#7133](https://github.com/MetaMask/core/pull/7133))
@@ -128,7 +130,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Initial release of the shield-controller package ([#6137](https://github.com/MetaMask/core/pull/6137)
-[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@2.0.0...HEAD
+[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@2.1.0...HEAD
+[2.1.0]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@2.0.0...@metamask/shield-controller@2.1.0
[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@1.2.0...@metamask/shield-controller@2.0.0
[1.2.0]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@1.1.0...@metamask/shield-controller@1.2.0
[1.1.0]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@1.0.0...@metamask/shield-controller@1.1.0
diff --git a/packages/shield-controller/package.json b/packages/shield-controller/package.json
index e220f2f009d..2bc2b31ec71 100644
--- a/packages/shield-controller/package.json
+++ b/packages/shield-controller/package.json
@@ -1,6 +1,6 @@
{
"name": "@metamask/shield-controller",
- "version": "2.0.0",
+ "version": "2.1.0",
"description": "Controller handling shield transaction coverage logic",
"keywords": [
"MetaMask",
From d703297c05b779fa6041ff6812e4bed10a23e80f Mon Sep 17 00:00:00 2001
From: Prithpal Sooriya
Date: Thu, 13 Nov 2025 12:37:46 +0000
Subject: [PATCH 6/9] Release/677.0.0 (#7147)
## Explanation
Major bump of the `@metamask/notification-services-controller` from
`19.0.0` to `20.0.0`
Extension Preview:
https://github.com/MetaMask/metamask-extension/pull/37709
Mobile Preview: https://github.com/MetaMask/metamask-mobile/pull/22539
## 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/contributing.md#updating-changelogs),
highlighting breaking changes as necessary
- [x] I've prepared draft pull requests for clients and consumer
packages to resolve any breaking changes
---
> [!NOTE]
> Publishes core 677.0.0 and updates
`@metamask/notification-services-controller` to 20.0.0 with breaking
Notification API v3 migration documented.
>
> - **Release**
> - Bump monorepo `version` to `677.0.0` in `package.json`.
> - **@metamask/notification-services-controller (20.0.0)**
> - `package.json`: version bump to `20.0.0`.
> - `CHANGELOG.md`: document breaking migration from Notification API v2
to v3, including:
> - Endpoint changes to `/api/v3/notifications` and request format
update (`{ addresses: string[], locale?: string }`).
> - Response structure updates with `notification_type` and nested
payloads; new platform notifications with templates.
> - Type and function renames; import path updates to
`api-notifications`.
> - Auto-expiry reduced to 30 days; optional `locale` support in
controller constructor.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
584085cfc2d61aa885f7da8b9146d01fa87c0825. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
package.json | 2 +-
packages/notification-services-controller/CHANGELOG.md | 5 ++++-
packages/notification-services-controller/package.json | 2 +-
3 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/package.json b/package.json
index b36d2aa0c99..75de8caea59 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@metamask/core-monorepo",
- "version": "676.0.0",
+ "version": "677.0.0",
"private": true,
"description": "Monorepo for packages shared between MetaMask clients",
"repository": {
diff --git a/packages/notification-services-controller/CHANGELOG.md b/packages/notification-services-controller/CHANGELOG.md
index c1a2031f875..46c5a2677f2 100644
--- a/packages/notification-services-controller/CHANGELOG.md
+++ b/packages/notification-services-controller/CHANGELOG.md
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [20.0.0]
+
### Changed
- **BREAKING:** Moved Notification API from v2 to v3 ([#7102](https://github.com/MetaMask/core/pull/7102))
@@ -618,7 +620,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Initial release
-[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@19.0.0...HEAD
+[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@20.0.0...HEAD
+[20.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@19.0.0...@metamask/notification-services-controller@20.0.0
[19.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@18.3.1...@metamask/notification-services-controller@19.0.0
[18.3.1]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@18.3.0...@metamask/notification-services-controller@18.3.1
[18.3.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@18.2.0...@metamask/notification-services-controller@18.3.0
diff --git a/packages/notification-services-controller/package.json b/packages/notification-services-controller/package.json
index 2999150edf4..fcf5b4bec37 100644
--- a/packages/notification-services-controller/package.json
+++ b/packages/notification-services-controller/package.json
@@ -1,6 +1,6 @@
{
"name": "@metamask/notification-services-controller",
- "version": "19.0.0",
+ "version": "20.0.0",
"description": "Manages New MetaMask decentralized Notification system",
"keywords": [
"MetaMask",
From 962960169d5375bfbfc543172f3dbf8ac5a4be94 Mon Sep 17 00:00:00 2001
From: Matthew Walsh
Date: Thu, 13 Nov 2025 12:48:45 +0000
Subject: [PATCH 7/9] fix: use relay if Arbitrum USDC deposit (#7146)
## Explanation
Always get a quote if using the Relay strategy and the required token is
Arbitrum USDC, even if the payment token matches.
## References
Related to
[#6247](https://github.com/MetaMask/MetaMask-planning/issues/6247)
## 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/contributing.md#updating-changelogs),
highlighting breaking changes as necessary
- [x] I've prepared draft pull requests for clients and consumer
packages to resolve any breaking changes
---
> [!NOTE]
> Always retrieve a Relay quote for Arbitrum USDC deposits and convert
getStrategy from async to sync across controller, utils, and tests.
>
> - **Behavior Change (Relay + Arbitrum USDC)**
> - In `utils/source-amounts.ts`, always require a quote when strategy
is `Relay` and required token is Arbitrum USDC, even if the payment
token matches (via `isQuoteAlwaysRequired`).
> - **API/Contract Change (BREAKING)**
> - `getStrategy` constructor option and action changed from async to
sync.
> - Updated handler registration in `TransactionPayController.ts` and
all call sites to remove `await` (e.g., `utils/strategy.ts`,
`utils/quotes.ts`, `helpers/TransactionPayPublishHook.ts`).
> - **Tests**
> - Adjusted tests to reflect synchronous `getStrategy` and new behavior
for Arbitrum USDC with Relay.
> - **Changelog**
> - Documented breaking change and new Relay quote behavior.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
a5261e83340d02e90a7ffb43a52861fc9836a801. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../transaction-pay-controller/CHANGELOG.md | 5 ++
.../src/TransactionPayController.test.ts | 6 +-
.../src/TransactionPayController.ts | 4 +-
.../helpers/TransactionPayPublishHook.test.ts | 2 +-
.../src/helpers/TransactionPayPublishHook.ts | 2 +-
.../transaction-pay-controller/src/types.ts | 6 +-
.../src/utils/quotes.test.ts | 2 +-
.../src/utils/quotes.ts | 2 +-
.../src/utils/source-amounts.test.ts | 53 ++++++++++++---
.../src/utils/source-amounts.ts | 64 +++++++++++++++++--
.../src/utils/strategy.test.ts | 20 +++---
.../src/utils/strategy.ts | 6 +-
12 files changed, 131 insertions(+), 41 deletions(-)
diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md
index 46245d32f82..6eb3fb4642e 100644
--- a/packages/transaction-pay-controller/CHANGELOG.md
+++ b/packages/transaction-pay-controller/CHANGELOG.md
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Fixed
+
+- **BREAKING:** Always retrieve quote if using Relay strategy and required token is Arbitrum USDC, even if payment token matches ([#7146](https://github.com/MetaMask/core/pull/7146))
+ - Change `getStrategy` constructor option from asynchronous to synchronous.
+
## [5.0.0]
### Added
diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts
index 59095e59bc4..473fc919c12 100644
--- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts
+++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts
@@ -77,7 +77,7 @@ describe('TransactionPayController', () => {
createController();
expect(
- await messenger.call(
+ messenger.call(
'TransactionPayController:getStrategy',
TRANSACTION_META_MOCK,
),
@@ -87,12 +87,12 @@ describe('TransactionPayController', () => {
it('returns callback value if provided', async () => {
new TransactionPayController({
getDelegationTransaction: jest.fn(),
- getStrategy: async () => TransactionPayStrategy.Test,
+ getStrategy: () => TransactionPayStrategy.Test,
messenger,
});
expect(
- await messenger.call(
+ messenger.call(
'TransactionPayController:getStrategy',
TRANSACTION_META_MOCK,
),
diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts
index eac0d916eb2..be80ea3f7d5 100644
--- a/packages/transaction-pay-controller/src/TransactionPayController.ts
+++ b/packages/transaction-pay-controller/src/TransactionPayController.ts
@@ -41,7 +41,7 @@ export class TransactionPayController extends BaseController<
readonly #getStrategy?: (
transaction: TransactionMeta,
- ) => Promise;
+ ) => TransactionPayStrategy;
constructor({
getDelegationTransaction,
@@ -139,7 +139,7 @@ export class TransactionPayController extends BaseController<
this.messenger.registerActionHandler(
'TransactionPayController:getStrategy',
- this.#getStrategy ?? (async () => TransactionPayStrategy.Relay),
+ this.#getStrategy ?? (() => TransactionPayStrategy.Relay),
);
this.messenger.registerActionHandler(
diff --git a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts
index b87ab8f2a22..681847534cc 100644
--- a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts
+++ b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts
@@ -61,7 +61,7 @@ describe('TransactionPayPublishHook', () => {
},
} as TransactionPayControllerState);
- getStrategyMock.mockResolvedValue(TransactionPayStrategy.Test);
+ getStrategyMock.mockReturnValue(TransactionPayStrategy.Test);
});
it('executes strategy with quotes', async () => {
diff --git a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts
index 7bbf7c7e8ac..c49cbcf252f 100644
--- a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts
+++ b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts
@@ -68,7 +68,7 @@ export class TransactionPayPublishHook {
return EMPTY_RESULT;
}
- const strategy = await getStrategy(this.#messenger, transactionMeta);
+ const strategy = getStrategy(this.#messenger, transactionMeta);
return await strategy.execute({
isSmartTransaction: this.#isSmartTransaction,
diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts
index bb7571dac92..530abe7d4a3 100644
--- a/packages/transaction-pay-controller/src/types.ts
+++ b/packages/transaction-pay-controller/src/types.ts
@@ -68,7 +68,7 @@ export type TransactionPayControllerGetDelegationTransactionAction = {
/** Action to get the pay strategy type used for a transaction. */
export type TransactionPayControllerGetStrategyAction = {
type: `${typeof CONTROLLER_NAME}:getStrategy`;
- handler: (transaction: TransactionMeta) => Promise;
+ handler: (transaction: TransactionMeta) => TransactionPayStrategy;
};
/** Action to update the payment token for a transaction. */
@@ -104,9 +104,7 @@ export type TransactionPayControllerOptions = {
getDelegationTransaction: GetDelegationTransactionCallback;
/** Callback to select the PayStrategy for a transaction. */
- getStrategy?: (
- transaction: TransactionMeta,
- ) => Promise;
+ getStrategy?: (transaction: TransactionMeta) => TransactionPayStrategy;
/** Controller messenger. */
messenger: TransactionPayControllerMessenger;
diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts
index 576e282c5c1..af60914f3d5 100644
--- a/packages/transaction-pay-controller/src/utils/quotes.test.ts
+++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts
@@ -114,7 +114,7 @@ describe('Quotes Utils', () => {
jest.resetAllMocks();
jest.clearAllTimers();
- getStrategyMock.mockResolvedValue({
+ getStrategyMock.mockReturnValue({
execute: jest.fn(),
getQuotes: getQuotesMock,
getBatchTransactions: getBatchTransactionsMock,
diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts
index 95c498414b2..d5d94581200 100644
--- a/packages/transaction-pay-controller/src/utils/quotes.ts
+++ b/packages/transaction-pay-controller/src/utils/quotes.ts
@@ -272,7 +272,7 @@ async function getQuotes(
messenger: TransactionPayControllerMessenger,
) {
const { id: transactionId } = transaction;
- const strategy = await getStrategy(messenger as never, transaction);
+ const strategy = getStrategy(messenger as never, transaction);
let quotes: TransactionPayQuote[] | undefined = [];
try {
diff --git a/packages/transaction-pay-controller/src/utils/source-amounts.test.ts b/packages/transaction-pay-controller/src/utils/source-amounts.test.ts
index 5784a49170b..7a8bfc90921 100644
--- a/packages/transaction-pay-controller/src/utils/source-amounts.test.ts
+++ b/packages/transaction-pay-controller/src/utils/source-amounts.test.ts
@@ -1,9 +1,16 @@
import { updateSourceAmounts } from './source-amounts';
import { getTokenFiatRate } from './token';
-import type { TransactionPaymentToken } from '..';
+import { getTransaction } from './transaction';
+import { TransactionPayStrategy, type TransactionPaymentToken } from '..';
+import {
+ ARBITRUM_USDC_ADDRESS,
+ CHAIN_ID_ARBITRUM,
+} from '../strategy/relay/constants';
+import { getMessengerMock } from '../tests/messenger-mock';
import type { TransactionData, TransactionPayRequiredToken } from '../types';
jest.mock('./token');
+jest.mock('./transaction');
const PAYMENT_TOKEN_MOCK: TransactionPaymentToken = {
address: '0x123',
@@ -37,11 +44,15 @@ const TRANSACTION_ID_MOCK = '123-456';
describe('Source Amounts Utils', () => {
const getTokenFiatRateMock = jest.mocked(getTokenFiatRate);
+ const getTransactionMock = jest.mocked(getTransaction);
+ const { messenger, getStrategyMock } = getMessengerMock();
beforeEach(() => {
jest.resetAllMocks();
getTokenFiatRateMock.mockReturnValue({ fiatRate: '2.0', usdRate: '3.0' });
+ getStrategyMock.mockReturnValue(TransactionPayStrategy.Test);
+ getTransactionMock.mockReturnValue({ id: TRANSACTION_ID_MOCK } as never);
});
describe('updateSourceAmounts', () => {
@@ -52,7 +63,7 @@ describe('Source Amounts Utils', () => {
tokens: [TRANSACTION_TOKEN_MOCK],
};
- updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, {} as never);
+ updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger);
expect(transactionData.sourceAmounts).toStrictEqual([
{
@@ -76,11 +87,35 @@ describe('Source Amounts Utils', () => {
],
};
- updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, {} as never);
+ updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger);
expect(transactionData.sourceAmounts).toStrictEqual([]);
});
+ it('does not return empty array if payment token matches but hyperliquid deposit and relay strategy', () => {
+ getStrategyMock.mockReturnValue(TransactionPayStrategy.Relay);
+
+ const transactionData: TransactionData = {
+ isLoading: false,
+ paymentToken: {
+ ...PAYMENT_TOKEN_MOCK,
+ address: ARBITRUM_USDC_ADDRESS,
+ chainId: CHAIN_ID_ARBITRUM,
+ },
+ tokens: [
+ {
+ ...TRANSACTION_TOKEN_MOCK,
+ address: ARBITRUM_USDC_ADDRESS,
+ chainId: CHAIN_ID_ARBITRUM,
+ },
+ ],
+ };
+
+ updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger);
+
+ expect(transactionData.sourceAmounts).toHaveLength(1);
+ });
+
it('returns empty array if skipIfBalance and has balance', () => {
const transactionData: TransactionData = {
isLoading: false,
@@ -94,7 +129,7 @@ describe('Source Amounts Utils', () => {
],
};
- updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, {} as never);
+ updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger);
expect(transactionData.sourceAmounts).toStrictEqual([]);
});
@@ -108,7 +143,7 @@ describe('Source Amounts Utils', () => {
getTokenFiatRateMock.mockReturnValue(undefined);
- updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, {} as never);
+ updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger);
expect(transactionData.sourceAmounts).toStrictEqual([]);
});
@@ -125,7 +160,7 @@ describe('Source Amounts Utils', () => {
],
};
- updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, {} as never);
+ updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger);
expect(transactionData.sourceAmounts).toStrictEqual([]);
});
@@ -136,7 +171,7 @@ describe('Source Amounts Utils', () => {
tokens: [TRANSACTION_TOKEN_MOCK],
};
- updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, {} as never);
+ updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger);
expect(transactionData.sourceAmounts).toBeUndefined();
});
@@ -148,14 +183,14 @@ describe('Source Amounts Utils', () => {
tokens: [],
};
- updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, {} as never);
+ updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger);
expect(transactionData.sourceAmounts).toBeUndefined();
});
// eslint-disable-next-line jest/expect-expect
it('does nothing if no transaction data', () => {
- updateSourceAmounts(TRANSACTION_ID_MOCK, undefined, {} as never);
+ updateSourceAmounts(TRANSACTION_ID_MOCK, undefined, messenger);
});
});
});
diff --git a/packages/transaction-pay-controller/src/utils/source-amounts.ts b/packages/transaction-pay-controller/src/utils/source-amounts.ts
index fe5203b852c..9907dba2dab 100644
--- a/packages/transaction-pay-controller/src/utils/source-amounts.ts
+++ b/packages/transaction-pay-controller/src/utils/source-amounts.ts
@@ -2,11 +2,18 @@ import { createModuleLogger } from '@metamask/utils';
import { BigNumber } from 'bignumber.js';
import { getTokenFiatRate } from './token';
+import { getTransaction } from './transaction';
import type {
TransactionPayControllerMessenger,
TransactionPaymentToken,
} from '..';
+import { TransactionPayStrategy } from '..';
+import type { TransactionMeta } from '../../../transaction-controller/src';
import { projectLogger } from '../logger';
+import {
+ ARBITRUM_USDC_ADDRESS,
+ CHAIN_ID_ARBITRUM,
+} from '../strategy/relay/constants';
import type {
TransactionPaySourceAmount,
TransactionData,
@@ -38,7 +45,9 @@ export function updateSourceAmounts(
}
const sourceAmounts = tokens
- .map((t) => calculateSourceAmount(paymentToken, t, messenger))
+ .map((t) =>
+ calculateSourceAmount(paymentToken, t, messenger, transactionId),
+ )
.filter(Boolean) as TransactionPaySourceAmount[];
log('Updated source amounts', { transactionId, sourceAmounts });
@@ -52,12 +61,14 @@ export function updateSourceAmounts(
* @param paymentToken - Selected payment token.
* @param token - Target token to cover.
* @param messenger - Controller messenger.
+ * @param transactionId - ID of the transaction.
* @returns The source amount or undefined if calculation failed.
*/
function calculateSourceAmount(
paymentToken: TransactionPaymentToken,
token: TransactionPayRequiredToken,
messenger: TransactionPayControllerMessenger,
+ transactionId: string,
): TransactionPaySourceAmount | undefined {
const paymentTokenFiatRate = getTokenFiatRate(
messenger,
@@ -71,10 +82,6 @@ function calculateSourceAmount(
const hasBalance = new BigNumber(token.balanceRaw).gte(token.amountRaw);
- const isSameTokenSelected =
- token.address.toLowerCase() === paymentToken.address.toLowerCase() &&
- token.chainId === paymentToken.chainId;
-
if (token.skipIfBalance && hasBalance) {
log('Skipping token as sufficient balance', {
tokenAddress: token.address,
@@ -82,7 +89,15 @@ function calculateSourceAmount(
return undefined;
}
- if (isSameTokenSelected) {
+ const strategy = getStrategyType(transactionId, messenger);
+
+ const isSameTokenSelected =
+ token.address.toLowerCase() === paymentToken.address.toLowerCase() &&
+ token.chainId === paymentToken.chainId;
+
+ const isAlwaysRequired = isQuoteAlwaysRequired(token, strategy);
+
+ if (isSameTokenSelected && !isAlwaysRequired) {
log('Skipping token as same as payment token');
return undefined;
}
@@ -108,3 +123,40 @@ function calculateSourceAmount(
targetTokenAddress: token.address,
};
}
+
+/**
+ * Determine if a quote is always required for a token and strategy.
+ *
+ * @param token - Target token.
+ * @param strategy - Payment strategy.
+ * @returns True if a quote is always required, false otherwise.
+ */
+function isQuoteAlwaysRequired(
+ token: TransactionPayRequiredToken,
+ strategy: TransactionPayStrategy,
+) {
+ const isHyperliquidDeposit =
+ token.chainId === CHAIN_ID_ARBITRUM &&
+ token.address.toLowerCase() === ARBITRUM_USDC_ADDRESS.toLowerCase();
+
+ return strategy === TransactionPayStrategy.Relay && isHyperliquidDeposit;
+}
+
+/**
+ * Get the strategy type for a transaction.
+ *
+ * @param transactionId - ID of the transaction.
+ * @param messenger - Controller messenger.
+ * @returns Payment strategy type.
+ */
+function getStrategyType(
+ transactionId: string,
+ messenger: TransactionPayControllerMessenger,
+) {
+ const transaction = getTransaction(
+ transactionId,
+ messenger,
+ ) as TransactionMeta;
+
+ return messenger.call('TransactionPayController:getStrategy', transaction);
+}
diff --git a/packages/transaction-pay-controller/src/utils/strategy.test.ts b/packages/transaction-pay-controller/src/utils/strategy.test.ts
index 5af1a6694ee..7b2c65593e2 100644
--- a/packages/transaction-pay-controller/src/utils/strategy.test.ts
+++ b/packages/transaction-pay-controller/src/utils/strategy.test.ts
@@ -18,35 +18,35 @@ describe('Strategy Utils', () => {
describe('getStrategy', () => {
it('returns TestStrategy if strategy name is Test', async () => {
- getStrategyMock.mockResolvedValue(TransactionPayStrategy.Test);
+ getStrategyMock.mockReturnValue(TransactionPayStrategy.Test);
- const strategy = await getStrategy(messenger, TRANSACTION_META_MOCK);
+ const strategy = getStrategy(messenger, TRANSACTION_META_MOCK);
expect(strategy).toBeInstanceOf(TestStrategy);
});
it('returns BridgeStrategy if strategy name is Bridge', async () => {
- getStrategyMock.mockResolvedValue(TransactionPayStrategy.Bridge);
+ getStrategyMock.mockReturnValue(TransactionPayStrategy.Bridge);
- const strategy = await getStrategy(messenger, TRANSACTION_META_MOCK);
+ const strategy = getStrategy(messenger, TRANSACTION_META_MOCK);
expect(strategy).toBeInstanceOf(BridgeStrategy);
});
it('returns RelayStrategy if strategy name is Relay', async () => {
- getStrategyMock.mockResolvedValue(TransactionPayStrategy.Relay);
+ getStrategyMock.mockReturnValue(TransactionPayStrategy.Relay);
- const strategy = await getStrategy(messenger, TRANSACTION_META_MOCK);
+ const strategy = getStrategy(messenger, TRANSACTION_META_MOCK);
expect(strategy).toBeInstanceOf(RelayStrategy);
});
it('throws if strategy name is unknown', async () => {
- getStrategyMock.mockResolvedValue('UnknownStrategy' as never);
+ getStrategyMock.mockReturnValue('UnknownStrategy' as never);
- await expect(
- getStrategy(messenger, TRANSACTION_META_MOCK),
- ).rejects.toThrow('Unknown strategy: UnknownStrategy');
+ expect(() => getStrategy(messenger, TRANSACTION_META_MOCK)).toThrow(
+ 'Unknown strategy: UnknownStrategy',
+ );
});
});
diff --git a/packages/transaction-pay-controller/src/utils/strategy.ts b/packages/transaction-pay-controller/src/utils/strategy.ts
index 450e2b2cbf5..dc505431f4e 100644
--- a/packages/transaction-pay-controller/src/utils/strategy.ts
+++ b/packages/transaction-pay-controller/src/utils/strategy.ts
@@ -13,11 +13,11 @@ import type { PayStrategy, TransactionPayControllerMessenger } from '../types';
* @param transaction - Transaction to get the strategy for.
* @returns The payment strategy instance.
*/
-export async function getStrategy(
+export function getStrategy(
messenger: TransactionPayControllerMessenger,
transaction: TransactionMeta,
-): Promise> {
- const strategyName = await messenger.call(
+): PayStrategy {
+ const strategyName = messenger.call(
'TransactionPayController:getStrategy',
transaction,
);
From 9ef4ca02469a750823d69c5238e613339edc91f2 Mon Sep 17 00:00:00 2001
From: Nguyen Anh Tu
Date: Thu, 13 Nov 2025 20:25:15 +0700
Subject: [PATCH 8/9] fix: trigger access token refresh (#7149)
## Explanation
- Previously `triggerAccessTokenRefresh` was only called during
`getSubscriptions` polling, normal subscription state change didn't
trigger it.
This PR trigger `triggerAccessTokenRefresh` everytime subscription state
change
- Remove `triggerAccessTokenRefresh` from
`startShieldSubscriptionWithCard` since that only return stripe checkout
url, not subscribed yet.
## 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/contributing.md#updating-changelogs),
highlighting breaking changes as necessary
- [x] I've prepared draft pull requests for clients and consumer
packages to resolve any breaking changes
---
> [!NOTE]
> Refresh access token immediately when subscription state changes and
remove refresh after starting card/crypto checkout; delete polling
flag/logic.
>
> - **Subscription Controller**:
> - **Behavior**: Call `triggerAccessTokenRefresh` in `getSubscriptions`
whenever state changes (subscriptions/customer/trials/lastSubscription).
> - **Refactor**: Remove `#shouldCallRefreshAuthToken` and related
polling conditional; `_executePoll` now only calls `getSubscriptions`.
> - **Flows**: Remove `triggerAccessTokenRefresh` after
`startShieldSubscriptionWithCard` and `startSubscriptionWithCrypto`
(checkout/session creation only).
> - **Docs**:
> - Update `CHANGELOG.md` to reflect the new refresh behavior and
removal after card checkout.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
04b3ce9f02c69087d8b556ba2339572873983063. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
packages/subscription-controller/CHANGELOG.md | 5 +++++
.../src/SubscriptionController.ts | 14 ++++----------
2 files changed, 9 insertions(+), 10 deletions(-)
diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md
index 5e45964d221..f65c4f6da2f 100644
--- a/packages/subscription-controller/CHANGELOG.md
+++ b/packages/subscription-controller/CHANGELOG.md
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Changed
+
+- Trigger `triggerAccessTokenRefresh` everytime subscription state change instead of only when polling ([#7149](https://github.com/MetaMask/core/pull/7149))
+- Remove `triggerAccessTokenRefresh` after `startShieldSubscriptionWithCard` ([#7149](https://github.com/MetaMask/core/pull/7149))
+
## [4.2.1]
### Added
diff --git a/packages/subscription-controller/src/SubscriptionController.ts b/packages/subscription-controller/src/SubscriptionController.ts
index a3ebf862394..6a654d39fed 100644
--- a/packages/subscription-controller/src/SubscriptionController.ts
+++ b/packages/subscription-controller/src/SubscriptionController.ts
@@ -241,8 +241,6 @@ export class SubscriptionController extends StaticIntervalPollingController()<
> {
readonly #subscriptionService: ISubscriptionService;
- #shouldCallRefreshAuthToken: boolean = false;
-
/**
* Creates a new SubscriptionController instance.
*
@@ -391,7 +389,8 @@ export class SubscriptionController extends StaticIntervalPollingController()<
state.trialedProducts = newTrialedProducts;
state.lastSubscription = newLastSubscription;
});
- this.#shouldCallRefreshAuthToken = true;
+ // trigger access token refresh to ensure the user has the latest access token if subscription state change
+ this.triggerAccessTokenRefresh();
}
return newSubscriptions;
@@ -466,8 +465,7 @@ export class SubscriptionController extends StaticIntervalPollingController()<
const response =
await this.#subscriptionService.startSubscriptionWithCard(request);
-
- this.triggerAccessTokenRefresh();
+ // note: no need to trigger access token refresh after startSubscriptionWithCard request because this only return stripe checkout session url, subscription not created yet
return response;
}
@@ -476,7 +474,7 @@ export class SubscriptionController extends StaticIntervalPollingController()<
this.#assertIsUserNotSubscribed({ products: request.products });
const response =
await this.#subscriptionService.startSubscriptionWithCrypto(request);
- this.triggerAccessTokenRefresh();
+
return response;
}
@@ -730,10 +728,6 @@ export class SubscriptionController extends StaticIntervalPollingController()<
async _executePoll(): Promise {
await this.getSubscriptions();
- if (this.#shouldCallRefreshAuthToken) {
- this.triggerAccessTokenRefresh();
- this.#shouldCallRefreshAuthToken = false;
- }
}
/**
From 49615f6675525c8b5dbe2874db5f0e5208123597 Mon Sep 17 00:00:00 2001
From: Lwin <147362763+lwin-kyaw@users.noreply.github.com>
Date: Thu, 13 Nov 2025 21:37:57 +0800
Subject: [PATCH 9/9] Release/678.0.0 (#7150)
## Explanation
Patch for `@metamask/subscription-controller` -> `v4.2.2`
## 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/contributing.md#updating-changelogs),
highlighting breaking changes as necessary
- [ ] I've prepared draft pull requests for clients and consumer
packages to resolve any breaking changes
---
> [!NOTE]
> Publishes @metamask/subscription-controller v4.2.2 with access token
refresh behavior changes and bumps monorepo version to 678.0.0.
>
> - **Release**:
> - `packages/subscription-controller`: bump to `4.2.2`
> - Changed: trigger `triggerAccessTokenRefresh` on any subscription
state change (not only polling)
> - Changed: remove `triggerAccessTokenRefresh` after
`startShieldSubscriptionWithCard`
> - Update changelog links for `4.2.2`
> - **Monorepo**:
> - Bump root `package.json` version to `678.0.0`
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
49da1551f6d44142e4f2c343b3482a8bfb7f030c. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
package.json | 2 +-
packages/subscription-controller/CHANGELOG.md | 5 ++++-
packages/subscription-controller/package.json | 2 +-
3 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/package.json b/package.json
index 75de8caea59..090bb5d7d2f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@metamask/core-monorepo",
- "version": "677.0.0",
+ "version": "678.0.0",
"private": true,
"description": "Monorepo for packages shared between MetaMask clients",
"repository": {
diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md
index f65c4f6da2f..f31c132d8ed 100644
--- a/packages/subscription-controller/CHANGELOG.md
+++ b/packages/subscription-controller/CHANGELOG.md
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [4.2.2]
+
### Changed
- Trigger `triggerAccessTokenRefresh` everytime subscription state change instead of only when polling ([#7149](https://github.com/MetaMask/core/pull/7149))
@@ -193,7 +195,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Bump `@metamask/controller-utils` from `^11.12.0` to `^11.14.0` ([#6620](https://github.com/MetaMask/core/pull/6620), [#6629](https://github.com/MetaMask/core/pull/6629))
- Bump `@metamask/utils` from `^11.4.2` to `^11.8.0` ([#6588](https://github.com/MetaMask/core/pull/6588))
-[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@4.2.1...HEAD
+[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@4.2.2...HEAD
+[4.2.2]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@4.2.1...@metamask/subscription-controller@4.2.2
[4.2.1]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@4.2.0...@metamask/subscription-controller@4.2.1
[4.2.0]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@4.1.0...@metamask/subscription-controller@4.2.0
[4.1.0]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@4.0.0...@metamask/subscription-controller@4.1.0
diff --git a/packages/subscription-controller/package.json b/packages/subscription-controller/package.json
index fbe3c4536f4..2d2d32c8f1a 100644
--- a/packages/subscription-controller/package.json
+++ b/packages/subscription-controller/package.json
@@ -1,6 +1,6 @@
{
"name": "@metamask/subscription-controller",
- "version": "4.2.1",
+ "version": "4.2.2",
"description": "Handle user subscription",
"keywords": [
"MetaMask",