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"