diff --git a/package.json b/package.json
index ad2f9197d1..cbbbc9ff43 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@metamask/core-monorepo",
- "version": "983.0.0",
+ "version": "985.0.0",
"private": true,
"description": "Monorepo for packages shared between MetaMask clients",
"repository": {
diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md
index 811be3964c..3074ae675c 100644
--- a/packages/assets-controller/CHANGELOG.md
+++ b/packages/assets-controller/CHANGELOG.md
@@ -10,6 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Bump `@metamask/transaction-controller` from `^65.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796))
+- Bump `@metamask/core-backend` from `^6.2.2` to `^6.3.0` ([#8813](https://github.com/MetaMask/core/pull/8813))
+
+### Fixed
+
+- Non-EVM assets with a `slip44` asset namespace (e.g. Bitcoin, Solana native, TRON) are now correctly typed as `native` instead of `erc20` in `assetsInfo` ([#8811](https://github.com/MetaMask/core/pull/8811))
+- Solana SPL tokens (CAIP-19 `solana:.../token:
`) are now correctly typed as `spl` instead of `erc20` in `assetsInfo` ([#8811](https://github.com/MetaMask/core/pull/8811))
## [7.1.2]
diff --git a/packages/assets-controller/package.json b/packages/assets-controller/package.json
index 1d704de98d..d6450c7c96 100644
--- a/packages/assets-controller/package.json
+++ b/packages/assets-controller/package.json
@@ -62,7 +62,7 @@
"@metamask/base-controller": "^9.1.0",
"@metamask/client-controller": "^1.0.1",
"@metamask/controller-utils": "^12.1.0",
- "@metamask/core-backend": "^6.2.2",
+ "@metamask/core-backend": "^6.3.0",
"@metamask/keyring-api": "^23.1.0",
"@metamask/keyring-controller": "^25.5.0",
"@metamask/keyring-internal-api": "^11.0.1",
diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts
index 0024243cf6..997e62e13b 100644
--- a/packages/assets-controller/src/AssetsController.ts
+++ b/packages/assets-controller/src/AssetsController.ts
@@ -57,6 +57,7 @@ import type { Hex } from '@metamask/utils';
import {
isCaipChainId,
isStrictHexString,
+ KnownCaipNamespace,
parseCaipAssetType,
parseCaipChainId,
} from '@metamask/utils';
@@ -82,7 +83,10 @@ import type { AccountsControllerAccountBalancesUpdatedEvent } from './data-sourc
import { SnapDataSource } from './data-sources/SnapDataSource';
import type { StakedBalanceDataSourceConfig } from './data-sources/StakedBalanceDataSource';
import { StakedBalanceDataSource } from './data-sources/StakedBalanceDataSource';
-import { TokenDataSource } from './data-sources/TokenDataSource';
+import {
+ CaipAssetNamespace,
+ TokenDataSource,
+} from './data-sources/TokenDataSource';
import {
CHAINS_WITH_DEFAULT_TRACKED_ASSETS,
DEFAULT_TRACKED_ASSETS_BY_CHAIN,
@@ -1663,7 +1667,10 @@ export class AssetsController extends BaseController<
let tokenType: FungibleAssetMetadata['type'] = 'erc20';
if (this.#isNativeAsset(normalizedAssetId)) {
tokenType = 'native';
- } else if (parsed.assetNamespace === 'spl') {
+ } else if (
+ parsed.chain.namespace === KnownCaipNamespace.Solana &&
+ parsed.assetNamespace === CaipAssetNamespace.Token
+ ) {
tokenType = 'spl';
}
diff --git a/packages/assets-controller/src/README.md b/packages/assets-controller/src/README.md
index 892cfc1623..baebf8606f 100644
--- a/packages/assets-controller/src/README.md
+++ b/packages/assets-controller/src/README.md
@@ -653,7 +653,7 @@ type Caip19AssetId = string;
// - Native ETH: "eip155:1/slip44:60"
// - USDC on Ethereum: "eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
// - SOL: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501"
-// - SPL Token: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/spl:EPjFWdd5..."
+// - SPL Token: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5..."
// CAIP-2 chain identifier
type ChainId = string;
diff --git a/packages/assets-controller/src/data-sources/TokenDataSource.test.ts b/packages/assets-controller/src/data-sources/TokenDataSource.test.ts
index 15932f3936..8b8cc63df4 100644
--- a/packages/assets-controller/src/data-sources/TokenDataSource.test.ts
+++ b/packages/assets-controller/src/data-sources/TokenDataSource.test.ts
@@ -20,8 +20,13 @@ const MOCK_ADDRESS = '0x1234567890123456789012345678901234567890';
const MOCK_TOKEN_ASSET =
'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Caip19AssetId;
const MOCK_NATIVE_ASSET = 'eip155:1/slip44:60' as Caip19AssetId;
+const MOCK_BTC_ASSET =
+ 'bip122:000000000019d6689c085ae165831e93/slip44:0' as Caip19AssetId;
+const MOCK_SOL_NATIVE_ASSET =
+ 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' as Caip19AssetId;
+const MOCK_TRX_ASSET = 'tron:728126428/slip44:195' as Caip19AssetId;
const MOCK_SPL_ASSET =
- 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/spl:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Caip19AssetId;
+ 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Caip19AssetId;
type MockApiClient = {
tokens: {
@@ -516,6 +521,57 @@ describe('TokenDataSource', () => {
expect(context.response.assetsInfo?.[MOCK_SPL_ASSET]?.type).toBe('spl');
});
+ it.each([
+ {
+ label: 'Bitcoin (bip122/slip44)',
+ assetId: MOCK_BTC_ASSET,
+ chainId: 'bip122:000000000019d6689c085ae165831e93',
+ name: 'Bitcoin',
+ symbol: 'BTC',
+ decimals: 8,
+ },
+ {
+ label: 'SOL native (solana/slip44)',
+ assetId: MOCK_SOL_NATIVE_ASSET,
+ chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
+ name: 'Solana',
+ symbol: 'SOL',
+ decimals: 9,
+ },
+ {
+ label: 'TRX native (tron/slip44)',
+ assetId: MOCK_TRX_ASSET,
+ chainId: 'tron:728126428',
+ name: 'TRON',
+ symbol: 'TRX',
+ decimals: 6,
+ },
+ ])(
+ 'middleware types non-EVM slip44 asset as native: $label',
+ async ({ assetId, chainId, name, symbol, decimals }) => {
+ const { controller } = setupController({
+ messenger: createTestMessenger(),
+ supportedNetworks: [chainId],
+ assetsResponse: [
+ createMockAssetResponse(assetId, { name, symbol, decimals }),
+ ],
+ });
+
+ const next = jest.fn().mockResolvedValue(undefined);
+ const context = createMiddlewareContext({
+ response: {
+ detectedAssets: {
+ 'mock-account-id': [assetId],
+ },
+ },
+ });
+
+ await controller.assetsMiddleware(context, next);
+
+ expect(context.response.assetsInfo?.[assetId]?.type).toBe('native');
+ },
+ );
+
it('middleware merges metadata into existing response', async () => {
const anotherAsset =
'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f' as Caip19AssetId;
diff --git a/packages/assets-controller/src/data-sources/TokenDataSource.ts b/packages/assets-controller/src/data-sources/TokenDataSource.ts
index 110831664f..c94198e73d 100644
--- a/packages/assets-controller/src/data-sources/TokenDataSource.ts
+++ b/packages/assets-controller/src/data-sources/TokenDataSource.ts
@@ -45,7 +45,7 @@ const BULK_SCAN_BATCH_SIZE = 100;
const MIN_TOKEN_OCCURRENCES = 3;
/** CAIP-19 `assetNamespace` segments used across filtering logic. */
-enum CaipAssetNamespace {
+export enum CaipAssetNamespace {
Slip44 = 'slip44',
Erc20 = 'erc20',
Token = 'token',
@@ -102,11 +102,18 @@ function transformV3AssetResponseToMetadata(
const parsed = parseCaipAssetType(assetId);
let tokenType: 'native' | 'erc20' | 'spl' = 'erc20';
- if (nativeAssetIds.has(assetId.toLowerCase())) {
+ if (
+ nativeAssetIds.has(assetId.toLowerCase()) ||
+ parsed.assetNamespace === CaipAssetNamespace.Slip44
+ ) {
tokenType = 'native';
- } else if (parsed.assetNamespace === 'spl') {
+ } else if (
+ parsed.chain.namespace === KnownCaipNamespace.Solana &&
+ parsed.assetNamespace === CaipAssetNamespace.Token
+ ) {
tokenType = 'spl';
}
+ // TODO: Add support for Tron trc20 standard
const metadata: FungibleAssetMetadata = {
// Type derived from assetId
diff --git a/packages/assets-controller/src/types.ts b/packages/assets-controller/src/types.ts
index d0bf7fe7bc..038fe74df9 100644
--- a/packages/assets-controller/src/types.ts
+++ b/packages/assets-controller/src/types.ts
@@ -9,7 +9,7 @@ import type { CaipAssetType, CaipChainId, Json } from '@metamask/utils';
* - Native: "eip155:1/slip44:60" (ETH)
* - ERC20: "eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" (USDC)
* - ERC721: "eip155:1/erc721:0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D/1234" (BAYC #1234)
- * - SPL: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/spl:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
+ * - SPL: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
*/
export type Caip19AssetId = CaipAssetType;
diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md
index 8b75745cd8..7e7c8a9077 100644
--- a/packages/assets-controllers/CHANGELOG.md
+++ b/packages/assets-controllers/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Bump `@metamask/transaction-controller` from `^65.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796))
+- Bump `@metamask/core-backend` from `^6.2.2` to `^6.3.0` ([#8813](https://github.com/MetaMask/core/pull/8813))
## [108.1.0]
diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json
index 7e78eb1842..2f5f583c50 100644
--- a/packages/assets-controllers/package.json
+++ b/packages/assets-controllers/package.json
@@ -66,7 +66,7 @@
"@metamask/base-controller": "^9.1.0",
"@metamask/contract-metadata": "^2.4.0",
"@metamask/controller-utils": "^12.1.0",
- "@metamask/core-backend": "^6.2.2",
+ "@metamask/core-backend": "^6.3.0",
"@metamask/eth-query": "^4.0.0",
"@metamask/keyring-api": "^23.1.0",
"@metamask/keyring-controller": "^25.5.0",
diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts
index bd5162f310..2d73863857 100644
--- a/packages/bridge-controller/src/selectors.test.ts
+++ b/packages/bridge-controller/src/selectors.test.ts
@@ -39,7 +39,7 @@ describe('Bridge Selectors', () => {
exchangeRate: '2.5',
usdExchangeRate: '1.5',
},
- 'solana:101/spl:456': {
+ 'solana:101/token:456': {
exchangeRate: '3.0',
},
},
diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md
index eb49833023..ef66ee9b21 100644
--- a/packages/core-backend/CHANGELOG.md
+++ b/packages/core-backend/CHANGELOG.md
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [6.3.0]
+
### Added
- Add `OHLCVService` for real-time OHLCV (candlestick) data streaming via WebSocket ([#8695](https://github.com/MetaMask/core/pull/8695))
@@ -68,7 +70,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
-- Bump `@metamask/accounts-controller` from `^36.0.0` to `^37.0.0` ([#7996](https://github.com/MetaMask/core/pull/7996)), ([#8140](https://github.com/MetaMask/core/pull/8140))
+- Bump `@metamask/accounts-controller` from `^36.0.0` to `^37.0.0` ([#7996](https://github.com/MetaMask/core/pull/7996), [#8140](https://github.com/MetaMask/core/pull/8140))
- Bump `@metamask/controller-utils` from `^11.18.0` to `^11.19.0` ([#7995](https://github.com/MetaMask/core/pull/7995))
## [6.0.0]
@@ -282,7 +284,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Type definitions** - Comprehensive TypeScript types for transactions, balances, WebSocket messages, and service configurations
- **Logging infrastructure** - Structured logging with module-specific loggers for debugging and monitoring
-[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/core-backend@6.2.2...HEAD
+[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/core-backend@6.3.0...HEAD
+[6.3.0]: https://github.com/MetaMask/core/compare/@metamask/core-backend@6.2.2...@metamask/core-backend@6.3.0
[6.2.2]: https://github.com/MetaMask/core/compare/@metamask/core-backend@6.2.1...@metamask/core-backend@6.2.2
[6.2.1]: https://github.com/MetaMask/core/compare/@metamask/core-backend@6.2.0...@metamask/core-backend@6.2.1
[6.2.0]: https://github.com/MetaMask/core/compare/@metamask/core-backend@6.1.1...@metamask/core-backend@6.2.0
diff --git a/packages/core-backend/package.json b/packages/core-backend/package.json
index 6d9f3276db..18aaf432ad 100644
--- a/packages/core-backend/package.json
+++ b/packages/core-backend/package.json
@@ -1,6 +1,6 @@
{
"name": "@metamask/core-backend",
- "version": "6.2.2",
+ "version": "6.3.0",
"description": "Core backend services for MetaMask",
"keywords": [
"Ethereum",
diff --git a/packages/notification-services-controller/CHANGELOG.md b/packages/notification-services-controller/CHANGELOG.md
index 147522beea..88ae266c9c 100644
--- a/packages/notification-services-controller/CHANGELOG.md
+++ b/packages/notification-services-controller/CHANGELOG.md
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- Add `registerPushNotifications` to `NotificationServicesControllerEnableNotificationsOptions` so clients can enable MetaMask notifications without registering push notifications. ([#8782](https://github.com/MetaMask/core/pull/8782))
+- Add optional mobile OS and app version metadata to push token registrations so clients can provide Firebase error attribution data. ([#8782](https://github.com/MetaMask/core/pull/8782))
+
## [24.0.0]
### Added
diff --git a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController-method-action-types.ts b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController-method-action-types.ts
index df3a4ff636..92eeee08c5 100644
--- a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController-method-action-types.ts
+++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController-method-action-types.ts
@@ -60,6 +60,7 @@ export type NotificationServicesControllerSetFeatureAnnouncementsEnabledAction =
* Used only during initialization to seed marketing push notifications.
* @param opts.productAnnouncementEnabled - The user's product-announcement flag.
* Used only during initialization to seed marketing in-app notifications.
+ * @param opts.registerPushNotifications - Whether to attempt FCM/device push registration.
* @returns The updated or newly created user storage.
* @throws {Error} Throws an error if unauthenticated or from other operations.
*/
@@ -72,7 +73,7 @@ export type NotificationServicesControllerCreateOnChainTriggersAction = {
* Enables all MetaMask notifications for the user.
* This is identical flow when initializing notifications for the first time.
*
- * @param opts - Optional settings for first-time AUS notification preferences initialization.
+ * @param opts - Optional options to mutate this functionality.
* @throws {Error} If there is an error during the process of enabling notifications.
*/
export type NotificationServicesControllerEnableMetamaskNotificationsAction = {
diff --git a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts
index 1ec067c7c1..f65a607fbd 100644
--- a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts
+++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts
@@ -579,6 +579,45 @@ describe('NotificationServicesController', () => {
]);
});
+ it('skips push registration when registerPushNotifications is false', async () => {
+ const {
+ messenger,
+ mockEnablePushNotifications,
+ mockGetConfig,
+ mockUpdateNotifications,
+ mockKeyringControllerGetState,
+ } = arrangeMocks({
+ configurePrefs: (mock) => mock.mockResolvedValueOnce(null),
+ });
+
+ mockKeyringControllerGetState.mockReturnValue({
+ isUnlocked: true,
+ keyrings: [
+ {
+ accounts: [ADDRESS_1],
+ type: KeyringTypes.hd,
+ metadata: { id: 'srp-1', name: 'SRP 1' },
+ },
+ ],
+ });
+ const mockTriggerQuery = mockGetOnChainNotificationsConfig();
+
+ const controller = new NotificationServicesController({
+ messenger,
+ env: { featureAnnouncements: featureAnnouncementsEnv },
+ });
+
+ await controller.createOnChainTriggers({
+ registerPushNotifications: false,
+ });
+
+ expect(mockGetConfig).toHaveBeenCalled();
+ expect(mockTriggerQuery.isDone()).toBe(true);
+ expect(mockUpdateNotifications).toHaveBeenCalled();
+ expect(controller.state.isNotificationServicesEnabled).toBe(true);
+ expect(mockEnablePushNotifications).not.toHaveBeenCalled();
+ });
+
it('enables all wallet-activity accounts when Trigger API has no enabled accounts for first-time setup', async () => {
const {
messenger,
@@ -1447,6 +1486,28 @@ describe('NotificationServicesController', () => {
expect(mocks.mockUpdateNotifications).toHaveBeenCalled();
});
+ it('forwards registerPushNotifications false when enabling MetaMask notifications', async () => {
+ const mocks = arrangeMocks({
+ configurePrefs: (mock) => mock.mockResolvedValueOnce(null),
+ });
+ const mockTriggerQuery = mockGetOnChainNotificationsConfig();
+
+ const controller = new NotificationServicesController({
+ messenger: mocks.messenger,
+ env: { featureAnnouncements: featureAnnouncementsEnv },
+ });
+
+ await controller.enableMetamaskNotifications({
+ registerPushNotifications: false,
+ });
+
+ expect(mocks.mockGetConfig).toHaveBeenCalled();
+ expect(mockTriggerQuery.isDone()).toBe(true);
+ expect(mocks.mockUpdateNotifications).toHaveBeenCalled();
+ expect(controller.state.isNotificationServicesEnabled).toBe(true);
+ expect(mocks.mockEnablePushNotifications).not.toHaveBeenCalled();
+ });
+
it('should not create new notification subscriptions when enabling an account that already has notifications', async () => {
const mocks = arrangeMocks({
// Mock fully-initialized existing notifications
diff --git a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts
index 5ddc45c43e..1d479ef99d 100644
--- a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts
+++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts
@@ -204,8 +204,22 @@ export type NotificationServicesControllerEnableNotificationsOptions = {
* in-app notifications.
*/
productAnnouncementEnabled?: boolean;
+ /**
+ * Whether to attempt FCM/device push registration after notification
+ * preferences are initialized or refreshed. This does not request OS push
+ * permission.
+ *
+ * @default true
+ */
+ registerPushNotifications?: boolean;
};
+export type NotificationServicesControllerCreateOnChainTriggersOptions =
+ NotificationServicesControllerEnableNotificationsOptions;
+
+export type NotificationServicesControllerEnableMetamaskNotificationsOptions =
+ NotificationServicesControllerEnableNotificationsOptions;
+
const locallyPersistedNotificationTypes = new Set([
TRIGGER_TYPES.SNAP,
]);
@@ -1016,11 +1030,12 @@ export class NotificationServicesController extends BaseController<
* Used only during initialization to seed marketing push notifications.
* @param opts.productAnnouncementEnabled - The user's product-announcement flag.
* Used only during initialization to seed marketing in-app notifications.
+ * @param opts.registerPushNotifications - Whether to attempt FCM/device push registration.
* @returns The updated or newly created user storage.
* @throws {Error} Throws an error if unauthenticated or from other operations.
*/
public async createOnChainTriggers(
- opts?: NotificationServicesControllerEnableNotificationsOptions,
+ opts: NotificationServicesControllerCreateOnChainTriggersOptions = {},
): Promise {
try {
this.#setIsUpdatingMetamaskNotifications(true);
@@ -1070,12 +1085,14 @@ export class NotificationServicesController extends BaseController<
.filter((account) => account.enabled)
.map((account) => account.address);
- // 2. Lazily enable push notifications (FCM may take some time, so keeps UI unblocked)
- this.#pushNotifications
- .enablePushNotifications(accountsWithNotifications)
- .catch(() => {
- // Do Nothing
- });
+ if (opts.registerPushNotifications ?? true) {
+ // Attempt FCM/device registration only; clients must request OS permission separately.
+ this.#pushNotifications
+ .enablePushNotifications(accountsWithNotifications)
+ .catch(() => {
+ // Do Nothing
+ });
+ }
// Update the state of the controller
this.update((state) => {
@@ -1102,11 +1119,11 @@ export class NotificationServicesController extends BaseController<
* Enables all MetaMask notifications for the user.
* This is identical flow when initializing notifications for the first time.
*
- * @param opts - Optional settings for first-time AUS notification preferences initialization.
+ * @param opts - Optional options to mutate this functionality.
* @throws {Error} If there is an error during the process of enabling notifications.
*/
public async enableMetamaskNotifications(
- opts?: NotificationServicesControllerEnableNotificationsOptions,
+ opts: NotificationServicesControllerEnableMetamaskNotificationsOptions = {},
): Promise {
try {
this.#setIsUpdatingMetamaskNotifications(true);
diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.test.ts b/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.test.ts
index 082c3d69d3..bbe4a74827 100644
--- a/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.test.ts
+++ b/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.test.ts
@@ -124,6 +124,33 @@ describe('NotificationServicesPushController', () => {
});
});
+ it('should call activatePushNotifications with mobile OS and app version metadata', async () => {
+ const mocks = arrangeServicesMocks();
+ const { controller, messenger } = arrangeMockMessenger({
+ platform: 'mobile',
+ os: 'android',
+ appVersion: '7.42.0',
+ });
+ mockAuthBearerTokenCall(messenger);
+
+ await controller.enablePushNotifications(MOCK_ADDRESSES);
+
+ expect(mocks.activatePushNotificationsMock).toHaveBeenCalledWith({
+ bearerToken: MOCK_JWT,
+ addresses: MOCK_ADDRESSES,
+ env: expect.any(Object),
+ createRegToken: expect.any(Function),
+ regToken: {
+ platform: 'mobile',
+ locale: 'en',
+ oldToken: '',
+ os: 'android',
+ appVersion: '7.42.0',
+ },
+ controllerEnv: 'prd',
+ });
+ });
+
it('should not activate push notifications triggers if there is no auth bearer token', async () => {
const mocks = arrangeServicesMocks();
const { controller, messenger } = arrangeMockMessenger();
@@ -384,6 +411,37 @@ describe('NotificationServicesPushController', () => {
expect(result).toBe(true);
});
+ it('should call updateLinksAPI with mobile OS and app version metadata', async () => {
+ const mocks = arrangeServicesMocks();
+ const { controller, messenger } = arrangeMockMessenger({
+ platform: 'mobile',
+ os: 'ios',
+ appVersion: '7.42.0',
+ state: {
+ fcmToken: MOCK_FCM_TOKEN,
+ isPushEnabled: true,
+ isUpdatingFCMToken: false,
+ },
+ });
+ mockAuthBearerTokenCall(messenger);
+
+ const result = await controller.addPushNotificationLinks(MOCK_ADDRESSES);
+
+ expect(mocks.updateLinksAPIMock).toHaveBeenCalledWith({
+ bearerToken: MOCK_JWT,
+ addresses: MOCK_ADDRESSES,
+ regToken: {
+ token: MOCK_FCM_TOKEN,
+ platform: 'mobile',
+ locale: 'en',
+ os: 'ios',
+ appVersion: '7.42.0',
+ },
+ env: 'prd',
+ });
+ expect(result).toBe(true);
+ });
+
it('should return false when push feature is disabled', async () => {
const mocks = arrangeServicesMocks();
const { controller } = arrangeMockMessenger({
diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.ts b/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.ts
index 22abb736a9..44842609fa 100644
--- a/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.ts
+++ b/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.ts
@@ -11,6 +11,7 @@ import log from 'loglevel';
import type { Types } from '../NotificationServicesController';
import type { NotificationServicesPushControllerMethodActions } from './NotificationServicesPushController-method-action-types';
import type { ENV } from './services/endpoints';
+import type { RegToken } from './services/services';
import {
activatePushNotifications,
deleteLinksAPI,
@@ -120,6 +121,11 @@ export type ControllerConfig = {
*/
getLocale?: () => string;
+ /**
+ * App or extension version to include when registering push tokens.
+ */
+ appVersion?: string;
+
/**
* Global switch to determine to use push notifications
* Allows us to control Builds on extension (MV2 vs MV3)
@@ -131,6 +137,11 @@ export type ControllerConfig = {
*/
platform: 'extension' | 'mobile';
+ /**
+ * Mobile operating system to include when registering push tokens.
+ */
+ os?: 'android' | 'ios';
+
/**
* Push Service Interface
* - create reg token
@@ -147,6 +158,11 @@ type StateCommand =
| { type: 'disable' }
| { type: 'update'; fcmToken: string };
+type RegistrationTokenMetadata = Pick<
+ RegToken,
+ 'appVersion' | 'locale' | 'os' | 'platform'
+>;
+
/**
* Manages push notifications for the application, including enabling, disabling, and updating triggers for push notifications.
* This controller integrates with Firebase Cloud Messaging (FCM) to handle the registration and management of push notifications.
@@ -239,6 +255,23 @@ export class NotificationServicesPushController extends BaseController<
}
}
+ #getRegistrationTokenMetadata(): RegistrationTokenMetadata {
+ const tokenMetadata: RegistrationTokenMetadata = {
+ platform: this.#config.platform,
+ locale: this.#config.getLocale?.() ?? 'en',
+ };
+
+ if (this.#config.os) {
+ tokenMetadata.os = this.#config.os;
+ }
+
+ if (this.#config.appVersion) {
+ tokenMetadata.appVersion = this.#config.appVersion;
+ }
+
+ return tokenMetadata;
+ }
+
public async subscribeToPushNotifications(): Promise {
if (!this.#config.isPushFeatureEnabled) {
return;
@@ -293,8 +326,7 @@ export class NotificationServicesPushController extends BaseController<
env: this.#env,
createRegToken: this.#config.pushService.createRegToken,
regToken: {
- platform: this.#config.platform,
- locale: this.#config.getLocale?.() ?? 'en',
+ ...this.#getRegistrationTokenMetadata(),
oldToken: this.state.fcmToken,
},
controllerEnv: this.#config.env ?? 'prd',
@@ -383,8 +415,7 @@ export class NotificationServicesPushController extends BaseController<
addresses,
regToken: {
token: this.state.fcmToken,
- platform: this.#config.platform,
- locale: this.#config.getLocale?.() ?? 'en',
+ ...this.#getRegistrationTokenMetadata(),
},
env: this.#config.env ?? 'prd',
});
@@ -453,8 +484,7 @@ export class NotificationServicesPushController extends BaseController<
env: this.#env,
createRegToken: this.#config.pushService.createRegToken,
regToken: {
- platform: this.#config.platform,
- locale: this.#config.getLocale?.() ?? 'en',
+ ...this.#getRegistrationTokenMetadata(),
oldToken: this.state.fcmToken,
},
controllerEnv: this.#config.env ?? 'prd',
diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/__fixtures__/mockServices.ts b/packages/notification-services-controller/src/NotificationServicesPushController/__fixtures__/mockServices.ts
index d4610a919e..3603734027 100644
--- a/packages/notification-services-controller/src/NotificationServicesPushController/__fixtures__/mockServices.ts
+++ b/packages/notification-services-controller/src/NotificationServicesPushController/__fixtures__/mockServices.ts
@@ -12,6 +12,7 @@ type MockReply = {
export const mockEndpointUpdatePushNotificationLinks = (
mockReply?: MockReply,
+ requestBody?: nock.RequestBodyMatcher,
): nock.Scope => {
const mockResponse = getMockUpdatePushNotificationLinksResponse();
const reply = mockReply ?? {
@@ -19,9 +20,13 @@ export const mockEndpointUpdatePushNotificationLinks = (
body: mockResponse.response,
};
- const mockEndpoint = nock(mockResponse.url).post('').reply(reply.status);
+ const endpoint = nock(mockResponse.url);
+ const mockEndpoint =
+ requestBody === undefined
+ ? endpoint.post('')
+ : endpoint.post('', requestBody);
- return mockEndpoint;
+ return mockEndpoint.reply(reply.status);
};
export const mockEndpointDeletePushNotificationLinks = (
diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/services/services.test.ts b/packages/notification-services-controller/src/NotificationServicesPushController/services/services.test.ts
index 0c302b25ab..f45494d312 100644
--- a/packages/notification-services-controller/src/NotificationServicesPushController/services/services.test.ts
+++ b/packages/notification-services-controller/src/NotificationServicesPushController/services/services.test.ts
@@ -11,6 +11,7 @@ import {
deleteLinksAPI,
updateLinksAPI,
} from './services';
+import type { RegToken } from './services';
// Testing util to clean up verbose logs when testing errors
const mockErrorLog = (): jest.SpyInstance =>
@@ -21,9 +22,35 @@ const MOCK_NEW_REG_TOKEN = 'NEW_REG_TOKEN';
const MOCK_ADDRESSES = ['0x123', '0x456', '0x789'];
const MOCK_JWT = 'MOCK_JWT';
+type CreateRegTokenMock = jest.Mock<
+ Promise,
+ [PushNotificationEnv]
+>;
+
+type ArrangeMocksParams = {
+ bearerToken: string;
+ addresses: string[];
+ createRegToken: CreateRegTokenMock;
+ regToken: {
+ platform: Platform;
+ locale: string;
+ };
+ env: PushNotificationEnv;
+};
+
+type ArrangeMocksResult = {
+ params: ArrangeMocksParams<'extension'>;
+ mobileParams: ArrangeMocksParams<'mobile'>;
+ apis: {
+ mockPut: ReturnType;
+ };
+};
+
describe('NotificationServicesPushController Services', () => {
describe('updateLinksAPI', () => {
- const act = async (): Promise =>
+ const act = async (
+ regTokenOverrides?: Partial,
+ ): Promise =>
await updateLinksAPI({
bearerToken: MOCK_JWT,
addresses: MOCK_ADDRESSES,
@@ -31,6 +58,7 @@ describe('NotificationServicesPushController Services', () => {
token: MOCK_NEW_REG_TOKEN,
platform: 'extension',
locale: 'en',
+ ...regTokenOverrides,
},
});
@@ -55,16 +83,44 @@ describe('NotificationServicesPushController Services', () => {
const result = await act();
expect(result).toBe(false);
});
+
+ it('should include mobile metadata when provided', async () => {
+ const mockAPI = mockEndpointUpdatePushNotificationLinks(undefined, {
+ addresses: MOCK_ADDRESSES,
+ registration_token: {
+ token: MOCK_NEW_REG_TOKEN,
+ platform: 'mobile',
+ locale: 'en',
+ os: 'ios',
+ appVersion: '7.42.0',
+ },
+ });
+
+ const result = await act({
+ platform: 'mobile',
+ os: 'ios',
+ appVersion: '7.42.0',
+ });
+
+ expect(mockAPI.isDone()).toBe(true);
+ expect(result).toBe(true);
+ });
});
describe('activatePushNotifications', () => {
- // Internal testing utility - return type is inferred
- // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
- const arrangeMocks = (override?: { mockPut?: { status: number } }) => {
+ const arrangeMocks = (override?: {
+ mockPut?: { status: number };
+ requestBody?: Parameters<
+ typeof mockEndpointUpdatePushNotificationLinks
+ >[1];
+ }): ArrangeMocksResult => {
+ const createRegToken: CreateRegTokenMock = jest
+ .fn, [PushNotificationEnv]>()
+ .mockResolvedValue(MOCK_NEW_REG_TOKEN);
const params = {
bearerToken: MOCK_JWT,
addresses: MOCK_ADDRESSES,
- createRegToken: jest.fn().mockResolvedValue(MOCK_NEW_REG_TOKEN),
+ createRegToken,
regToken: {
platform: 'extension' as const,
locale: 'en',
@@ -84,7 +140,10 @@ describe('NotificationServicesPushController Services', () => {
params,
mobileParams,
apis: {
- mockPut: mockEndpointUpdatePushNotificationLinks(override?.mockPut),
+ mockPut: mockEndpointUpdatePushNotificationLinks(
+ override?.mockPut,
+ override?.requestBody,
+ ),
},
};
};
@@ -127,6 +186,35 @@ describe('NotificationServicesPushController Services', () => {
expect(apis.mockPut.isDone()).toBe(true);
expect(result).toBe(MOCK_NEW_REG_TOKEN);
});
+
+ it('should pass mobile metadata when provided', async () => {
+ const { mobileParams, apis } = arrangeMocks({
+ requestBody: {
+ addresses: MOCK_ADDRESSES,
+ registration_token: {
+ token: MOCK_NEW_REG_TOKEN,
+ platform: 'mobile',
+ locale: 'en',
+ os: 'android',
+ appVersion: '7.42.0',
+ },
+ },
+ });
+ const paramsWithMetadata = {
+ ...mobileParams,
+ regToken: {
+ ...mobileParams.regToken,
+ os: 'android' as const,
+ appVersion: '7.42.0',
+ },
+ };
+
+ const result = await activatePushNotifications(paramsWithMetadata);
+
+ expect(mobileParams.createRegToken).toHaveBeenCalled();
+ expect(apis.mockPut.isDone()).toBe(true);
+ expect(result).toBe(MOCK_NEW_REG_TOKEN);
+ });
});
describe('deleteLinksAPI', () => {
diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/services/services.ts b/packages/notification-services-controller/src/NotificationServicesPushController/services/services.ts
index 26895745a1..95067488ea 100644
--- a/packages/notification-services-controller/src/NotificationServicesPushController/services/services.ts
+++ b/packages/notification-services-controller/src/NotificationServicesPushController/services/services.ts
@@ -10,6 +10,8 @@ export type RegToken = {
token: string;
platform: 'extension' | 'mobile' | 'portfolio';
locale: string;
+ os?: 'android' | 'ios';
+ appVersion?: string;
oldToken?: string;
};
@@ -26,6 +28,8 @@ export type PushTokenRequest = {
token: string;
platform: 'extension' | 'mobile' | 'portfolio';
locale: string;
+ os?: 'android' | 'ios';
+ appVersion?: string;
oldToken?: string;
};
};
@@ -129,7 +133,10 @@ type ActivatePushNotificationsParams = {
// Other Request Parameters
bearerToken: string;
addresses: string[];
- regToken: Pick;
+ regToken: Pick<
+ RegToken,
+ 'appVersion' | 'locale' | 'oldToken' | 'os' | 'platform'
+ >;
};
/**
@@ -155,6 +162,8 @@ export async function activatePushNotifications(
token: regToken,
platform: params.regToken.platform,
locale: params.regToken.locale,
+ os: params.regToken.os,
+ appVersion: params.regToken.appVersion,
oldToken: params.regToken.oldToken,
},
env: params.controllerEnv,
diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md
index 50b409eb64..38f10fd91d 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]
+### Changed
+
+- Bump `@metamask/core-backend` from `^6.2.2` to `^6.3.0` ([#8813](https://github.com/MetaMask/core/pull/8813))
+
## [65.4.0]
### Added
diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json
index 87a81c9bd1..4eda1c387c 100644
--- a/packages/transaction-controller/package.json
+++ b/packages/transaction-controller/package.json
@@ -64,7 +64,7 @@
"@metamask/approval-controller": "^9.0.1",
"@metamask/base-controller": "^9.1.0",
"@metamask/controller-utils": "^12.1.0",
- "@metamask/core-backend": "^6.2.2",
+ "@metamask/core-backend": "^6.3.0",
"@metamask/gas-fee-controller": "^26.2.1",
"@metamask/messenger": "^1.2.0",
"@metamask/metamask-eth-abis": "^3.1.1",
diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md
index 5495abc37f..e5c47314c3 100644
--- a/packages/transaction-pay-controller/CHANGELOG.md
+++ b/packages/transaction-pay-controller/CHANGELOG.md
@@ -7,10 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [22.5.0]
+
### Added
+- Add Across submit support for post-quote Predict withdraw flows ([#8761](https://github.com/MetaMask/core/pull/8761))
- Add Polymarket deposit-wallet support to the Relay strategy for `predictWithdraw` transactions, routed via the `isPolymarketDepositWallet` flag on `TransactionConfig` ([#8754](https://github.com/MetaMask/core/pull/8754))
+### Changed
+
+- Move the Relay gasless execution feature flag to `confirmations_pay_extended.payStrategies.relay.gaslessEnabled` ([#8810](https://github.com/MetaMask/core/pull/8810))
+
## [22.4.0]
### Added
@@ -889,7 +896,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Initial release ([#6820](https://github.com/MetaMask/core/pull/6820))
-[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.4.0...HEAD
+[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.5.0...HEAD
+[22.5.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.4.0...@metamask/transaction-pay-controller@22.5.0
[22.4.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.3.1...@metamask/transaction-pay-controller@22.4.0
[22.3.1]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.3.0...@metamask/transaction-pay-controller@22.3.1
[22.3.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.2.0...@metamask/transaction-pay-controller@22.3.0
diff --git a/packages/transaction-pay-controller/package.json b/packages/transaction-pay-controller/package.json
index 18355ad1d7..1a7d37b1ef 100644
--- a/packages/transaction-pay-controller/package.json
+++ b/packages/transaction-pay-controller/package.json
@@ -1,6 +1,6 @@
{
"name": "@metamask/transaction-pay-controller",
- "version": "22.4.0",
+ "version": "22.5.0",
"description": "Manages alternate payment strategies to provide required funds for transactions in MetaMask",
"keywords": [
"Ethereum",
diff --git a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts
index d7ff9627bd..8db721b870 100644
--- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts
+++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts
@@ -33,7 +33,10 @@ import { getAcrossDestination } from './across-actions';
import { hasUnsupportedTransactionAuthorizationList } from './authorization-list';
import { normalizeAcrossRequest } from './perps';
import { isAcrossQuoteRequest } from './requests';
-import { getAcrossOrderedTransactions } from './transactions';
+import {
+ getAcrossOrderedTransactions,
+ getOriginalTransactionGas,
+} from './transactions';
import type {
AcrossAction,
AcrossActionRequestBody,
@@ -796,25 +799,6 @@ function combinePostQuoteGas(
};
}
-function getOriginalTransactionGas(
- transaction: TransactionMeta,
-): number | undefined {
- const nestedGas = transaction.nestedTransactions?.find((tx) => tx.gas)?.gas;
- const rawGas = nestedGas ?? transaction.txParams.gas;
-
- if (rawGas === undefined) {
- return undefined;
- }
-
- const gas = new BigNumber(rawGas);
-
- if (!gas.isFinite() || gas.isNaN() || !gas.isInteger() || gas.lte(0)) {
- return undefined;
- }
-
- return gas.toNumber();
-}
-
function calculateOriginalSourceNetworkCost({
gas,
messenger,
diff --git a/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts b/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts
index 074ee59760..dd453928c1 100644
--- a/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts
+++ b/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts
@@ -79,6 +79,7 @@ const QUOTE_MOCK: TransactionPayQuote = {
},
},
request: {
+ actions: [],
amount: '100',
tradeType: 'exactOutput',
},
@@ -104,8 +105,10 @@ describe('Across Submit', () => {
const {
addTransactionBatchMock,
addTransactionMock,
+ estimateGasBatchMock,
estimateGasMock,
findNetworkClientIdByChainIdMock,
+ getKeyringControllerStateMock,
getRemoteFeatureFlagControllerStateMock,
getTransactionControllerStateMock,
messenger,
@@ -126,6 +129,16 @@ describe('Across Submit', () => {
},
},
});
+ getKeyringControllerStateMock.mockReturnValue({
+ isUnlocked: true,
+ keyrings: [
+ {
+ type: 'HD Key Tree',
+ accounts: [FROM_MOCK],
+ metadata: { id: 'hd-keyring', name: 'HD Key Tree' },
+ },
+ ],
+ });
estimateGasMock.mockResolvedValue({
gas: '0x5208',
@@ -231,6 +244,7 @@ describe('Across Submit', () => {
expect(addTransactionBatchMock).toHaveBeenCalledWith(
expect.objectContaining({
+ excludeNativeTokenForFee: true,
gasFeeToken: QUOTE_MOCK.request.sourceTokenAddress,
}),
);
@@ -285,6 +299,237 @@ describe('Across Submit', () => {
);
});
+ it('estimates 7702 batch gas when a post-quote original transaction was not priced in the quote', async () => {
+ const postQuote = {
+ ...QUOTE_MOCK,
+ original: {
+ ...QUOTE_MOCK.original,
+ metamask: {
+ gasLimits: [{ estimate: 43000, max: 64000 }],
+ is7702: true,
+ },
+ },
+ request: {
+ ...QUOTE_MOCK.request,
+ isPostQuote: true,
+ },
+ } as TransactionPayQuote;
+
+ getKeyringControllerStateMock.mockReturnValue({
+ isUnlocked: true,
+ keyrings: [
+ {
+ type: 'HD Key Tree',
+ accounts: [FROM_MOCK],
+ metadata: { id: 'hd-keyring', name: 'HD Key Tree' },
+ },
+ ],
+ });
+ estimateGasBatchMock.mockResolvedValue({
+ gasLimits: [123456],
+ });
+
+ await submitAcrossQuotes({
+ accountSupports7702: true,
+ messenger,
+ quotes: [postQuote],
+ transaction: {
+ ...TRANSACTION_META_MOCK,
+ type: TransactionType.batch,
+ nestedTransactions: [{ type: TransactionType.predictWithdraw }],
+ txParams: {
+ from: FROM_MOCK,
+ to: '0x000000000000000000000000000000000000dEaD' as Hex,
+ data: '0x12345678' as Hex,
+ },
+ } as TransactionMeta,
+ isSmartTransaction: jest.fn(),
+ });
+
+ expect(estimateGasBatchMock).toHaveBeenCalledWith({
+ chainId: QUOTE_MOCK.request.sourceChainId,
+ from: FROM_MOCK,
+ transactions: [
+ expect.objectContaining({
+ data: '0x12345678',
+ gas: undefined,
+ to: '0x000000000000000000000000000000000000dEaD',
+ }),
+ expect.objectContaining({
+ data: QUOTE_MOCK.original.quote.approvalTxns[0].data,
+ gas: undefined,
+ to: QUOTE_MOCK.original.quote.approvalTxns[0].to,
+ }),
+ expect.objectContaining({
+ data: QUOTE_MOCK.original.quote.swapTx.data,
+ gas: undefined,
+ to: QUOTE_MOCK.original.quote.swapTx.to,
+ }),
+ ],
+ });
+ expect(addTransactionBatchMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ disable7702: false,
+ disableHook: true,
+ disableSequential: true,
+ gasLimit7702: toHex(123456),
+ transactions: [
+ expect.objectContaining({
+ params: expect.objectContaining({
+ data: '0x12345678',
+ gas: undefined,
+ to: '0x000000000000000000000000000000000000dEaD',
+ }),
+ type: TransactionType.predictWithdraw,
+ }),
+ expect.objectContaining({
+ params: expect.objectContaining({
+ data: QUOTE_MOCK.original.quote.approvalTxns[0].data,
+ gas: undefined,
+ to: QUOTE_MOCK.original.quote.approvalTxns[0].to,
+ }),
+ type: TransactionType.tokenMethodApprove,
+ }),
+ expect.objectContaining({
+ params: expect.objectContaining({
+ data: QUOTE_MOCK.original.quote.swapTx.data,
+ gas: undefined,
+ to: QUOTE_MOCK.original.quote.swapTx.to,
+ }),
+ type: TransactionType.predictAcrossWithdraw,
+ }),
+ ],
+ }),
+ );
+ });
+
+ it('reuses quoted 7702 batch gas when the post-quote original transaction already has gas', async () => {
+ const postQuote = {
+ ...QUOTE_MOCK,
+ original: {
+ ...QUOTE_MOCK.original,
+ metamask: {
+ gasLimits: [{ estimate: 43000, max: 64000 }],
+ is7702: true,
+ },
+ quote: {
+ ...QUOTE_MOCK.original.quote,
+ approvalTxns: [],
+ },
+ },
+ request: {
+ ...QUOTE_MOCK.request,
+ isPostQuote: true,
+ },
+ } as TransactionPayQuote;
+
+ await submitAcrossQuotes({
+ accountSupports7702: true,
+ messenger,
+ quotes: [postQuote],
+ transaction: {
+ ...TRANSACTION_META_MOCK,
+ type: TransactionType.swap,
+ txParams: {
+ from: FROM_MOCK,
+ to: '0x000000000000000000000000000000000000dEaD' as Hex,
+ data: '0x12345678' as Hex,
+ gas: '0x5208',
+ },
+ } as TransactionMeta,
+ isSmartTransaction: jest.fn(),
+ });
+
+ expect(estimateGasBatchMock).not.toHaveBeenCalled();
+ expect(addTransactionBatchMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ disable7702: false,
+ gasLimit7702: toHex(64000),
+ transactions: [
+ expect.objectContaining({
+ params: expect.objectContaining({
+ data: '0x12345678',
+ gas: undefined,
+ to: '0x000000000000000000000000000000000000dEaD',
+ }),
+ type: TransactionType.swap,
+ }),
+ expect.objectContaining({
+ params: expect.objectContaining({
+ data: QUOTE_MOCK.original.quote.swapTx.data,
+ gas: undefined,
+ to: QUOTE_MOCK.original.quote.swapTx.to,
+ }),
+ type: TransactionType.swap,
+ }),
+ ],
+ }),
+ );
+ });
+
+ it('submits 7702 batches without estimated gas when the account cannot sign authorizations', async () => {
+ getKeyringControllerStateMock.mockReturnValue({
+ isUnlocked: true,
+ keyrings: [
+ {
+ type: 'Ledger Hardware',
+ accounts: [FROM_MOCK],
+ metadata: { id: 'ledger-keyring', name: 'Ledger Hardware' },
+ },
+ ],
+ });
+
+ await submitAcrossQuotes({
+ accountSupports7702: true,
+ messenger,
+ quotes: [QUOTE_MOCK],
+ transaction: {
+ ...TRANSACTION_META_MOCK,
+ txParams: {
+ ...TRANSACTION_META_MOCK.txParams,
+ authorizationList: [{ address: '0xabc' as Hex }],
+ },
+ } as TransactionMeta,
+ isSmartTransaction: jest.fn(),
+ });
+
+ expect(estimateGasBatchMock).not.toHaveBeenCalled();
+ expect(addTransactionBatchMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ disable7702: false,
+ gasLimit7702: undefined,
+ }),
+ );
+ });
+
+ it('submits 7702 batches without estimated gas when estimation returns multiple gas limits', async () => {
+ estimateGasBatchMock.mockResolvedValue({
+ gasLimits: [123456, 234567],
+ });
+
+ await submitAcrossQuotes({
+ accountSupports7702: true,
+ messenger,
+ quotes: [QUOTE_MOCK],
+ transaction: {
+ ...TRANSACTION_META_MOCK,
+ txParams: {
+ ...TRANSACTION_META_MOCK.txParams,
+ authorizationList: [{ address: '0xabc' as Hex }],
+ },
+ } as TransactionMeta,
+ isSmartTransaction: jest.fn(),
+ });
+
+ expect(estimateGasBatchMock).toHaveBeenCalledTimes(1);
+ expect(addTransactionBatchMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ disable7702: false,
+ gasLimit7702: undefined,
+ }),
+ );
+ });
+
it('submits a single transaction when no approvals', async () => {
const noApprovalQuote = {
...QUOTE_MOCK,
@@ -340,6 +585,7 @@ describe('Across Submit', () => {
expect(addTransactionMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
+ excludeNativeTokenForFee: true,
gasFeeToken: QUOTE_MOCK.request.sourceTokenAddress,
}),
);
@@ -401,6 +647,297 @@ describe('Across Submit', () => {
);
});
+ it('prepends the original transaction and uses predict withdraw type for post-quote predict withdraws', async () => {
+ const postQuote = {
+ ...QUOTE_MOCK,
+ original: {
+ ...QUOTE_MOCK.original,
+ metamask: {
+ gasLimits: [
+ { estimate: 50000, max: 50000 },
+ { estimate: 22000, max: 22000 },
+ ],
+ is7702: false,
+ },
+ quote: {
+ ...QUOTE_MOCK.original.quote,
+ approvalTxns: [],
+ },
+ },
+ request: {
+ ...QUOTE_MOCK.request,
+ isPostQuote: true,
+ },
+ } as TransactionPayQuote;
+
+ await submitAcrossQuotes({
+ messenger,
+ quotes: [postQuote],
+ transaction: {
+ ...TRANSACTION_META_MOCK,
+ type: TransactionType.batch,
+ nestedTransactions: [{ type: TransactionType.predictWithdraw }],
+ txParams: {
+ from: FROM_MOCK,
+ to: '0x000000000000000000000000000000000000dEaD' as Hex,
+ data: '0x12345678' as Hex,
+ value: '0x1' as Hex,
+ },
+ } as TransactionMeta,
+ isSmartTransaction: jest.fn(),
+ });
+
+ expect(addTransactionBatchMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ from: FROM_MOCK,
+ transactions: [
+ {
+ params: expect.objectContaining({
+ data: '0x12345678',
+ gas: toHex(50000),
+ to: '0x000000000000000000000000000000000000dEaD',
+ value: '0x1',
+ }),
+ type: TransactionType.predictWithdraw,
+ },
+ {
+ params: expect.objectContaining({
+ data: QUOTE_MOCK.original.quote.swapTx.data,
+ gas: toHex(22000),
+ to: QUOTE_MOCK.original.quote.swapTx.to,
+ }),
+ type: TransactionType.predictAcrossWithdraw,
+ },
+ ],
+ }),
+ );
+ });
+
+ it('keeps Across gas limits aligned when post-quote original gas is absent', async () => {
+ const postQuote = {
+ ...QUOTE_MOCK,
+ original: {
+ ...QUOTE_MOCK.original,
+ metamask: {
+ gasLimits: [{ estimate: 22000, max: 22000 }],
+ is7702: false,
+ },
+ quote: {
+ ...QUOTE_MOCK.original.quote,
+ approvalTxns: [],
+ },
+ },
+ request: {
+ ...QUOTE_MOCK.request,
+ isPostQuote: true,
+ },
+ } as TransactionPayQuote;
+
+ await submitAcrossQuotes({
+ messenger,
+ quotes: [postQuote],
+ transaction: {
+ ...TRANSACTION_META_MOCK,
+ type: TransactionType.batch,
+ nestedTransactions: [{ type: TransactionType.predictWithdraw }],
+ txParams: {
+ from: FROM_MOCK,
+ to: '0x000000000000000000000000000000000000dEaD' as Hex,
+ data: '0x12345678' as Hex,
+ value: '0x1' as Hex,
+ },
+ } as TransactionMeta,
+ isSmartTransaction: jest.fn(),
+ });
+
+ expect(addTransactionBatchMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ transactions: [
+ {
+ params: expect.objectContaining({
+ data: '0x12345678',
+ gas: undefined,
+ to: '0x000000000000000000000000000000000000dEaD',
+ value: '0x1',
+ }),
+ type: TransactionType.predictWithdraw,
+ },
+ {
+ params: expect.objectContaining({
+ data: QUOTE_MOCK.original.quote.swapTx.data,
+ gas: toHex(22000),
+ to: QUOTE_MOCK.original.quote.swapTx.to,
+ }),
+ type: TransactionType.predictAcrossWithdraw,
+ },
+ ],
+ }),
+ );
+ });
+
+ it('passes gas fee token for post-quote predict withdraw batches', async () => {
+ const postQuote = {
+ ...QUOTE_MOCK,
+ fees: {
+ ...QUOTE_MOCK.fees,
+ isSourceGasFeeToken: true,
+ },
+ original: {
+ ...QUOTE_MOCK.original,
+ metamask: {
+ gasLimits: [
+ { estimate: 50000, max: 50000 },
+ { estimate: 22000, max: 22000 },
+ ],
+ is7702: false,
+ },
+ quote: {
+ ...QUOTE_MOCK.original.quote,
+ approvalTxns: [],
+ },
+ },
+ request: {
+ ...QUOTE_MOCK.request,
+ isPostQuote: true,
+ },
+ } as TransactionPayQuote;
+
+ await submitAcrossQuotes({
+ messenger,
+ quotes: [postQuote],
+ transaction: {
+ ...TRANSACTION_META_MOCK,
+ type: TransactionType.predictWithdraw,
+ txParams: {
+ from: FROM_MOCK,
+ to: '0x000000000000000000000000000000000000dEaD' as Hex,
+ data: '0x12345678' as Hex,
+ },
+ } as TransactionMeta,
+ isSmartTransaction: jest.fn(),
+ });
+
+ expect(addTransactionBatchMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ excludeNativeTokenForFee: true,
+ gasFeeToken: QUOTE_MOCK.request.sourceTokenAddress,
+ }),
+ );
+ });
+
+ it('submits post-quote predict withdraw parent authorization lists as 7702 batches', async () => {
+ const postQuote = {
+ ...QUOTE_MOCK,
+ original: {
+ ...QUOTE_MOCK.original,
+ metamask: {
+ gasLimits: [{ estimate: 22000, max: 22000 }],
+ is7702: false,
+ },
+ quote: {
+ ...QUOTE_MOCK.original.quote,
+ approvalTxns: [],
+ },
+ },
+ request: {
+ ...QUOTE_MOCK.request,
+ isPostQuote: true,
+ },
+ } as TransactionPayQuote;
+
+ await submitAcrossQuotes({
+ messenger,
+ quotes: [postQuote],
+ transaction: {
+ ...TRANSACTION_META_MOCK,
+ type: TransactionType.batch,
+ nestedTransactions: [{ type: TransactionType.predictWithdraw }],
+ txParams: {
+ authorizationList: [{ address: '0xabc' as Hex }],
+ from: FROM_MOCK,
+ to: '0x000000000000000000000000000000000000dEaD' as Hex,
+ data: '0x12345678' as Hex,
+ },
+ } as TransactionMeta,
+ isSmartTransaction: jest.fn(),
+ });
+
+ expect(addTransactionBatchMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ disable7702: false,
+ disableHook: true,
+ disableSequential: true,
+ transactions: [
+ {
+ params: expect.objectContaining({
+ data: '0x12345678',
+ gas: undefined,
+ to: '0x000000000000000000000000000000000000dEaD',
+ }),
+ type: TransactionType.predictWithdraw,
+ },
+ {
+ params: expect.objectContaining({
+ data: QUOTE_MOCK.original.quote.swapTx.data,
+ gas: undefined,
+ to: QUOTE_MOCK.original.quote.swapTx.to,
+ }),
+ type: TransactionType.predictAcrossWithdraw,
+ },
+ ],
+ }),
+ );
+ });
+
+ it('uses the original transaction type for non-predict post-quote batches', async () => {
+ const postQuote = {
+ ...QUOTE_MOCK,
+ original: {
+ ...QUOTE_MOCK.original,
+ metamask: {
+ gasLimits: [undefined as never, { estimate: 22000, max: 22000 }],
+ is7702: false,
+ },
+ quote: {
+ ...QUOTE_MOCK.original.quote,
+ approvalTxns: [],
+ },
+ },
+ request: {
+ ...QUOTE_MOCK.request,
+ isPostQuote: true,
+ },
+ } as TransactionPayQuote;
+
+ await submitAcrossQuotes({
+ messenger,
+ quotes: [postQuote],
+ transaction: {
+ ...TRANSACTION_META_MOCK,
+ type: TransactionType.swap,
+ txParams: {
+ from: FROM_MOCK,
+ to: '0x000000000000000000000000000000000000dEaD' as Hex,
+ data: '0x12345678' as Hex,
+ },
+ } as TransactionMeta,
+ isSmartTransaction: jest.fn(),
+ });
+
+ expect(addTransactionBatchMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ transactions: expect.arrayContaining([
+ expect.objectContaining({
+ params: expect.objectContaining({
+ gas: undefined,
+ }),
+ type: TransactionType.swap,
+ }),
+ ]),
+ }),
+ );
+ });
+
it('preserves transaction type when not perps or predict', async () => {
const noApprovalQuote = {
...QUOTE_MOCK,
diff --git a/packages/transaction-pay-controller/src/strategy/across/across-submit.ts b/packages/transaction-pay-controller/src/strategy/across/across-submit.ts
index cb09bac1cb..3c52f2395d 100644
--- a/packages/transaction-pay-controller/src/strategy/across/across-submit.ts
+++ b/packages/transaction-pay-controller/src/strategy/across/across-submit.ts
@@ -18,14 +18,20 @@ import type {
TransactionPayControllerMessenger,
TransactionPayQuote,
} from '../../types';
+import { accountSupports7702 } from '../../utils/7702';
import { getPayStrategiesConfig } from '../../utils/feature-flags';
+import { getGasBuffer } from '../../utils/feature-flags';
import {
collectTransactionIds,
getTransaction,
updateTransaction,
+ isPredictWithdrawTransaction,
waitForTransactionConfirmed,
} from '../../utils/transaction';
-import { getAcrossOrderedTransactions } from './transactions';
+import {
+ getAcrossOrderedTransactions,
+ getOriginalTransactionGas,
+} from './transactions';
import type { AcrossQuote } from './types';
const log = createModuleLogger(projectLogger, 'across-strategy');
@@ -33,7 +39,7 @@ const ACROSS_STATUS_POLL_INTERVAL = 1000;
type PreparedAcrossTransaction = {
params: TransactionParams;
- type: TransactionType;
+ type: TransactionMeta['type'];
};
/**
@@ -79,10 +85,10 @@ async function executeSingleQuote(
},
);
- const acrossDepositType = getAcrossDepositType(transaction.type);
+ const acrossDepositType = getAcrossDepositType(transaction);
const transactionHash = await submitTransactions(
quote,
- transaction.id,
+ transaction,
acrossDepositType,
messenger,
);
@@ -105,14 +111,14 @@ async function executeSingleQuote(
* Submit transactions for an Across quote.
*
* @param quote - Across quote.
- * @param parentTransactionId - ID of the parent transaction.
+ * @param parentTransaction - Parent transaction.
* @param acrossDepositType - Transaction type used for the swap/deposit step.
* @param messenger - Controller messenger.
* @returns Hash of the last submitted transaction, if available.
*/
async function submitTransactions(
quote: TransactionPayQuote,
- parentTransactionId: string,
+ parentTransaction: TransactionMeta,
acrossDepositType: TransactionType,
messenger: TransactionPayControllerMessenger,
): Promise {
@@ -124,32 +130,81 @@ async function submitTransactions(
quote: quote.original.quote,
swapType: acrossDepositType,
});
+ const shouldPrependOriginalTransaction =
+ quote.request.isPostQuote === true &&
+ parentTransaction.txParams.to !== undefined;
+ const hasPrependedOriginalGasLimit =
+ shouldPrependOriginalTransaction &&
+ !is7702 &&
+ quoteGasLimits.length > orderedTransactions.length;
+ const gasLimitOffset = hasPrependedOriginalGasLimit ? 1 : 0;
+ const transactionCount =
+ orderedTransactions.length + (shouldPrependOriginalTransaction ? 1 : 0);
const networkClientId = messenger.call(
'NetworkController:findNetworkClientIdByChainId',
chainId,
);
- const batchGasLimit =
- is7702 && orderedTransactions.length > 1
- ? quoteGasLimits[0]?.max
- : undefined;
+ const is7702Batch = is7702 && transactionCount > 1;
+ const canUseQuotedBatchGasLimit =
+ is7702Batch &&
+ (!shouldPrependOriginalTransaction ||
+ hasOriginalTransactionGas(parentTransaction));
+ const batchGasLimit = canUseQuotedBatchGasLimit
+ ? quoteGasLimits[0]?.max
+ : undefined;
- if (is7702 && orderedTransactions.length > 1 && batchGasLimit === undefined) {
+ if (canUseQuotedBatchGasLimit && batchGasLimit === undefined) {
throw new Error('Missing quote gas limit for Across 7702 batch');
}
- const gasLimit7702 =
+ const quotedGasLimit7702 =
batchGasLimit === undefined ? undefined : toHex(batchGasLimit);
+ const parentHasAuthorizationList = Boolean(
+ parentTransaction.txParams.authorizationList?.length,
+ );
+
+ const shouldUseGasFeeToken7702Submit = shouldEstimate7702SubmitBatch(
+ parentTransaction,
+ quote,
+ )
+ ? accountSupports7702(messenger, from)
+ : false;
+ const shouldUse7702Submit = [
+ Boolean(quotedGasLimit7702),
+ is7702Batch,
+ parentHasAuthorizationList,
+ shouldUseGasFeeToken7702Submit,
+ ].some(Boolean);
+
+ const shouldEstimateGasLimit7702 = !quotedGasLimit7702 && shouldUse7702Submit;
+
+ const estimatedGasLimit7702 = shouldEstimateGasLimit7702
+ ? await estimateSubmitBatchGasLimit7702({
+ chainId,
+ from,
+ messenger,
+ orderedTransactions,
+ parentTransaction,
+ shouldPrependOriginalTransaction,
+ })
+ : undefined;
+
+ const gasLimit7702 = quotedGasLimit7702 ?? estimatedGasLimit7702;
+ const submitAs7702 = shouldUse7702Submit || Boolean(gasLimit7702);
- const transactions: PreparedAcrossTransaction[] = orderedTransactions.map(
- (transaction, index) => {
- const gasLimit = gasLimit7702 ? undefined : quoteGasLimits[index]?.max;
+ const acrossTransactions: PreparedAcrossTransaction[] =
+ orderedTransactions.map((transaction, index) => {
+ const gasLimit = submitAs7702
+ ? undefined
+ : quoteGasLimits[index + gasLimitOffset]?.max;
- if (gasLimit === undefined && !gasLimit7702) {
+ if (gasLimit === undefined && !submitAs7702) {
+ const quoteGasIndex = index + gasLimitOffset;
const errorMessage =
transaction.kind === 'approval'
- ? `Missing quote gas limit for Across approval transaction at index ${index}`
+ ? `Missing quote gas limit for Across approval transaction at index ${quoteGasIndex}`
: 'Missing quote gas limit for Across swap transaction';
throw new Error(errorMessage);
@@ -167,8 +222,18 @@ async function submitTransactions(
}),
type: transaction.type ?? acrossDepositType,
};
- },
- );
+ });
+ const originalTransaction = shouldPrependOriginalTransaction
+ ? [
+ buildOriginalTransaction(
+ parentTransaction,
+ submitAs7702 || !hasPrependedOriginalGasLimit
+ ? undefined
+ : quoteGasLimits[0]?.max,
+ ),
+ ]
+ : [];
+ const transactions = [...originalTransaction, ...acrossTransactions];
const transactionIds: string[] = [];
@@ -181,7 +246,7 @@ async function submitTransactions(
updateTransaction(
{
- transactionId: parentTransactionId,
+ transactionId: parentTransaction.id,
messenger,
note: 'Add required transaction ID from Across submission',
},
@@ -197,6 +262,7 @@ async function submitTransactions(
const gasFeeToken = quote.fees.isSourceGasFeeToken
? quote.request.sourceTokenAddress
: undefined;
+ const excludeNativeTokenForFee = gasFeeToken ? true : undefined;
try {
if (transactions.length === 1) {
@@ -204,6 +270,7 @@ async function submitTransactions(
'TransactionController:addTransaction',
transactions[0].params,
{
+ excludeNativeTokenForFee,
gasFeeToken,
networkClientId,
origin: ORIGIN_METAMASK,
@@ -218,9 +285,10 @@ async function submitTransactions(
}));
await messenger.call('TransactionController:addTransactionBatch', {
- disable7702: !gasLimit7702,
- disableHook: Boolean(gasLimit7702),
- disableSequential: Boolean(gasLimit7702),
+ disable7702: !submitAs7702,
+ disableHook: submitAs7702,
+ disableSequential: submitAs7702,
+ excludeNativeTokenForFee,
from,
gasFeeToken,
gasLimit7702,
@@ -260,6 +328,13 @@ type AcrossStatusResponse = {
txHash?: Hex;
};
+/**
+ * Poll Across until a submitted deposit reaches a terminal status.
+ *
+ * @param transactionHash - Source-chain deposit transaction hash.
+ * @param messenger - Controller messenger.
+ * @returns Destination/fill transaction hash when available, otherwise the source hash.
+ */
async function waitForAcrossCompletion(
transactionHash: Hex | undefined,
messenger: TransactionPayControllerMessenger,
@@ -335,10 +410,168 @@ async function waitForAcrossCompletion(
}
}
-function getAcrossDepositType(
- transactionType?: TransactionType,
-): TransactionType {
- switch (transactionType) {
+/**
+ * Check whether submit should estimate a 7702 batch gas limit.
+ *
+ * This is needed for Predict withdraw post-quote flows that pay source-chain
+ * gas with the source token, because the final submit batch can differ from the
+ * batch shape that Across quoted.
+ *
+ * @param parentTransaction - Original transaction metadata.
+ * @param quote - Across quote selected for execution.
+ * @returns Whether submit should try to estimate the final 7702 batch gas.
+ */
+function shouldEstimate7702SubmitBatch(
+ parentTransaction: TransactionMeta,
+ quote: TransactionPayQuote,
+): boolean {
+ return (
+ isPredictWithdrawTransaction(parentTransaction) &&
+ quote.request.isPostQuote === true &&
+ quote.fees.isSourceGasFeeToken === true
+ );
+}
+
+/**
+ * Estimate the 7702 batch gas limit for the actual submit payload.
+ *
+ * Quotes can contain a combined 7702 gas limit that only covered the Across
+ * approval/swap legs. When submit prepends the original transaction, estimate
+ * the final batch shape so the gas limit covers every submitted leg.
+ *
+ * @param args - Estimation arguments.
+ * @param args.chainId - Source chain ID.
+ * @param args.from - Sender address.
+ * @param args.messenger - Controller messenger.
+ * @param args.orderedTransactions - Across approval/swap legs in submission order.
+ * @param args.parentTransaction - Original transaction that may be prepended.
+ * @param args.shouldPrependOriginalTransaction - Whether to include the original transaction in the estimate.
+ * @returns Hex gas limit, or `undefined` when estimation is unavailable.
+ */
+async function estimateSubmitBatchGasLimit7702({
+ chainId,
+ from,
+ messenger,
+ orderedTransactions,
+ parentTransaction,
+ shouldPrependOriginalTransaction,
+}: {
+ chainId: Hex;
+ from: Hex;
+ messenger: TransactionPayControllerMessenger;
+ orderedTransactions: ReturnType;
+ parentTransaction: TransactionMeta;
+ shouldPrependOriginalTransaction: boolean;
+}): Promise {
+ if (!accountSupports7702(messenger, from)) {
+ return undefined;
+ }
+
+ const originalTransaction = shouldPrependOriginalTransaction
+ ? [buildOriginalTransaction(parentTransaction)]
+ : [];
+
+ const acrossTransactions = orderedTransactions.map((transaction) => ({
+ params: buildTransactionParams(from, {
+ chainId: transaction.chainId,
+ data: transaction.data,
+ maxFeePerGas: transaction.maxFeePerGas,
+ maxPriorityFeePerGas: transaction.maxPriorityFeePerGas,
+ to: transaction.to,
+ value: transaction.value,
+ }),
+ type: transaction.type,
+ }));
+
+ const transactions = [...originalTransaction, ...acrossTransactions];
+
+ try {
+ const result = await messenger.call(
+ 'TransactionController:estimateGasBatch',
+ {
+ chainId,
+ from,
+ transactions: transactions.map(({ params }) =>
+ toBatchTransactionParams(params),
+ ),
+ },
+ );
+
+ if (result.gasLimits.length !== 1) {
+ return undefined;
+ }
+
+ const gasLimit = Math.ceil(
+ result.gasLimits[0] * getGasBuffer(messenger, chainId),
+ );
+
+ return toHex(gasLimit);
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * Build the original parent transaction as a prepared batch leg.
+ *
+ * @param transaction - Original transaction metadata.
+ * @param gasLimit - Optional gas limit to pin on the original leg.
+ * @returns Prepared transaction params and transaction type for the original leg.
+ */
+function buildOriginalTransaction(
+ transaction: TransactionMeta,
+ gasLimit?: number,
+): PreparedAcrossTransaction {
+ return {
+ params: {
+ data: transaction.txParams.data,
+ from: transaction.txParams.from,
+ gas: gasLimit === undefined ? undefined : toHex(gasLimit),
+ to: transaction.txParams.to,
+ value: transaction.txParams.value,
+ } as TransactionParams,
+ type: getOriginalTransactionType(transaction),
+ };
+}
+
+/**
+ * Get the transaction type to use for the original batch leg.
+ *
+ * @param transaction - Original transaction metadata.
+ * @returns `predictWithdraw` for Predict withdrawals; otherwise the original type.
+ */
+function getOriginalTransactionType(
+ transaction: TransactionMeta,
+): TransactionMeta['type'] {
+ if (isPredictWithdrawTransaction(transaction)) {
+ return TransactionType.predictWithdraw;
+ }
+
+ return transaction.type;
+}
+
+/**
+ * Check whether the original transaction already has a usable gas limit.
+ *
+ * @param transaction - Original transaction metadata.
+ * @returns Whether the original or nested transaction gas is a positive integer.
+ */
+function hasOriginalTransactionGas(transaction: TransactionMeta): boolean {
+ return getOriginalTransactionGas(transaction) !== undefined;
+}
+
+/**
+ * Get the transaction type for the Across bridge/deposit leg.
+ *
+ * @param transaction - Original parent transaction.
+ * @returns Across-specific transaction type for known flows, or the original type.
+ */
+function getAcrossDepositType(transaction: TransactionMeta): TransactionType {
+ if (isPredictWithdrawTransaction(transaction)) {
+ return TransactionType.predictAcrossWithdraw;
+ }
+
+ switch (transaction.type) {
case TransactionType.perpsDeposit:
return TransactionType.perpsAcrossDeposit;
case TransactionType.predictDeposit:
@@ -346,10 +579,24 @@ function getAcrossDepositType(
case undefined:
return TransactionType.perpsAcrossDeposit;
default:
- return transactionType;
+ return transaction.type as TransactionType;
}
}
+/**
+ * Build TransactionController params for an Across approval or swap leg.
+ *
+ * @param from - Sender address.
+ * @param params - Across transaction fields.
+ * @param params.chainId - Source chain ID.
+ * @param params.data - Transaction calldata.
+ * @param params.gasLimit - Optional gas limit.
+ * @param params.to - Recipient contract address.
+ * @param params.value - Optional native value.
+ * @param params.maxFeePerGas - Optional EIP-1559 max fee.
+ * @param params.maxPriorityFeePerGas - Optional EIP-1559 priority fee.
+ * @returns TransactionController params.
+ */
function buildTransactionParams(
from: Hex,
params: {
@@ -375,6 +622,12 @@ function buildTransactionParams(
};
}
+/**
+ * Normalize an optional numeric string or hex string into a hex value.
+ *
+ * @param value - Optional value to normalize.
+ * @returns Hex value, or `undefined` when no value is provided.
+ */
function normalizeOptionalHex(value?: string): Hex | undefined {
if (value === undefined) {
return undefined;
@@ -383,6 +636,12 @@ function normalizeOptionalHex(value?: string): Hex | undefined {
return toHex(value);
}
+/**
+ * Convert full TransactionController params into batch transaction params.
+ *
+ * @param params - Transaction params.
+ * @returns Batch-compatible transaction params.
+ */
function toBatchTransactionParams(
params: TransactionParams,
): BatchTransactionParams {
diff --git a/packages/transaction-pay-controller/src/strategy/across/transactions.ts b/packages/transaction-pay-controller/src/strategy/across/transactions.ts
index f5cb6ef302..38d04fff10 100644
--- a/packages/transaction-pay-controller/src/strategy/across/transactions.ts
+++ b/packages/transaction-pay-controller/src/strategy/across/transactions.ts
@@ -1,4 +1,6 @@
import { TransactionType } from '@metamask/transaction-controller';
+import type { TransactionMeta } from '@metamask/transaction-controller';
+import { BigNumber } from 'bignumber.js';
import type { AcrossSwapApprovalResponse } from './types';
@@ -38,3 +40,28 @@ export function getAcrossOrderedTransactions({
},
];
}
+
+/**
+ * Get a usable gas limit from the original or nested transaction.
+ *
+ * @param transaction - Original transaction metadata.
+ * @returns Positive integer gas limit if present, otherwise undefined.
+ */
+export function getOriginalTransactionGas(
+ transaction: TransactionMeta,
+): number | undefined {
+ const nestedGas = transaction.nestedTransactions?.find((tx) => tx.gas)?.gas;
+ const rawGas = nestedGas ?? transaction.txParams.gas;
+
+ if (rawGas === undefined) {
+ return undefined;
+ }
+
+ const gas = new BigNumber(rawGas);
+
+ if (!gas.isFinite() || gas.isNaN() || !gas.isInteger() || gas.lte(0)) {
+ return undefined;
+ }
+
+ return gas.toNumber();
+}
diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts
index d2455f787d..b11a84ac28 100644
--- a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts
+++ b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts
@@ -468,7 +468,7 @@ describe('Feature Flags Utils', () => {
getRemoteFeatureFlagControllerStateMock.mockReturnValue({
...getDefaultRemoteFeatureFlagControllerState(),
remoteFeatureFlags: {
- confirmations_pay: {
+ confirmations_pay_extended: {
payStrategies: {
relay: {
gaslessEnabled: true,
@@ -485,7 +485,7 @@ describe('Feature Flags Utils', () => {
getRemoteFeatureFlagControllerStateMock.mockReturnValue({
...getDefaultRemoteFeatureFlagControllerState(),
remoteFeatureFlags: {
- confirmations_pay: {
+ confirmations_pay_extended: {
payStrategies: {
relay: {
gaslessEnabled: false,
diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts
index f3b3144325..ded5bcac2c 100644
--- a/packages/transaction-pay-controller/src/utils/feature-flags.ts
+++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts
@@ -133,13 +133,20 @@ export type PayStrategiesConfigRaw = {
across?: AcrossConfigRaw;
relay?: {
enabled?: boolean;
- gaslessEnabled?: boolean;
originGasOverhead?: string;
pollingInterval?: number;
pollingTimeout?: number;
};
};
+type FeatureFlagsExtendedRaw = {
+ payStrategies?: {
+ relay?: {
+ gaslessEnabled?: boolean;
+ };
+ };
+};
+
export type PayStrategiesConfig = {
across: AcrossConfig;
relay: {
@@ -493,8 +500,8 @@ export function isRelayExecuteEnabled(
): boolean {
const state = messenger.call('RemoteFeatureFlagController:getState');
const featureFlags =
- (state.remoteFeatureFlags?.confirmations_pay as
- | FeatureFlagsRaw
+ (state.remoteFeatureFlags?.confirmations_pay_extended as
+ | FeatureFlagsExtendedRaw
| undefined) ?? {};
return featureFlags.payStrategies?.relay?.gaslessEnabled ?? false;
}
diff --git a/yarn.lock b/yarn.lock
index 87c2892c8e..d57ba7d5d8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2782,7 +2782,7 @@ __metadata:
"@metamask/base-controller": "npm:^9.1.0"
"@metamask/client-controller": "npm:^1.0.1"
"@metamask/controller-utils": "npm:^12.1.0"
- "@metamask/core-backend": "npm:^6.2.2"
+ "@metamask/core-backend": "npm:^6.3.0"
"@metamask/keyring-api": "npm:^23.1.0"
"@metamask/keyring-controller": "npm:^25.5.0"
"@metamask/keyring-internal-api": "npm:^11.0.1"
@@ -2835,7 +2835,7 @@ __metadata:
"@metamask/base-controller": "npm:^9.1.0"
"@metamask/contract-metadata": "npm:^2.4.0"
"@metamask/controller-utils": "npm:^12.1.0"
- "@metamask/core-backend": "npm:^6.2.2"
+ "@metamask/core-backend": "npm:^6.3.0"
"@metamask/eth-query": "npm:^4.0.0"
"@metamask/ethjs-provider-http": "npm:^0.3.0"
"@metamask/keyring-api": "npm:^23.1.0"
@@ -3368,7 +3368,7 @@ __metadata:
languageName: unknown
linkType: soft
-"@metamask/core-backend@npm:^6.2.2, @metamask/core-backend@workspace:packages/core-backend":
+"@metamask/core-backend@npm:^6.3.0, @metamask/core-backend@workspace:packages/core-backend":
version: 0.0.0-use.local
resolution: "@metamask/core-backend@workspace:packages/core-backend"
dependencies:
@@ -5724,7 +5724,7 @@ __metadata:
"@metamask/base-controller": "npm:^9.1.0"
"@metamask/connectivity-controller": "npm:^0.2.0"
"@metamask/controller-utils": "npm:^12.1.0"
- "@metamask/core-backend": "npm:^6.2.2"
+ "@metamask/core-backend": "npm:^6.3.0"
"@metamask/eth-block-tracker": "npm:^15.0.0"
"@metamask/eth-json-rpc-provider": "npm:^6.0.1"
"@metamask/ethjs-provider-http": "npm:^0.3.0"