diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cdaba956e6..d834e3a373 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -100,6 +100,7 @@ /packages/rate-limit-controller @MetaMask/core-platform /packages/react-data-query @MetaMask/core-platform /packages/profile-metrics-controller @MetaMask/core-platform +/packages/wallet @MetaMask/core-platform ## Web3Auth Team /packages/seedless-onboarding-controller @MetaMask/web3auth diff --git a/README.md b/README.md index dd2325a539..8dd6a4beef 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/transaction-controller`](packages/transaction-controller) - [`@metamask/transaction-pay-controller`](packages/transaction-pay-controller) - [`@metamask/user-operation-controller`](packages/user-operation-controller) +- [`@metamask/wallet`](packages/wallet) @@ -195,6 +196,7 @@ linkStyle default opacity:0.5 transaction_controller(["@metamask/transaction-controller"]); transaction_pay_controller(["@metamask/transaction-pay-controller"]); user_operation_controller(["@metamask/user-operation-controller"]); + wallet(["@metamask/wallet"]); account_tree_controller --> accounts_controller; account_tree_controller --> base_controller; account_tree_controller --> keyring_controller; diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 62d7af273e..cef941a545 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -862,34 +862,45 @@ "count": 1 } }, - "packages/core-backend/src/AccountActivityService.test.ts": { + "packages/core-backend/src/api/shared-types.ts": { "no-restricted-syntax": { - "count": 2 + "count": 1 } }, - "packages/core-backend/src/AccountActivityService.ts": { + "packages/core-backend/src/index.ts": { "no-restricted-syntax": { - "count": 1 + "count": 4 } }, - "packages/core-backend/src/BackendWebSocketService.test.ts": { + "packages/core-backend/src/ws/AccountActivityService.test.ts": { "no-restricted-syntax": { - "count": 1 + "count": 2 } }, - "packages/core-backend/src/BackendWebSocketService.ts": { + "packages/core-backend/src/ws/AccountActivityService.ts": { "no-restricted-syntax": { - "count": 5 + "count": 1 } }, - "packages/core-backend/src/api/shared-types.ts": { + "packages/core-backend/src/ws/BackendWebSocketService.test.ts": { "no-restricted-syntax": { "count": 1 } }, - "packages/core-backend/src/index.ts": { + "packages/core-backend/src/ws/BackendWebSocketService.ts": { "no-restricted-syntax": { + "count": 5 + } + }, + "packages/core-backend/src/ws/ohlcv/OHLCVService.test.ts": { + "@typescript-eslint/naming-convention": { "count": 2 + }, + "id-length": { + "count": 1 + }, + "no-restricted-syntax": { + "count": 1 } }, "packages/delegation-controller/src/DelegationController.test.ts": { diff --git a/package.json b/package.json index 5d0f731505..5353dcae47 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "978.0.0", + "version": "980.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/account-tree-controller/CHANGELOG.md b/packages/account-tree-controller/CHANGELOG.md index 7144f0a421..e306123252 100644 --- a/packages/account-tree-controller/CHANGELOG.md +++ b/packages/account-tree-controller/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.4.0] + ### Added - Add `AccountTreeController:accountGroup{Created,Updated,Removed}` events ([#8766](https://github.com/MetaMask/core/pull/8766)) @@ -15,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/accounts-controller` from `^38.0.0` to `^38.1.1` ([#8755](https://github.com/MetaMask/core/pull/8755), [#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/multichain-account-service` from `^9.0.0` to `^10.0.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) +- Bump `@metamask/profile-sync-controller` from `^28.0.2` to `^28.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) ## [7.3.0] @@ -565,7 +569,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5847](https://github.com/MetaMask/core/pull/5847)) - Grouping accounts into 3 main categories: Entropy source, Snap ID, keyring types. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/account-tree-controller@7.3.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/account-tree-controller@7.4.0...HEAD +[7.4.0]: https://github.com/MetaMask/core/compare/@metamask/account-tree-controller@7.3.0...@metamask/account-tree-controller@7.4.0 [7.3.0]: https://github.com/MetaMask/core/compare/@metamask/account-tree-controller@7.2.0...@metamask/account-tree-controller@7.3.0 [7.2.0]: https://github.com/MetaMask/core/compare/@metamask/account-tree-controller@7.1.0...@metamask/account-tree-controller@7.2.0 [7.1.0]: https://github.com/MetaMask/core/compare/@metamask/account-tree-controller@7.0.0...@metamask/account-tree-controller@7.1.0 diff --git a/packages/account-tree-controller/package.json b/packages/account-tree-controller/package.json index c27f2bd60b..7adf1cc2d3 100644 --- a/packages/account-tree-controller/package.json +++ b/packages/account-tree-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/account-tree-controller", - "version": "7.3.0", + "version": "7.4.0", "description": "Controller to group account together based on some pre-defined rules", "keywords": [ "Ethereum", @@ -58,8 +58,8 @@ "@metamask/keyring-api": "^23.1.0", "@metamask/keyring-controller": "^25.5.0", "@metamask/messenger": "^1.2.0", - "@metamask/multichain-account-service": "^9.0.0", - "@metamask/profile-sync-controller": "^28.0.2", + "@metamask/multichain-account-service": "^10.0.0", + "@metamask/profile-sync-controller": "^28.1.0", "@metamask/snaps-controllers": "^19.0.0", "@metamask/snaps-sdk": "^11.0.0", "@metamask/snaps-utils": "^12.1.2", diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 737848319c..811be3964c 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/transaction-controller` from `^65.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796)) + +## [7.1.2] + +### Changed + +- Bump `@metamask/account-tree-controller` from `^7.3.0` to `^7.4.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) +- Bump `@metamask/assets-controllers` from `^108.0.0` to `^108.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) + +### Fixed + +- `buildNativeAssetsFromConstant` now normalizes each native asset ID via `normalizeAssetId`, ensuring ERC20 addresses are EIP-55 checksummed and consistent with IDs written by data sources ([#8789](https://github.com/MetaMask/core/pull/8789)) +- `RpcDataSource` no longer writes `{ amount: "NaN" }` entries into `assetsBalance` when state holds malformed native metadata ([#8781](https://github.com/MetaMask/core/pull/8781)) + - Existing native metadata in `assetsInfo` is now only reused when its `decimals` field is a finite, non-negative number (via a new `#hasValidDecimals` guard). Stale entries like `{ type: 'native', decimals: null, name: '', symbol: '' }` or `{ decimals: -1, ... }` are replaced with the chain-status stub (`{ type: 'native', symbol/name: chainStatus.nativeCurrency, decimals: 18 }`) so balance conversion has a usable `decimals` and consumers never see a negative `decimals` in `assetsInfo`. + - `decimals: 0` is treated as valid; `name` and `symbol` are not required. + - Decimals resolution in `#handleBalanceUpdate` and the manual-fetch path no longer relies on `??` to fall through from state to pipeline metadata. A new `#pickValidDecimals` helper picks the first source whose `decimals` is finite and non-negative, so a stale `decimals: NaN` (or `decimals: -1`) in state can no longer shadow the chain-status stub's `decimals: 18` and silently produce `amount: '0'` while `assetsInfo` reports `decimals: 18`. + - `#convertToHumanReadable` now defensively returns `'0'` when `decimals` isn't a finite non-negative number or when the raw balance can't be parsed, matching the existing safe fallback used in the error path. +- The mUSD (`MetaMask USD`) contract address stored in `defaults.ts` is now EIP-55 checksummed (`0xacA92E438df0B2401fF60dA7E4337B687a2435DA`) ([#8786](https://github.com/MetaMask/core/pull/8786)). Previously the address was all-lowercase, causing the CAIP-19 keys pre-seeded into `assetsInfo` by `buildDefaultAssetsInfo()` to differ from the checksummed keys written by data sources (which always normalise asset IDs via `normalizeAssetId`). The mismatch resulted in two separate `assetsInfo` entries for the same token after the first balance or token-data poll. + ## [7.1.1] ### Changed @@ -480,7 +501,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Refactor `RpcDataSource` to delegate polling to `BalanceFetcher` and `TokenDetector` services ([#7709](https://github.com/MetaMask/core/pull/7709)) - Refactor `BalanceFetcher` and `TokenDetector` to extend `StaticIntervalPollingControllerOnly` for independent polling management ([#7709](https://github.com/MetaMask/core/pull/7709)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@7.1.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@7.1.2...HEAD +[7.1.2]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@7.1.1...@metamask/assets-controller@7.1.2 [7.1.1]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@7.1.0...@metamask/assets-controller@7.1.1 [7.1.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@7.0.1...@metamask/assets-controller@7.1.0 [7.0.1]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@7.0.0...@metamask/assets-controller@7.0.1 diff --git a/packages/assets-controller/package.json b/packages/assets-controller/package.json index db0da5e20a..1d704de98d 100644 --- a/packages/assets-controller/package.json +++ b/packages/assets-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controller", - "version": "7.1.1", + "version": "7.1.2", "description": "Tracks assets balances/prices and handles token detection across all digital assets", "keywords": [ "Ethereum", @@ -56,9 +56,9 @@ "@ethereumjs/util": "^9.1.0", "@ethersproject/abi": "^5.7.0", "@ethersproject/providers": "^5.7.0", - "@metamask/account-tree-controller": "^7.3.0", + "@metamask/account-tree-controller": "^7.4.0", "@metamask/accounts-controller": "^38.1.1", - "@metamask/assets-controllers": "^108.0.0", + "@metamask/assets-controllers": "^108.1.0", "@metamask/base-controller": "^9.1.0", "@metamask/client-controller": "^1.0.1", "@metamask/controller-utils": "^12.1.0", @@ -76,7 +76,7 @@ "@metamask/preferences-controller": "^23.1.0", "@metamask/snaps-controllers": "^19.0.0", "@metamask/snaps-utils": "^12.1.2", - "@metamask/transaction-controller": "^65.3.0", + "@metamask/transaction-controller": "^65.4.0", "@metamask/utils": "^11.9.0", "async-mutex": "^0.5.0", "bignumber.js": "^9.1.2", diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index da37526a53..ab609ea176 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -260,6 +260,23 @@ describe('AssetsController', () => { selectedCurrency: 'usd', }); }); + + it('pre-seeds assetsInfo with EIP-55 checksummed CAIP-19 keys', () => { + // Regression: MUSD_ADDRESS was previously all-lowercase, so + // buildDefaultAssetsInfo() produced lowercase CAIP-19 keys while data + // sources (which call normalizeAssetId) wrote checksummed keys. + // After the first balance poll both keys existed in assetsInfo. + const defaultState = getDefaultAssetsControllerState(); + const assetIds = Object.keys(defaultState.assetsInfo); + expect(assetIds.length).toBeGreaterThan(0); + // Every erc20 asset ID must contain at least one uppercase hex letter + // (EIP-55 checksum property) so that keys match normalizeAssetId output. + const erc20Ids = assetIds.filter((id) => id.includes('/erc20:')); + expect(erc20Ids.length).toBeGreaterThan(0); + for (const id of erc20Ids) { + expect(id).toMatch(/\/erc20:0x[0-9a-fA-F]*[A-F][0-9a-fA-F]*/u); + } + }); }); describe('constructor', () => { diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts index 6686e096df..df834db6f8 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts @@ -1553,6 +1553,225 @@ describe('RpcDataSource', () => { }); }); + describe('native metadata validation (#hasValidDecimals)', () => { + const NATIVE_ASSET_ID = 'eip155:1/slip44:60' as Caip19AssetId; + + const subscribeAndEmit = async ( + stateNativeMeta: unknown, + onAssetsUpdate: jest.Mock, + ): Promise => { + let balanceUpdateCallback: + | ((result: BalanceFetchResult) => void | Promise) + | null = null; + jest + .spyOn(BalanceFetcher.prototype, 'setOnBalanceUpdate') + .mockImplementation(function (this: BalanceFetcher, callback) { + balanceUpdateCallback = callback; + }); + + await withController( + { + actionHandlerOverrides: { + 'AssetsController:getState': () => ({ + ...getDefaultAssetsControllerState(), + assetsInfo: { [NATIVE_ASSET_ID]: stateNativeMeta }, + }), + }, + }, + async ({ controller }) => { + await controller.subscribe({ + request: createDataRequest(), + subscriptionId: 'test-sub', + isUpdate: false, + onAssetsUpdate, + }); + await balanceUpdateCallback?.( + createBalanceFetchResult({ + balances: [ + { + assetId: NATIVE_ASSET_ID, + balance: '1000000000000000000', + } as BalanceFetchResult['balances'][0], + ], + }), + ); + }, + ); + }; + + it('falls back to chain-status stub when state native metadata has null decimals', async () => { + const onAssetsUpdate = jest.fn(); + await subscribeAndEmit( + { + aggregators: ['dynamic'], + decimals: null, + name: '', + symbol: '', + type: 'native', + }, + onAssetsUpdate, + ); + + expect(onAssetsUpdate).toHaveBeenCalled(); + const [response] = onAssetsUpdate.mock.calls[0]; + expect(response.assetsInfo?.[NATIVE_ASSET_ID]).toStrictEqual({ + type: 'native', + symbol: 'ETH', + name: 'ETH', + decimals: 18, + }); + expect( + response.assetsBalance?.[MOCK_ACCOUNT_ID]?.[NATIVE_ASSET_ID]?.amount, + ).toBe('1'); + }); + + it('falls back to chain-status stub when state native metadata has NaN decimals', async () => { + const onAssetsUpdate = jest.fn(); + await subscribeAndEmit( + { + decimals: Number.NaN, + name: 'ETH', + symbol: 'ETH', + type: 'native', + }, + onAssetsUpdate, + ); + + const [response] = onAssetsUpdate.mock.calls[0]; + expect(response.assetsInfo?.[NATIVE_ASSET_ID]?.decimals).toBe(18); + // Regression: `decimals: NaN` in state must not bypass the pipeline's + // valid `decimals: 18` via `??`, otherwise the amount silently becomes + // '0' even though `assetsInfo` reports the correct decimals. + expect( + response.assetsBalance?.[MOCK_ACCOUNT_ID]?.[NATIVE_ASSET_ID]?.amount, + ).toBe('1'); + }); + + it('keeps existing native metadata when decimals is 0 (valid)', async () => { + const onAssetsUpdate = jest.fn(); + const existing = { + decimals: 0, + name: '', + symbol: '', + type: 'native' as const, + }; + await subscribeAndEmit(existing, onAssetsUpdate); + + const [response] = onAssetsUpdate.mock.calls[0]; + expect(response.assetsInfo?.[NATIVE_ASSET_ID]).toStrictEqual(existing); + }); + + it('keeps existing native metadata when name/symbol are missing but decimals is valid', async () => { + const onAssetsUpdate = jest.fn(); + const existing = { + decimals: 18, + type: 'native' as const, + }; + await subscribeAndEmit(existing, onAssetsUpdate); + + const [response] = onAssetsUpdate.mock.calls[0]; + expect(response.assetsInfo?.[NATIVE_ASSET_ID]).toStrictEqual(existing); + }); + + it('falls back to chain-status stub when state native metadata has negative decimals', async () => { + const onAssetsUpdate = jest.fn(); + await subscribeAndEmit( + { + decimals: -1, + name: 'X', + symbol: 'X', + type: 'native', + }, + onAssetsUpdate, + ); + + const [response] = onAssetsUpdate.mock.calls[0]; + // Regression: `#hasValidDecimals` previously accepted negative + // decimals (only checked `Number.isFinite`), so consumers saw stale + // `decimals: -1` in `assetsInfo` while the balance silently became + // `'0'`. The metadata guard must reject negatives so the chain-status + // stub takes over and the balance resolves correctly. + expect(response.assetsInfo?.[NATIVE_ASSET_ID]).toStrictEqual({ + type: 'native', + symbol: 'ETH', + name: 'ETH', + decimals: 18, + }); + expect( + response.assetsBalance?.[MOCK_ACCOUNT_ID]?.[NATIVE_ASSET_ID]?.amount, + ).toBe('1'); + }); + }); + + describe('convertToHumanReadable guards', () => { + const NATIVE_ASSET_ID = 'eip155:1/slip44:60' as Caip19AssetId; + + const runFetchWithStateMetadata = async ( + stateNativeMeta: unknown, + rawBalance: string, + ): Promise<{ + onAssetsUpdate: jest.Mock; + }> => { + let balanceUpdateCallback: + | ((result: BalanceFetchResult) => void | Promise) + | null = null; + jest + .spyOn(BalanceFetcher.prototype, 'setOnBalanceUpdate') + .mockImplementation(function (this: BalanceFetcher, callback) { + balanceUpdateCallback = callback; + }); + + const onAssetsUpdate = jest.fn(); + await withController( + { + actionHandlerOverrides: { + 'AssetsController:getState': () => ({ + ...getDefaultAssetsControllerState(), + assetsInfo: { [NATIVE_ASSET_ID]: stateNativeMeta }, + }), + }, + }, + async ({ controller }) => { + await controller.subscribe({ + request: createDataRequest(), + subscriptionId: 'test-sub', + isUpdate: false, + onAssetsUpdate, + }); + await balanceUpdateCallback?.( + createBalanceFetchResult({ + balances: [ + { + assetId: NATIVE_ASSET_ID, + balance: rawBalance, + } as BalanceFetchResult['balances'][0], + ], + }), + ); + }, + ); + + return { onAssetsUpdate }; + }; + + it('returns "0" amount when raw balance is not a number', async () => { + const { onAssetsUpdate } = await runFetchWithStateMetadata( + { + decimals: 18, + name: 'ETH', + symbol: 'ETH', + type: 'native', + }, + 'not-a-number', + ); + + const [response] = onAssetsUpdate.mock.calls[0]; + expect( + response.assetsBalance?.[MOCK_ACCOUNT_ID]?.[NATIVE_ASSET_ID]?.amount, + ).toBe('0'); + }); + }); + describe('handleDetectionUpdate (via callback)', () => { it('invokes onAssetsUpdate when TokenDetector callback runs', async () => { let detectionUpdateCallback: diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.ts b/packages/assets-controller/src/data-sources/RpcDataSource.ts index a2bc1b4563..2bcb742c65 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.ts @@ -346,12 +346,30 @@ export class RpcDataSource extends AbstractDataSource< /** * Convert a raw balance to human-readable format using decimals. * + * Returns `'0'` when either input is invalid (e.g. `decimals` is `null`, + * `NaN`, negative or non-finite, or `rawBalance` cannot be parsed as a + * number). Defaulting to a fixed decimals value would silently produce + * wrong amounts; `'0'` keeps state safe and never lets `NaN` leak in. + * * @param rawBalance - The raw balance string. * @param decimals - The number of decimals for the token. - * @returns The human-readable balance string. + * @returns The human-readable balance string, or `'0'` when inputs are invalid. */ #convertToHumanReadable(rawBalance: string, decimals: number): string { + if (!Number.isFinite(decimals) || decimals < 0) { + log('Invalid decimals — defaulting balance to "0"', { + rawBalance, + decimals, + }); + return '0'; + } + const rawAmount = new BigNumberJS(rawBalance); + if (!rawAmount.isFinite()) { + log('Invalid raw balance — defaulting to "0"', { rawBalance, decimals }); + return '0'; + } + const divisor = new BigNumberJS(10).pow(decimals); return rawAmount.dividedBy(divisor).toFixed(); } @@ -383,7 +401,7 @@ export class RpcDataSource extends AbstractDataSource< // enriched by the price/info API with image, description, etc. // Only emit a minimal stub when there's nothing in state yet, // so we don't clobber that richer metadata on every balance refresh. - if (existingMeta) { + if (this.#hasValidDecimals(existingMeta)) { assetsInfo[balance.assetId] = existingMeta; } else { const chainStatus = this.#chainStatuses[chainId]; @@ -406,6 +424,50 @@ export class RpcDataSource extends AbstractDataSource< return assetsInfo; } + /** + * Type guard for metadata whose `decimals` is safe to use for balance + * conversion. + * + * Mirrors the validity rules in `#convertToHumanReadable` (finite and + * non-negative). Keeping these in sync ensures that whenever the metadata + * guard accepts a value, the balance guard will also accept it — so we + * never end up emitting metadata with `decimals: -1` while silently + * defaulting the balance to `'0'`. + * + * @param metadata - The metadata to check. + * @returns `true` if `decimals` is a finite, non-negative number. + */ + #hasValidDecimals( + metadata: AssetMetadata | undefined, + ): metadata is AssetMetadata { + return Boolean( + metadata && Number.isFinite(metadata.decimals) && metadata.decimals >= 0, + ); + } + + /** + * Pick the first valid `decimals` value from a list of metadata sources. + * + * `??` only short-circuits on `null`/`undefined`, so a stale state entry + * with `decimals: NaN` would otherwise win over a later source that holds + * a correct value (e.g. the chain-status stub produced by + * `#collectMetadataForBalances`). This helper treats `NaN`, negative, and + * non-finite values as missing so the next source can supply a usable one. + * + * @param metadatas - Metadata candidates in priority order. + * @returns The first finite `decimals` value, or `undefined` if none are valid. + */ + #pickValidDecimals( + ...metadatas: (AssetMetadata | undefined)[] + ): number | undefined { + for (const metadata of metadatas) { + if (this.#hasValidDecimals(metadata)) { + return metadata.decimals; + } + } + return undefined; + } + /** * Handle balance update from BalanceFetcher. * @@ -436,7 +498,7 @@ export class RpcDataSource extends AbstractDataSource< for (const balance of normalizedBalances) { const stateMetadata = existingMetadata[balance.assetId]; const pipelineMetadata = assetsInfo[balance.assetId]; - const decimals = stateMetadata?.decimals ?? pipelineMetadata?.decimals; + const decimals = this.#pickValidDecimals(stateMetadata, pipelineMetadata); if (decimals === undefined) { continue; @@ -1012,8 +1074,10 @@ export class RpcDataSource extends AbstractDataSource< for (const balance of normalizedBalances) { const stateMetadata = existingMetadata[balance.assetId]; const pipelineMetadata = assetsInfo[balance.assetId]; - let decimals: number | undefined = - stateMetadata?.decimals ?? pipelineMetadata?.decimals; + let decimals: number | undefined = this.#pickValidDecimals( + stateMetadata, + pipelineMetadata, + ); if (decimals === undefined) { const parsed = parseCaipAssetType(balance.assetId); @@ -1054,7 +1118,7 @@ export class RpcDataSource extends AbstractDataSource< // nothing is in state yet, so we don't clobber that richer metadata. const existingNativeMeta = this.#getExistingAssetsMetadata()[nativeAssetId]; - if (existingNativeMeta) { + if (this.#hasValidDecimals(existingNativeMeta)) { assetsInfo[nativeAssetId] = existingNativeMeta; } else { const chainStatus = this.#chainStatuses[chainId]; diff --git a/packages/assets-controller/src/defaults.ts b/packages/assets-controller/src/defaults.ts index 963008a604..edb5be687b 100644 --- a/packages/assets-controller/src/defaults.ts +++ b/packages/assets-controller/src/defaults.ts @@ -6,10 +6,18 @@ import type { } from './types'; /** - * Address of MetaMask USD (mUSD) — same canonical contract address - * across every chain we deploy it to. + * EIP-55 checksummed address of MetaMask USD (mUSD) — same canonical contract + * address across every chain we deploy it to. + * + * Must be checksummed so that the CAIP-19 asset IDs produced by + * `musdAssetId()` (and therefore the keys seeded into `assetsInfo` by + * `buildDefaultAssetsInfo()`) match the keys written by data sources, which + * always pass IDs through `normalizeAssetId` → `toChecksumAddress` before + * emitting a `DataResponse`. Using a lowercase address would cause the + * pre-seeded keys to diverge from the data-source keys, leaving a duplicate + * entry in `assetsInfo` after the first balance or token-data poll. */ -const MUSD_ADDRESS = '0xaca92e438df0b2401ff60da7e4337b687a2435da'; +const MUSD_ADDRESS = '0xacA92E438df0B2401fF60dA7E4337B687a2435DA'; /** * Hardcoded metadata for MetaMask USD. Pre-seeding this in default @@ -63,14 +71,14 @@ export const CHAINS_WITH_DEFAULT_TRACKED_ASSETS: ReadonlySet = new Set( /** * Pre-seeded metadata for every default tracked asset, keyed by the - * lowercase CAIP-19 id so callers can look up without worrying about - * checksum case. + * checksummed CAIP-19 id. All callers must pass a checksummed asset ID; + * use `normalizeAssetId` to ensure the correct format before looking up. */ export const DEFAULT_ASSET_METADATA: ReadonlyMap = new Map([ - [musdAssetId('eip155:1' as ChainId).toLowerCase(), MUSD_METADATA], - [musdAssetId('eip155:59144' as ChainId).toLowerCase(), MUSD_METADATA], - [musdAssetId('eip155:143' as ChainId).toLowerCase(), MUSD_METADATA], + [musdAssetId('eip155:1' as ChainId), MUSD_METADATA], + [musdAssetId('eip155:59144' as ChainId), MUSD_METADATA], + [musdAssetId('eip155:143' as ChainId), MUSD_METADATA], ]); /** @@ -89,14 +97,14 @@ export function getDefaultTrackedAssetsForChain( /** * Look up pre-seeded metadata for a default tracked asset. * - * @param assetId - CAIP-19 asset id (any case). + * @param assetId - CAIP-19 asset id (must be EIP-55 checksummed for EVM tokens). * @returns The metadata if the asset is a default tracked asset, * otherwise `undefined`. */ export function getDefaultAssetMetadata( assetId: Caip19AssetId, ): AssetMetadata | undefined { - return DEFAULT_ASSET_METADATA.get(assetId.toLowerCase()); + return DEFAULT_ASSET_METADATA.get(assetId); } /** diff --git a/packages/assets-controller/src/utils/native-assets.test.ts b/packages/assets-controller/src/utils/native-assets.test.ts index 1712a996be..e63df1bd83 100644 --- a/packages/assets-controller/src/utils/native-assets.test.ts +++ b/packages/assets-controller/src/utils/native-assets.test.ts @@ -5,6 +5,7 @@ import { buildNativeAssetsFromConstant, buildNativeAssetsFromApi, } from './native-assets'; +import { normalizeAssetId } from './normalizeAssetId'; jest.mock('@metamask/controller-utils', () => ({ ...jest.requireActual('@metamask/controller-utils'), @@ -14,12 +15,12 @@ jest.mock('@metamask/controller-utils', () => ({ const fetchWithErrorHandlingMock = jest.mocked(fetchWithErrorHandling); describe('buildNativeAssetsFromConstant', () => { - it('includes an entry for every value in SPOT_PRICES_SUPPORT_INFO', () => { + it('includes a normalized entry for every value in SPOT_PRICES_SUPPORT_INFO', () => { const result = buildNativeAssetsFromConstant(); const supportInfoValues = Object.values(SPOT_PRICES_SUPPORT_INFO); for (const assetId of supportInfoValues) { - expect(Object.values(result)).toContain(assetId); + expect(Object.values(result)).toContain(normalizeAssetId(assetId)); } }); }); diff --git a/packages/assets-controller/src/utils/native-assets.ts b/packages/assets-controller/src/utils/native-assets.ts index 6dc7e8cac5..2693daae52 100644 --- a/packages/assets-controller/src/utils/native-assets.ts +++ b/packages/assets-controller/src/utils/native-assets.ts @@ -3,6 +3,7 @@ import { fetchWithErrorHandling } from '@metamask/controller-utils'; import { parseCaipAssetType } from '@metamask/utils'; import type { Caip19AssetId, ChainId } from '../types'; +import { normalizeAssetId } from './normalizeAssetId'; const CHAINID_NETWORK_URL = 'https://chainid.network/chains.json'; @@ -23,7 +24,7 @@ export function buildNativeAssetsFromConstant(): Record< const nativeAssetsMap: Record = {}; for (const nativeAssetId of Object.values(SPOT_PRICES_SUPPORT_INFO)) { const { chainId } = parseCaipAssetType(nativeAssetId); - nativeAssetsMap[chainId] = nativeAssetId; + nativeAssetsMap[chainId] = normalizeAssetId(nativeAssetId); } return nativeAssetsMap; } diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index dd00e1d802..8b75745cd8 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/transaction-controller` from `^65.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796)) + +## [108.1.0] + +### Added + +- Add cursor-based pagination support to `searchTokens` ([#8788](https://github.com/MetaMask/core/pull/8788)) + - The response now includes `totalCount` (total number of matching tokens across all pages) and `pageInfo` (`{ hasNextPage, endCursor }`) when returned by the API. + - Pass `pageInfo.endCursor` as the `after` option to fetch the next page of results. + - Export new type `PageInfo` for the pagination metadata shape. + +### Changed + +- Bump `@metamask/account-tree-controller` from `^7.3.0` to `^7.4.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) +- Bump `@metamask/multichain-account-service` from `^9.0.0` to `^10.0.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) +- Bump `@metamask/profile-sync-controller` from `^28.0.2` to `^28.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) + ## [108.0.0] ### Changed @@ -3081,7 +3100,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use Ethers for AssetsContractController ([#845](https://github.com/MetaMask/core/pull/845)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@108.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@108.1.0...HEAD +[108.1.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@108.0.0...@metamask/assets-controllers@108.1.0 [108.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@107.0.0...@metamask/assets-controllers@108.0.0 [107.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@106.0.1...@metamask/assets-controllers@107.0.0 [106.0.1]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@106.0.0...@metamask/assets-controllers@106.0.1 diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index 01180e3487..7e78eb1842 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controllers", - "version": "108.0.0", + "version": "108.1.0", "description": "Controllers which manage interactions involving ERC-20, ERC-721, and ERC-1155 tokens (including NFTs)", "keywords": [ "Ethereum", @@ -60,7 +60,7 @@ "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", "@metamask/abi-utils": "^2.0.3", - "@metamask/account-tree-controller": "^7.3.0", + "@metamask/account-tree-controller": "^7.4.0", "@metamask/accounts-controller": "^38.1.1", "@metamask/approval-controller": "^9.0.1", "@metamask/base-controller": "^9.1.0", @@ -72,20 +72,20 @@ "@metamask/keyring-controller": "^25.5.0", "@metamask/messenger": "^1.2.0", "@metamask/metamask-eth-abis": "^3.1.1", - "@metamask/multichain-account-service": "^9.0.0", + "@metamask/multichain-account-service": "^10.0.0", "@metamask/network-controller": "^32.0.0", "@metamask/network-enablement-controller": "^5.1.1", "@metamask/permission-controller": "^13.1.1", "@metamask/phishing-controller": "^17.1.2", "@metamask/polling-controller": "^16.0.5", "@metamask/preferences-controller": "^23.1.0", - "@metamask/profile-sync-controller": "^28.0.2", + "@metamask/profile-sync-controller": "^28.1.0", "@metamask/rpc-errors": "^7.0.2", "@metamask/snaps-controllers": "^19.0.0", "@metamask/snaps-sdk": "^11.0.0", "@metamask/snaps-utils": "^12.1.2", "@metamask/storage-service": "^1.0.1", - "@metamask/transaction-controller": "^65.3.0", + "@metamask/transaction-controller": "^65.4.0", "@metamask/utils": "^11.9.0", "@tanstack/query-core": "^5.62.16", "@types/bn.js": "^5.1.5", diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts index dda1702b93..236f3c6eb5 100644 --- a/packages/assets-controllers/src/index.ts +++ b/packages/assets-controllers/src/index.ts @@ -294,6 +294,7 @@ export type { TrendingAsset, TrendingTokensQueryParams, TokenSearchItem, + PageInfo, TokenAsset, TokenRwaData, TokenSecurityData, diff --git a/packages/assets-controllers/src/token-service.test.ts b/packages/assets-controllers/src/token-service.test.ts index e6362f154a..cba26bb90b 100644 --- a/packages/assets-controllers/src/token-service.test.ts +++ b/packages/assets-controllers/src/token-service.test.ts @@ -557,6 +557,7 @@ describe('Token service', () => { expect(results).toStrictEqual({ count: sampleSearchResults.length, data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, }); }); @@ -583,6 +584,7 @@ describe('Token service', () => { expect(results).toStrictEqual({ count: 1, data: [sampleSearchResults[0]], + pageInfo: { hasNextPage: false, endCursor: null }, }); }); @@ -607,6 +609,7 @@ describe('Token service', () => { expect(results).toStrictEqual({ count: sampleSearchResults.length, data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, }); }); @@ -636,6 +639,7 @@ describe('Token service', () => { expect(results).toStrictEqual({ count: sampleSearchResults.length, data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, }); }); @@ -734,7 +738,11 @@ describe('Token service', () => { const results = await searchTokens([sampleCaipChainId], searchQuery); - expect(results).toStrictEqual({ count: 0, data: [] }); + expect(results).toStrictEqual({ + count: 0, + data: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }); }); it('should return empty array when no chainIds are provided', async () => { @@ -754,7 +762,11 @@ describe('Token service', () => { const results = await searchTokens([], searchQuery); - expect(results).toStrictEqual({ count: 0, data: [] }); + expect(results).toStrictEqual({ + count: 0, + data: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }); }); it('should handle API error responses in JSON format', async () => { @@ -809,6 +821,7 @@ describe('Token service', () => { expect(result).toStrictEqual({ count: sampleSearchResults.length, data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, }); }); @@ -834,6 +847,7 @@ describe('Token service', () => { expect(results).toStrictEqual({ count: sampleSearchResults.length, data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, }); }); @@ -860,6 +874,7 @@ describe('Token service', () => { expect(results).toStrictEqual({ count: sampleSearchResults.length, data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, }); }); @@ -886,6 +901,7 @@ describe('Token service', () => { expect(results).toStrictEqual({ count: sampleSearchResults.length, data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, }); }); @@ -912,6 +928,7 @@ describe('Token service', () => { expect(results).toStrictEqual({ count: sampleSearchResults.length, data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, }); }); @@ -932,10 +949,90 @@ describe('Token service', () => { const results = await searchTokens([sampleCaipChainId], searchQuery); + expect(results).toStrictEqual({ + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }); + }); + + it('should forward pageInfo and totalCount when the API returns them', async () => { + const searchQuery = 'USD'; + const mockResponse = { + count: sampleSearchResults.length, + totalCount: 2343, + data: sampleSearchResults, + pageInfo: { hasNextPage: true, endCursor: 'MA==' }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&first=10&includeMarketData=false&includeRwaData=true`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery); + + expect(results).toStrictEqual({ + count: sampleSearchResults.length, + totalCount: 2343, + data: sampleSearchResults, + pageInfo: { hasNextPage: true, endCursor: 'MA==' }, + }); + }); + + it('should send the after cursor as a query parameter', async () => { + const searchQuery = 'USD'; + const cursor = 'MA=='; + const mockResponse = { + count: sampleSearchResults.length, + totalCount: 2343, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: 'MQ==' }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&first=10&after=${encodeURIComponent(cursor)}&includeMarketData=false&includeRwaData=true`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery, { + after: cursor, + }); + + expect(results).toStrictEqual({ + count: sampleSearchResults.length, + totalCount: 2343, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: 'MQ==' }, + }); + }); + + it('should omit pageInfo and totalCount when the API does not return them', async () => { + const searchQuery = 'USD'; + const mockResponse = { + count: sampleSearchResults.length, + data: sampleSearchResults, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&first=10&includeMarketData=false&includeRwaData=true`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery); + expect(results).toStrictEqual({ count: sampleSearchResults.length, data: sampleSearchResults, }); + expect(results).not.toHaveProperty('pageInfo'); + expect(results).not.toHaveProperty('totalCount'); }); }); @@ -1421,6 +1518,7 @@ describe('Token service', () => { expect(results).toStrictEqual({ count: sampleSearchResultsWithSecurityData.length, data: sampleSearchResultsWithSecurityData, + pageInfo: { hasNextPage: false, endCursor: null }, }); expect(results.data[0].securityData?.resultType).toBe('Verified'); expect(results.data[0].securityData?.maliciousScore).toBe('0.0'); diff --git a/packages/assets-controllers/src/token-service.ts b/packages/assets-controllers/src/token-service.ts index 1b49ccd656..3bc7eb1f6d 100644 --- a/packages/assets-controllers/src/token-service.ts +++ b/packages/assets-controllers/src/token-service.ts @@ -60,6 +60,7 @@ export type SortTrendingBy = * @param options.chainIds - Array of CAIP format chain IDs (e.g., 'eip155:1', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'). * @param options.query - The search query (token name, symbol, or address). * @param options.limit - Optional limit for the number of results (defaults to 10). + * @param options.after - Optional cursor for fetching the next page of results. * @param options.includeMarketData - Optional flag to include market data in the results (defaults to false). * @param options.includeRwaData - Optional flag to include RWA data in the results (defaults to false). * @param options.includeTokenSecurityData - Optional flag to include token security data in the results (defaults to false). @@ -69,11 +70,12 @@ function getTokenSearchURL(options: { chainIds: CaipChainId[]; query: string; limit?: number; + after?: string; includeMarketData?: boolean; includeRwaData?: boolean; includeTokenSecurityData?: boolean; }): string { - const { chainIds, query, limit, ...optionalParams } = options; + const { chainIds, query, limit, after, ...optionalParams } = options; const encodedQuery = encodeURIComponent(query); const encodedChainIds = chainIds .map((id) => encodeURIComponent(id)) @@ -97,7 +99,7 @@ function getTokenSearchURL(options: { } } - return `${TOKEN_END_POINT_API}/tokens/search?networks=${encodedChainIds}&query=${encodedQuery}${numberOfItems ? `&first=${numberOfItems}` : ''}&${queryParams.toString()}`; + return `${TOKEN_END_POINT_API}/tokens/search?networks=${encodedChainIds}&query=${encodedQuery}${numberOfItems ? `&first=${numberOfItems}` : ''}${after ? `&after=${encodeURIComponent(after)}` : ''}&${queryParams.toString()}`; } /** @@ -304,8 +306,15 @@ export type TokenSearchItem = { securityData?: TokenSecurityData; }; +export type PageInfo = { + hasNextPage: boolean; + endCursor: string | null; +}; + type SearchTokenOptions = { limit?: number; + /** Cursor returned by a previous response's `pageInfo.endCursor` to fetch the next page. */ + after?: string; includeMarketData?: boolean; includeRwaData?: boolean; includeTokenSecurityData?: boolean; @@ -318,39 +327,55 @@ type SearchTokenOptions = { * @param query - The search query (token name, symbol, or address). * @param options - Additional fetch options. * @param options.limit - The maximum number of results to return. + * @param options.after - Cursor from a previous response's `pageInfo.endCursor` to fetch the next page. * @param options.includeMarketData - Optional flag to include market data in the results (defaults to false). * @param options.includeRwaData - Optional flag to include RWA data in the results (defaults to false). * @param options.includeTokenSecurityData - Optional flag to include token security data in the results (defaults to false). - * @returns Object containing count, data array, and an optional error message if the request failed. + * @returns Object containing count, totalCount, data array, optional pageInfo for pagination, and an optional error message if the request failed. */ export async function searchTokens( chainIds: CaipChainId[], query: string, { limit = 10, + after, includeMarketData = false, includeRwaData = true, includeTokenSecurityData, }: SearchTokenOptions = {}, -): Promise<{ count: number; data: TokenSearchItem[]; error?: string }> { +): Promise<{ + count: number; + totalCount?: number; + data: TokenSearchItem[]; + pageInfo?: PageInfo; + error?: string; +}> { const tokenSearchURL = getTokenSearchURL({ chainIds, query, limit, + after, includeMarketData, includeRwaData, includeTokenSecurityData, }); try { - const result: { count: number; data: TokenSearchItem[] } = - await handleFetch(tokenSearchURL); + const result: { + count: number; + totalCount?: number; + data: TokenSearchItem[]; + pageInfo?: PageInfo; + } = await handleFetch(tokenSearchURL); - // The API returns an object with structure: { count: number, data: array, pageInfo: object } if (result && typeof result === 'object' && Array.isArray(result.data)) { return { count: result.count ?? result.data.length, + ...(result.totalCount !== undefined && { + totalCount: result.totalCount, + }), data: result.data, + ...(result.pageInfo !== undefined && { pageInfo: result.pageInfo }), }; } diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 72306fa915..cdc0f45df7 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/assets-controller` from `^7.1.1` to `^7.1.2` ([#8783](https://github.com/MetaMask/core/pull/8783)) +- Bump `@metamask/assets-controllers` from `^108.0.0` to `^108.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) +- Bump `@metamask/profile-sync-controller` from `^28.0.2` to `^28.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) +- Bump `@metamask/transaction-controller` from `^65.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796)) + ## [72.0.4] ### Changed diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index ad2f1b43c1..52d12dd221 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -58,8 +58,8 @@ "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", "@metamask/accounts-controller": "^38.1.1", - "@metamask/assets-controller": "^7.1.1", - "@metamask/assets-controllers": "^108.0.0", + "@metamask/assets-controller": "^7.1.2", + "@metamask/assets-controllers": "^108.1.0", "@metamask/base-controller": "^9.1.0", "@metamask/controller-utils": "^12.1.0", "@metamask/gas-fee-controller": "^26.2.1", @@ -69,10 +69,10 @@ "@metamask/multichain-network-controller": "^3.1.1", "@metamask/network-controller": "^32.0.0", "@metamask/polling-controller": "^16.0.5", - "@metamask/profile-sync-controller": "^28.0.2", + "@metamask/profile-sync-controller": "^28.1.0", "@metamask/remote-feature-flag-controller": "^4.2.1", "@metamask/snaps-controllers": "^19.0.0", - "@metamask/transaction-controller": "^65.3.0", + "@metamask/transaction-controller": "^65.4.0", "@metamask/utils": "^11.9.0", "bignumber.js": "^9.1.2", "reselect": "^5.1.1", diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 1c0f79ab54..a8d25b481f 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/profile-sync-controller` from `^28.0.2` to `^28.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) +- Bump `@metamask/transaction-controller` from `^65.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796)) + ## [71.1.4] ### Changed diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index a020056345..1825b2eb83 100644 --- a/packages/bridge-status-controller/package.json +++ b/packages/bridge-status-controller/package.json @@ -61,10 +61,10 @@ "@metamask/messenger": "^1.2.0", "@metamask/network-controller": "^32.0.0", "@metamask/polling-controller": "^16.0.5", - "@metamask/profile-sync-controller": "^28.0.2", + "@metamask/profile-sync-controller": "^28.1.0", "@metamask/snaps-controllers": "^19.0.0", "@metamask/superstruct": "^3.1.0", - "@metamask/transaction-controller": "^65.3.0", + "@metamask/transaction-controller": "^65.4.0", "@metamask/utils": "^11.9.0", "bignumber.js": "^9.1.2", "uuid": "^8.3.2" diff --git a/packages/claims-controller/CHANGELOG.md b/packages/claims-controller/CHANGELOG.md index 15662986f2..a1f52e5d11 100644 --- a/packages/claims-controller/CHANGELOG.md +++ b/packages/claims-controller/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/profile-sync-controller` from `^28.0.2` to `^28.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) ## [0.5.1] diff --git a/packages/claims-controller/package.json b/packages/claims-controller/package.json index f9cb3b0870..73ecee1bda 100644 --- a/packages/claims-controller/package.json +++ b/packages/claims-controller/package.json @@ -57,7 +57,7 @@ "@metamask/controller-utils": "^12.1.0", "@metamask/keyring-controller": "^25.5.0", "@metamask/messenger": "^1.2.0", - "@metamask/profile-sync-controller": "^28.0.2", + "@metamask/profile-sync-controller": "^28.1.0", "@metamask/utils": "^11.9.0" }, "devDependencies": { diff --git a/packages/config-registry-controller/CHANGELOG.md b/packages/config-registry-controller/CHANGELOG.md index 8da9c85691..2652ae3605 100644 --- a/packages/config-registry-controller/CHANGELOG.md +++ b/packages/config-registry-controller/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/profile-sync-controller` from `^28.0.2` to `^28.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) ## [0.3.1] diff --git a/packages/config-registry-controller/package.json b/packages/config-registry-controller/package.json index 74201544ff..63ed9f02d4 100644 --- a/packages/config-registry-controller/package.json +++ b/packages/config-registry-controller/package.json @@ -59,7 +59,7 @@ "@metamask/keyring-controller": "^25.5.0", "@metamask/messenger": "^1.2.0", "@metamask/polling-controller": "^16.0.5", - "@metamask/profile-sync-controller": "^28.0.2", + "@metamask/profile-sync-controller": "^28.1.0", "@metamask/remote-feature-flag-controller": "^4.2.1", "@metamask/superstruct": "^3.1.0", "@metamask/utils": "^11.9.0", diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index 08cdccdd2e..f371a1c977 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -7,10 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `OHLCVService` for real-time OHLCV (candlestick) data streaming via WebSocket ([#8695](https://github.com/MetaMask/core/pull/8695)) + - Wraps `BackendWebSocketService` through the messenger pattern to provide subscribe/unsubscribe semantics for market-data OHLCV channels + - Includes reference counting, grace-period unsubscribe, idempotency checks, chain-status forwarding, and automatic resubscription on reconnect +- Export new types `OHLCVBar`, `OHLCVSubscriptionOptions`, `OHLCVSystemNotificationData`, `OHLCVServiceOptions`, `OHLCVServiceActions`, `OHLCVServiceAllowedActions`, `OHLCVServiceBarUpdatedEvent`, `OHLCVServiceChainStatusChangedEvent`, `OHLCVServiceSubscriptionErrorEvent`, `OHLCVServiceEvents`, `OHLCVServiceAllowedEvents`, and `OHLCVServiceMessenger` ([#8695](https://github.com/MetaMask/core/pull/8695)) +- Export new constants `OHLCV_SERVICE_ALLOWED_ACTIONS` and `OHLCV_SERVICE_ALLOWED_EVENTS` for configuring the messenger ([#8695](https://github.com/MetaMask/core/pull/8695)) + ### Changed - Bump `@metamask/accounts-controller` from `^38.1.0` to `^38.1.1` ([#8774](https://github.com/MetaMask/core/pull/8774)) - Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/profile-sync-controller` from `^28.0.2` to `^28.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) ## [6.2.2] diff --git a/packages/core-backend/package.json b/packages/core-backend/package.json index 1dbe1eb9ec..6d9f3276db 100644 --- a/packages/core-backend/package.json +++ b/packages/core-backend/package.json @@ -57,9 +57,10 @@ "@metamask/controller-utils": "^12.1.0", "@metamask/keyring-controller": "^25.5.0", "@metamask/messenger": "^1.2.0", - "@metamask/profile-sync-controller": "^28.0.2", + "@metamask/profile-sync-controller": "^28.1.0", "@metamask/utils": "^11.9.0", "@tanstack/query-core": "^5.62.16", + "async-mutex": "^0.5.0", "uuid": "^8.3.2" }, "devDependencies": { diff --git a/packages/core-backend/src/index.ts b/packages/core-backend/src/index.ts index 8245157118..6197c068f8 100644 --- a/packages/core-backend/src/index.ts +++ b/packages/core-backend/src/index.ts @@ -9,7 +9,7 @@ export { getCloseReason, WebSocketState, WebSocketEventType, -} from './BackendWebSocketService'; +} from './ws/BackendWebSocketService'; export type { BackendWebSocketServiceOptions, @@ -24,7 +24,7 @@ export type { BackendWebSocketServiceConnectionStateChangedEvent, BackendWebSocketServiceEvents, BackendWebSocketServiceMessenger, -} from './BackendWebSocketService'; +} from './ws/BackendWebSocketService'; // ============================================================================ // ACCOUNT ACTIVITY SERVICE @@ -34,7 +34,7 @@ export { AccountActivityService, ACCOUNT_ACTIVITY_SERVICE_ALLOWED_ACTIONS, ACCOUNT_ACTIVITY_SERVICE_ALLOWED_EVENTS, -} from './AccountActivityService'; +} from './ws/AccountActivityService'; export type { SystemNotificationData, @@ -49,7 +49,7 @@ export type { AccountActivityServiceEvents, AllowedEvents as AccountActivityServiceAllowedEvents, AccountActivityServiceMessenger, -} from './AccountActivityService'; +} from './ws/AccountActivityService'; // ============================================================================ // SHARED TYPES @@ -80,6 +80,31 @@ export type { ApiPlatformClientServiceMessenger, } from './ApiPlatformClientService'; +// ============================================================================ +// OHLCV SERVICE +// ============================================================================ + +export { + OHLCVService, + OHLCV_SERVICE_ALLOWED_ACTIONS, + OHLCV_SERVICE_ALLOWED_EVENTS, +} from './ws/ohlcv'; + +export type { + OHLCVBar, + OHLCVSubscriptionOptions, + OHLCVSystemNotificationData, + OHLCVServiceOptions, + OHLCVServiceActions, + OHLCVServiceAllowedActions, + OHLCVServiceBarUpdatedEvent, + OHLCVServiceChainStatusChangedEvent, + OHLCVServiceSubscriptionErrorEvent, + OHLCVServiceEvents, + OHLCVServiceAllowedEvents, + OHLCVServiceMessenger, +} from './ws/ohlcv'; + // ============================================================================ // API PLATFORM CLIENT // ============================================================================ diff --git a/packages/core-backend/src/AccountActivityService-method-action-types.ts b/packages/core-backend/src/ws/AccountActivityService-method-action-types.ts similarity index 100% rename from packages/core-backend/src/AccountActivityService-method-action-types.ts rename to packages/core-backend/src/ws/AccountActivityService-method-action-types.ts diff --git a/packages/core-backend/src/AccountActivityService.test.ts b/packages/core-backend/src/ws/AccountActivityService.test.ts similarity index 99% rename from packages/core-backend/src/AccountActivityService.test.ts rename to packages/core-backend/src/ws/AccountActivityService.test.ts index 0dd8630ee1..ac5ea12dbc 100644 --- a/packages/core-backend/src/AccountActivityService.test.ts +++ b/packages/core-backend/src/ws/AccountActivityService.test.ts @@ -7,7 +7,9 @@ import type { } from '@metamask/messenger'; import type { Hex } from '@metamask/utils'; -import { flushPromises } from '../../../tests/helpers'; +import { flushPromises } from '../../../../tests/helpers'; +import type { Transaction, BalanceUpdate } from '../types'; +import type { AccountActivityMessage } from '../types'; import { AccountActivityService } from './AccountActivityService'; import type { AccountActivityServiceMessenger, @@ -15,8 +17,6 @@ import type { } from './AccountActivityService'; import type { ServerNotificationMessage } from './BackendWebSocketService'; import { WebSocketState } from './BackendWebSocketService'; -import type { Transaction, BalanceUpdate } from './types'; -import type { AccountActivityMessage } from './types'; type AllAccountActivityServiceActions = MessengerActions; diff --git a/packages/core-backend/src/AccountActivityService.ts b/packages/core-backend/src/ws/AccountActivityService.ts similarity index 99% rename from packages/core-backend/src/AccountActivityService.ts rename to packages/core-backend/src/ws/AccountActivityService.ts index a8b61a427e..88b97f1729 100644 --- a/packages/core-backend/src/AccountActivityService.ts +++ b/packages/core-backend/src/ws/AccountActivityService.ts @@ -13,6 +13,12 @@ import type { TraceCallback } from '@metamask/controller-utils'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { Messenger } from '@metamask/messenger'; +import { projectLogger, createModuleLogger } from '../logger'; +import type { + Transaction, + AccountActivityMessage, + BalanceUpdate, +} from '../types'; import type { AccountActivityServiceMethodActions } from './AccountActivityService-method-action-types'; import type { WebSocketConnectionInfo, @@ -21,12 +27,6 @@ import type { } from './BackendWebSocketService'; import { WebSocketState } from './BackendWebSocketService'; import type { BackendWebSocketServiceMethodActions } from './BackendWebSocketService-method-action-types'; -import { projectLogger, createModuleLogger } from './logger'; -import type { - Transaction, - AccountActivityMessage, - BalanceUpdate, -} from './types'; // ============================================================================= // Types and Constants diff --git a/packages/core-backend/src/BackendWebSocketService-method-action-types.ts b/packages/core-backend/src/ws/BackendWebSocketService-method-action-types.ts similarity index 100% rename from packages/core-backend/src/BackendWebSocketService-method-action-types.ts rename to packages/core-backend/src/ws/BackendWebSocketService-method-action-types.ts diff --git a/packages/core-backend/src/BackendWebSocketService.test.ts b/packages/core-backend/src/ws/BackendWebSocketService.test.ts similarity index 99% rename from packages/core-backend/src/BackendWebSocketService.test.ts rename to packages/core-backend/src/ws/BackendWebSocketService.test.ts index cf113e1ba7..9c17e430c2 100644 --- a/packages/core-backend/src/BackendWebSocketService.test.ts +++ b/packages/core-backend/src/ws/BackendWebSocketService.test.ts @@ -5,7 +5,7 @@ import type { MockAnyNamespace, } from '@metamask/messenger'; -import { flushPromises } from '../../../tests/helpers'; +import { flushPromises } from '../../../../tests/helpers'; import { BackendWebSocketService, getCloseReason, diff --git a/packages/core-backend/src/BackendWebSocketService.ts b/packages/core-backend/src/ws/BackendWebSocketService.ts similarity index 99% rename from packages/core-backend/src/BackendWebSocketService.ts rename to packages/core-backend/src/ws/BackendWebSocketService.ts index d8ca9139d5..c71075c372 100644 --- a/packages/core-backend/src/BackendWebSocketService.ts +++ b/packages/core-backend/src/ws/BackendWebSocketService.ts @@ -9,8 +9,8 @@ import type { AuthenticationController } from '@metamask/profile-sync-controller import { getErrorMessage } from '@metamask/utils'; import { v4 as uuidV4 } from 'uuid'; +import { projectLogger, createModuleLogger } from '../logger'; import type { BackendWebSocketServiceMethodActions } from './BackendWebSocketService-method-action-types'; -import { projectLogger, createModuleLogger } from './logger'; const SERVICE_NAME = 'BackendWebSocketService' as const; diff --git a/packages/core-backend/src/ws/ohlcv/OHLCVService-method-action-types.ts b/packages/core-backend/src/ws/ohlcv/OHLCVService-method-action-types.ts new file mode 100644 index 0000000000..6c30d85382 --- /dev/null +++ b/packages/core-backend/src/ws/ohlcv/OHLCVService-method-action-types.ts @@ -0,0 +1,40 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { OHLCVService } from './OHLCVService'; + +/** + * Subscribe to an OHLCV channel. If this is the first subscriber for the + * given asset/interval/currency combination a WebSocket subscription is + * created. Additional calls for the same combination only bump the reference + * count. + * + * @param options - The subscription parameters. + * @returns A promise that resolves once the subscription is established. + */ +export type OHLCVServiceSubscribeAction = { + type: `OHLCVService:subscribe`; + handler: OHLCVService['subscribe']; +}; + +/** + * Unsubscribe from an OHLCV channel. Decrements the reference count and, + * when it reaches zero, starts a grace-period timer before actually + * unsubscribing from the WebSocket to absorb rapid navigation patterns. + * + * @param options - The subscription parameters to unsubscribe from. + * @returns A promise that resolves once the unsubscription is processed. + */ +export type OHLCVServiceUnsubscribeAction = { + type: `OHLCVService:unsubscribe`; + handler: OHLCVService['unsubscribe']; +}; + +/** + * Union of all OHLCVService action types. + */ +export type OHLCVServiceMethodActions = + | OHLCVServiceSubscribeAction + | OHLCVServiceUnsubscribeAction; diff --git a/packages/core-backend/src/ws/ohlcv/OHLCVService.test.ts b/packages/core-backend/src/ws/ohlcv/OHLCVService.test.ts new file mode 100644 index 0000000000..19a442779d --- /dev/null +++ b/packages/core-backend/src/ws/ohlcv/OHLCVService.test.ts @@ -0,0 +1,1124 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; + +import { flushPromises } from '../../../../../tests/helpers'; +import type { ServerNotificationMessage } from '../BackendWebSocketService'; +import { WebSocketState } from '../BackendWebSocketService'; +import { OHLCVService } from './OHLCVService'; +import type { OHLCVServiceMessenger } from './OHLCVService'; +import type { OHLCVSubscriptionOptions } from './types'; + +// ============================================================================= +// Test Helpers +// ============================================================================= + +type AllOHLCVServiceActions = MessengerActions; +type AllOHLCVServiceEvents = MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllOHLCVServiceActions, + AllOHLCVServiceEvents +>; + +const completeAsyncOperations = async (timeoutMs = 0): Promise => { + // Multiple rounds are needed because the channel lock chains promises + // through .then(), requiring several microtask ticks to fully settle. + for (let i = 0; i < 5; i++) { + await flushPromises(); + } + if (timeoutMs > 0) { + await new Promise((resolve) => setTimeout(resolve, timeoutMs)); + } + await flushPromises(); +}; + +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +const getMessenger = (): { + rootMessenger: RootMessenger; + messenger: OHLCVServiceMessenger; + mocks: { + connect: jest.Mock; + subscribe: jest.Mock; + channelHasSubscription: jest.Mock; + getSubscriptionsByChannel: jest.Mock; + findSubscriptionsByChannelPrefix: jest.Mock; + forceReconnection: jest.Mock; + addChannelCallback: jest.Mock; + removeChannelCallback: jest.Mock; + getConnectionInfo: jest.Mock; + }; +} => { + const rootMessenger = getRootMessenger(); + const messenger: OHLCVServiceMessenger = new Messenger< + 'OHLCVService', + AllOHLCVServiceActions, + AllOHLCVServiceEvents, + RootMessenger + >({ + namespace: 'OHLCVService', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + actions: [ + 'BackendWebSocketService:connect', + 'BackendWebSocketService:forceReconnection', + 'BackendWebSocketService:subscribe', + 'BackendWebSocketService:getConnectionInfo', + 'BackendWebSocketService:channelHasSubscription', + 'BackendWebSocketService:getSubscriptionsByChannel', + 'BackendWebSocketService:findSubscriptionsByChannelPrefix', + 'BackendWebSocketService:addChannelCallback', + 'BackendWebSocketService:removeChannelCallback', + ], + events: ['BackendWebSocketService:connectionStateChanged'], + messenger, + }); + + const mockConnect = jest.fn(); + const mockForceReconnection = jest.fn(); + const mockSubscribe = jest.fn(); + const mockChannelHasSubscription = jest.fn().mockReturnValue(false); + const mockGetSubscriptionsByChannel = jest.fn().mockReturnValue([]); + const mockFindSubscriptionsByChannelPrefix = jest.fn().mockReturnValue([]); + const mockAddChannelCallback = jest.fn(); + const mockRemoveChannelCallback = jest.fn(); + const mockGetConnectionInfo = jest.fn(); + + rootMessenger.registerActionHandler( + 'BackendWebSocketService:connect', + mockConnect, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:forceReconnection', + mockForceReconnection, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:subscribe', + mockSubscribe, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:channelHasSubscription', + mockChannelHasSubscription, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:getSubscriptionsByChannel', + mockGetSubscriptionsByChannel, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:findSubscriptionsByChannelPrefix', + mockFindSubscriptionsByChannelPrefix, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:addChannelCallback', + mockAddChannelCallback, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:removeChannelCallback', + mockRemoveChannelCallback, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:getConnectionInfo', + mockGetConnectionInfo, + ); + + return { + rootMessenger, + messenger, + mocks: { + connect: mockConnect, + subscribe: mockSubscribe, + channelHasSubscription: mockChannelHasSubscription, + getSubscriptionsByChannel: mockGetSubscriptionsByChannel, + findSubscriptionsByChannelPrefix: mockFindSubscriptionsByChannelPrefix, + forceReconnection: mockForceReconnection, + addChannelCallback: mockAddChannelCallback, + removeChannelCallback: mockRemoveChannelCallback, + getConnectionInfo: mockGetConnectionInfo, + }, + }; +}; + +type WithServiceCallback = (payload: { + service: OHLCVService; + messenger: OHLCVServiceMessenger; + rootMessenger: RootMessenger; + mocks: ReturnType['mocks']; + destroy: () => void; +}) => Promise | R; + +async function withService(fn: WithServiceCallback): Promise { + const setup = getMessenger(); + const service = new OHLCVService({ messenger: setup.messenger }); + service.init(); + + try { + return await fn({ + service, + messenger: setup.messenger, + rootMessenger: setup.rootMessenger, + mocks: setup.mocks, + destroy: () => service.destroy(), + }); + } finally { + service.destroy(); + } +} + +const getSystemNotificationCallback = (mocks: { + addChannelCallback: jest.Mock; +}): ((notification: ServerNotificationMessage) => void) => { + const call = mocks.addChannelCallback.mock.calls.find( + (c: unknown[]) => + c[0] && + typeof c[0] === 'object' && + 'channelName' in c[0] && + (c[0] as { channelName: string }).channelName === + 'system-notifications.v1.market-data.v1', + ); + + if (!call) { + throw new Error('system notification callback not registered'); + } + + return (call[0] as { callback: (n: ServerNotificationMessage) => void }) + .callback; +}; + +// ============================================================================= +// Shared Constants +// ============================================================================= + +const SUB_OPTS: OHLCVSubscriptionOptions = { + assetId: 'eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + interval: '1m', + currency: 'usd', +}; + +const EXPECTED_CHANNEL = + 'market-data.v1.eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913.1m.usd'; + +const BASE_CONNECTION_INFO = { + url: 'ws://test', + timeout: 10000, + reconnectDelay: 500, + maxReconnectDelay: 5000, + requestTimeout: 30000, +}; + +// ============================================================================= +// Tests +// ============================================================================= + +describe('OHLCVService', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + // =========================================================================== + // Constructor + // =========================================================================== + + describe('constructor', () => { + it('should register method action handlers and system-notifications callback', async () => { + await withService(async ({ service, mocks }) => { + expect(service).toBeInstanceOf(OHLCVService); + expect(service.name).toBe('OHLCVService'); + + expect(mocks.addChannelCallback).toHaveBeenCalledWith({ + channelName: 'system-notifications.v1.market-data.v1', + callback: expect.any(Function), + }); + }); + }); + }); + + // =========================================================================== + // Subscribe + // =========================================================================== + + describe('subscribe', () => { + it('should connect and create a WebSocket subscription for a new channel', async () => { + await withService(async ({ service, mocks }) => { + await service.subscribe(SUB_OPTS); + + expect(mocks.connect).toHaveBeenCalledTimes(1); + expect(mocks.channelHasSubscription).toHaveBeenCalledWith( + EXPECTED_CHANNEL, + ); + expect(mocks.subscribe).toHaveBeenCalledWith({ + channels: [EXPECTED_CHANNEL], + channelType: 'market-data.v1', + callback: expect.any(Function), + }); + }); + }); + + it('should skip WS subscribe if the channel already has a subscription', async () => { + await withService(async ({ service, mocks }) => { + mocks.channelHasSubscription.mockReturnValue(true); + + await service.subscribe(SUB_OPTS); + + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + }); + + it('should increment refCount on duplicate subscribe without WS traffic', async () => { + await withService(async ({ service, mocks }) => { + await service.subscribe(SUB_OPTS); + mocks.subscribe.mockClear(); + mocks.connect.mockClear(); + + await service.subscribe(SUB_OPTS); + + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + }); + + it('should publish subscriptionError when subscribe fails', async () => { + await withService(async ({ service, mocks, messenger }) => { + mocks.connect.mockRejectedValueOnce(new Error('connection failed')); + + const errorListener = jest.fn(); + messenger.subscribe('OHLCVService:subscriptionError', errorListener); + + await service.subscribe(SUB_OPTS); + + expect(errorListener).toHaveBeenCalledWith({ + channel: EXPECTED_CHANNEL, + error: expect.stringContaining('connection failed'), + operation: 'subscribe', + }); + expect(mocks.forceReconnection).not.toHaveBeenCalled(); + }); + }); + + it('should publish barUpdated events when WebSocket delivers data', async () => { + await withService(async ({ service, mocks, messenger }) => { + let capturedCallback: (n: ServerNotificationMessage) => void = + jest.fn(); + + mocks.subscribe.mockImplementation((opts) => { + capturedCallback = opts.callback; + return Promise.resolve(); + }); + + await service.subscribe(SUB_OPTS); + + const barListener = jest.fn(); + messenger.subscribe('OHLCVService:barUpdated', barListener); + + capturedCallback({ + event: 'data', + subscriptionId: 'sub-1', + timestamp: 1776364071003, + channel: EXPECTED_CHANNEL, + data: { + timestamp: 1776364020, + open: 74.099, + high: 74.1, + low: 74.083, + close: 74.099, + volume: 5806.43, + }, + } as ServerNotificationMessage); + + expect(barListener).toHaveBeenCalledWith({ + channel: EXPECTED_CHANNEL, + bar: { + timestamp: 1776364020, + open: 74.099, + high: 74.1, + low: 74.083, + close: 74.099, + volume: 5806.43, + }, + }); + }); + }); + }); + + // =========================================================================== + // Unsubscribe + // =========================================================================== + + describe('unsubscribe', () => { + it('should be a no-op if channel was never subscribed', async () => { + await withService(async ({ service, mocks }) => { + await service.unsubscribe(SUB_OPTS); + + expect(mocks.getSubscriptionsByChannel).not.toHaveBeenCalled(); + }); + }); + + it('should decrement refCount without unsubscribing when other consumers remain', async () => { + await withService(async ({ service, mocks }) => { + await service.subscribe(SUB_OPTS); + await service.subscribe(SUB_OPTS); + + await service.unsubscribe(SUB_OPTS); + + // No timer should have been started, no WS unsubscribe + jest.advanceTimersByTime(5000); + await completeAsyncOperations(); + expect(mocks.getSubscriptionsByChannel).not.toHaveBeenCalled(); + }); + }); + + it('should start a grace-period timer and unsubscribe after expiry', async () => { + await withService(async ({ service, mocks }) => { + const mockUnsub = jest.fn(); + mocks.getSubscriptionsByChannel.mockReturnValue([ + { unsubscribe: mockUnsub }, + ]); + + await service.subscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + // Before grace period expires — still subscribed + expect(mockUnsub).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(3000); + await completeAsyncOperations(); + + expect(mocks.getSubscriptionsByChannel).toHaveBeenCalledWith( + EXPECTED_CHANNEL, + ); + expect(mockUnsub).toHaveBeenCalledTimes(1); + }); + }); + }); + + // =========================================================================== + // Grace Period — Re-subscribe During Grace + // =========================================================================== + + describe('grace period', () => { + it('should cancel grace-period timer if re-subscribed before expiry', async () => { + await withService(async ({ service, mocks }) => { + const mockUnsub = jest.fn(); + mocks.getSubscriptionsByChannel.mockReturnValue([ + { unsubscribe: mockUnsub }, + ]); + + await service.subscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + // WS subscription still exists (no disconnect happened) + mocks.channelHasSubscription.mockReturnValue(true); + + // Re-subscribe during grace period + jest.advanceTimersByTime(1000); + mocks.subscribe.mockClear(); + mocks.connect.mockClear(); + await service.subscribe(SUB_OPTS); + + // Should NOT have called connect/subscribe again — subscription is still alive + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.subscribe).not.toHaveBeenCalled(); + + // Advance past original grace period — should NOT unsubscribe + jest.advanceTimersByTime(5000); + await completeAsyncOperations(); + expect(mockUnsub).not.toHaveBeenCalled(); + }); + }); + + it('should unsubscribe old channel after grace period during time-range switching', async () => { + const opts1m = SUB_OPTS; + const opts1h: OHLCVSubscriptionOptions = { + ...SUB_OPTS, + interval: '1h', + }; + + await withService(async ({ service, mocks }) => { + const mockUnsub = jest.fn().mockResolvedValue(undefined); + mocks.getSubscriptionsByChannel.mockReturnValue([ + { unsubscribe: mockUnsub }, + ]); + + // Subscribe 1m → unsubscribe → subscribe 1h + await service.subscribe(opts1m); + await service.unsubscribe(opts1m); + await service.subscribe(opts1h); + + // 1m is in grace period, not yet unsubscribed + expect(mockUnsub).not.toHaveBeenCalled(); + + // Grace period expires — old channel cleaned up + jest.advanceTimersByTime(3000); + await completeAsyncOperations(); + expect(mockUnsub).toHaveBeenCalledTimes(1); + }); + }); + }); + + // =========================================================================== + // Reference Counting + // =========================================================================== + + describe('reference counting', () => { + it('should share a single WS subscription across multiple consumers', async () => { + await withService(async ({ service, mocks }) => { + await service.subscribe(SUB_OPTS); + await service.subscribe(SUB_OPTS); + await service.subscribe(SUB_OPTS); + + // Only one WS subscribe call + expect(mocks.subscribe).toHaveBeenCalledTimes(1); + + // Unsubscribe twice — refCount goes from 3 → 1 + await service.unsubscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + jest.advanceTimersByTime(5000); + await completeAsyncOperations(); + + // Still has one consumer — no WS unsubscribe + expect(mocks.getSubscriptionsByChannel).not.toHaveBeenCalled(); + }); + }); + + it('should unsubscribe from WS when all consumers leave and grace expires', async () => { + await withService(async ({ service, mocks }) => { + const mockUnsub = jest.fn(); + mocks.getSubscriptionsByChannel.mockReturnValue([ + { unsubscribe: mockUnsub }, + ]); + + await service.subscribe(SUB_OPTS); + await service.subscribe(SUB_OPTS); + + await service.unsubscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + jest.advanceTimersByTime(3000); + await completeAsyncOperations(); + + expect(mockUnsub).toHaveBeenCalledTimes(1); + }); + }); + }); + + // =========================================================================== + // Race Condition — Per-Channel Locking + // =========================================================================== + + describe('per-channel locking', () => { + it('should serialize concurrent subscribes so refCount is correct', async () => { + await withService(async ({ service, mocks }) => { + let connectResolve!: () => void; + mocks.connect.mockImplementation( + () => + new Promise((resolve) => { + connectResolve = resolve; + }), + ); + + const p1 = service.subscribe(SUB_OPTS); + // Let the microtask tick so `connect` is called and `connectResolve` is assigned + await flushPromises(); + + const p2 = service.subscribe(SUB_OPTS); + + // p1 is waiting on connect, p2 is queued behind it via the lock + connectResolve(); + mocks.connect.mockResolvedValue(undefined); + await p1; + await p2; + + expect(mocks.subscribe).toHaveBeenCalledTimes(1); + + const mockUnsub = jest.fn(); + mocks.getSubscriptionsByChannel.mockReturnValue([ + { unsubscribe: mockUnsub }, + ]); + + // refCount must be 2 — first unsubscribe drops it to 1, no grace timer + await service.unsubscribe(SUB_OPTS); + jest.advanceTimersByTime(5000); + await completeAsyncOperations(); + expect(mockUnsub).not.toHaveBeenCalled(); + + // Second unsubscribe drops refCount to 0 → grace timer → WS unsubscribe + await service.unsubscribe(SUB_OPTS); + jest.advanceTimersByTime(3000); + await completeAsyncOperations(); + expect(mockUnsub).toHaveBeenCalledTimes(1); + }); + }); + + it('should serialize concurrent subscribe + unsubscribe so refCount never corrupts', async () => { + await withService(async ({ service, mocks }) => { + let connectResolve!: () => void; + mocks.connect.mockImplementation( + () => + new Promise((resolve) => { + connectResolve = resolve; + }), + ); + + const pSub = service.subscribe(SUB_OPTS); + await flushPromises(); + + const pUnsub = service.unsubscribe(SUB_OPTS); + + connectResolve(); + await pSub; + await pUnsub; + + // After subscribe then unsubscribe, refCount is 0 → grace timer starts + // Advance past grace period + const mockUnsub = jest.fn(); + mocks.getSubscriptionsByChannel.mockReturnValue([ + { unsubscribe: mockUnsub }, + ]); + + jest.advanceTimersByTime(3000); + await completeAsyncOperations(); + + expect(mockUnsub).toHaveBeenCalledTimes(1); + }); + }); + + it('should create a fresh WS subscription when subscribe races with grace-period unsubscribe', async () => { + await withService(async ({ service, mocks }) => { + let unsubResolve!: () => void; + const mockUnsub = jest.fn( + () => + new Promise((resolve) => { + unsubResolve = resolve; + }), + ); + mocks.getSubscriptionsByChannel.mockReturnValue([ + { unsubscribe: mockUnsub }, + ]); + + await service.subscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + jest.advanceTimersByTime(3000); + await flushPromises(); + + const subscribePromise = service.subscribe(SUB_OPTS); + unsubResolve(); + await subscribePromise; + + expect(mocks.subscribe).toHaveBeenCalledTimes(2); + }); + }); + }); + + // =========================================================================== + // Reconnect Resilience + // =========================================================================== + + describe('reconnect', () => { + it('should resubscribe active channels on WebSocket CONNECTED', async () => { + await withService(async ({ service, mocks, rootMessenger }) => { + await service.subscribe(SUB_OPTS); + mocks.subscribe.mockClear(); + mocks.channelHasSubscription.mockReturnValue(false); + + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + ...BASE_CONNECTION_INFO, + state: WebSocketState.CONNECTED, + connectedAt: Date.now(), + reconnectAttempts: 0, + }, + ); + await completeAsyncOperations(); + + expect(mocks.subscribe).toHaveBeenCalledWith({ + channels: [EXPECTED_CHANNEL], + channelType: 'market-data.v1', + callback: expect.any(Function), + }); + }); + }); + + it('should skip resubscribe if channel already has a subscription after reconnect', async () => { + await withService(async ({ service, mocks, rootMessenger }) => { + await service.subscribe(SUB_OPTS); + mocks.subscribe.mockClear(); + mocks.channelHasSubscription.mockReturnValue(true); + + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + ...BASE_CONNECTION_INFO, + state: WebSocketState.CONNECTED, + connectedAt: Date.now(), + reconnectAttempts: 0, + }, + ); + await completeAsyncOperations(); + + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + }); + + it('should not resubscribe channels in grace period (refCount === 0)', async () => { + await withService(async ({ service, mocks, rootMessenger }) => { + await service.subscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + // Channel is now in grace period (refCount === 0, timer running) + mocks.subscribe.mockClear(); + mocks.channelHasSubscription.mockReturnValue(false); + + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + ...BASE_CONNECTION_INFO, + state: WebSocketState.CONNECTED, + connectedAt: Date.now(), + reconnectAttempts: 0, + }, + ); + await completeAsyncOperations(); + + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + }); + + it('should recreate WS subscription when re-subscribing during grace period after disconnect', async () => { + await withService( + async ({ service, mocks, messenger, rootMessenger }) => { + // 1. Subscribe — creates WS subscription, refCount = 1 + await service.subscribe(SUB_OPTS); + + // 2. Unsubscribe — refCount = 0, grace-period timer starts + await service.unsubscribe(SUB_OPTS); + + // 3. Disconnect — BackendWebSocketService clears all server-side + // subscriptions. channelHasSubscription now returns false. + mocks.channelHasSubscription.mockReturnValue(false); + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + ...BASE_CONNECTION_INFO, + state: WebSocketState.DISCONNECTED, + connectedAt: undefined, + reconnectAttempts: 0, + }, + ); + await completeAsyncOperations(); + + // 4. Reconnect — resubscribeActiveChannels skips this channel + // because refCount is 0 (correct behaviour). + mocks.subscribe.mockClear(); + mocks.connect.mockClear(); + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + ...BASE_CONNECTION_INFO, + state: WebSocketState.CONNECTED, + connectedAt: Date.now(), + reconnectAttempts: 1, + }, + ); + await completeAsyncOperations(); + expect(mocks.subscribe).not.toHaveBeenCalled(); + + // 5. User re-subscribes BEFORE grace timer fires. + // The grace-period branch cancels the timer and bumps refCount, + // but the underlying WS subscription no longer exists. + // The fix must detect this and create a fresh WS subscription. + mocks.subscribe.mockClear(); + mocks.connect.mockClear(); + await service.subscribe(SUB_OPTS); + + expect(mocks.connect).toHaveBeenCalledTimes(1); + expect(mocks.subscribe).toHaveBeenCalledWith({ + channels: [EXPECTED_CHANNEL], + channelType: 'market-data.v1', + callback: expect.any(Function), + }); + + // 6. Verify bar updates are delivered through the new subscription. + const capturedCallback = mocks.subscribe.mock.calls[0][0].callback; + const barListener = jest.fn(); + messenger.subscribe('OHLCVService:barUpdated', barListener); + + capturedCallback({ + data: { + timestamp: 200, + open: 10, + high: 20, + low: 5, + close: 15, + volume: 1000, + }, + timestamp: Date.now(), + }); + + expect(barListener).toHaveBeenCalledWith({ + channel: EXPECTED_CHANNEL, + bar: { + timestamp: 200, + open: 10, + high: 20, + low: 5, + close: 15, + volume: 1000, + }, + }); + }, + ); + }); + + it('should deliver bar updates via resubscribed channel callback', async () => { + await withService( + async ({ service, mocks, messenger, rootMessenger }) => { + await service.subscribe(SUB_OPTS); + mocks.subscribe.mockClear(); + mocks.channelHasSubscription.mockReturnValue(false); + + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + ...BASE_CONNECTION_INFO, + state: WebSocketState.CONNECTED, + connectedAt: Date.now(), + reconnectAttempts: 0, + }, + ); + await completeAsyncOperations(); + + const resubscribeCallback = mocks.subscribe.mock.calls[0][0].callback; + const barListener = jest.fn(); + messenger.subscribe('OHLCVService:barUpdated', barListener); + + resubscribeCallback({ + data: { + timestamp: 100, + open: 1, + high: 2, + low: 0.5, + close: 1.5, + volume: 999, + }, + timestamp: Date.now(), + }); + + expect(barListener).toHaveBeenCalledWith({ + channel: EXPECTED_CHANNEL, + bar: { + timestamp: 100, + open: 1, + high: 2, + low: 0.5, + close: 1.5, + volume: 999, + }, + }); + }, + ); + }); + + it('should publish chainStatusChanged down on DISCONNECTED', async () => { + await withService(async ({ mocks, messenger, rootMessenger }) => { + const statusListener = jest.fn(); + messenger.subscribe('OHLCVService:chainStatusChanged', statusListener); + + // Simulate a system notification marking a chain as up + const systemCallback = getSystemNotificationCallback(mocks); + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.market-data.v1', + data: { chainIds: ['eip155:8453'], status: 'up' }, + timestamp: Date.now(), + } as ServerNotificationMessage); + + statusListener.mockClear(); + + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + ...BASE_CONNECTION_INFO, + state: WebSocketState.DISCONNECTED, + connectedAt: undefined, + reconnectAttempts: 0, + }, + ); + await completeAsyncOperations(); + + expect(statusListener).toHaveBeenCalledWith( + expect.objectContaining({ + chainIds: ['eip155:8453'], + status: 'down', + }), + ); + }); + }); + }); + + // =========================================================================== + // System Notifications + // =========================================================================== + + describe('system notifications', () => { + it('should forward chain-down notifications via chainStatusChanged event', async () => { + await withService(async ({ mocks, messenger }) => { + const statusListener = jest.fn(); + messenger.subscribe('OHLCVService:chainStatusChanged', statusListener); + + const systemCallback = getSystemNotificationCallback(mocks); + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.market-data.v1', + data: { chainIds: ['eip155:8453'], status: 'down' }, + timestamp: 1776364071003, + } as ServerNotificationMessage); + + expect(statusListener).toHaveBeenCalledWith({ + chainIds: ['eip155:8453'], + status: 'down', + timestamp: 1776364071003, + }); + }); + }); + + it('should forward chain-up notifications', async () => { + await withService(async ({ mocks, messenger }) => { + const statusListener = jest.fn(); + messenger.subscribe('OHLCVService:chainStatusChanged', statusListener); + + const systemCallback = getSystemNotificationCallback(mocks); + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.market-data.v1', + data: { chainIds: ['eip155:1', 'eip155:137'], status: 'up' }, + timestamp: 1776364071003, + } as ServerNotificationMessage); + + expect(statusListener).toHaveBeenCalledWith({ + chainIds: ['eip155:1', 'eip155:137'], + status: 'up', + timestamp: 1776364071003, + }); + }); + }); + + it('should throw on invalid system notification data', async () => { + await withService(async ({ mocks }) => { + const systemCallback = getSystemNotificationCallback(mocks); + + expect(() => + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.market-data.v1', + data: { invalid: true }, + timestamp: Date.now(), + } as unknown as ServerNotificationMessage), + ).toThrow('Invalid system notification data'); + }); + }); + }); + + // =========================================================================== + // Error Paths + // =========================================================================== + + describe('error paths', () => { + it('should publish subscriptionError when unsubscribe fails', async () => { + await withService(async ({ service, mocks, messenger }) => { + mocks.getSubscriptionsByChannel.mockImplementation(() => { + throw new Error('ws gone'); + }); + + const errorListener = jest.fn(); + messenger.subscribe('OHLCVService:subscriptionError', errorListener); + + await service.subscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + jest.advanceTimersByTime(3000); + await completeAsyncOperations(); + + expect(errorListener).toHaveBeenCalledWith({ + channel: EXPECTED_CHANNEL, + error: expect.stringContaining('ws gone'), + operation: 'unsubscribe', + }); + expect(mocks.forceReconnection).not.toHaveBeenCalled(); + }); + }); + + it('should clean up channel entry when subscribe fails during grace-period fall-through so subsequent subscribes work', async () => { + await withService(async ({ service, mocks, messenger }) => { + // 1. Subscribe — creates WS subscription, refCount = 1 + await service.subscribe(SUB_OPTS); + + // 2. Unsubscribe — refCount = 0, grace-period timer starts + await service.unsubscribe(SUB_OPTS); + + // 3. Disconnect — channelHasSubscription returns false + mocks.channelHasSubscription.mockReturnValue(false); + + // 4. Re-subscribe during grace period — grace branch detects WS + // subscription is gone and falls through to the try block. + // Make connect() throw to simulate a network failure. + mocks.connect.mockRejectedValueOnce(new Error('network down')); + mocks.subscribe.mockClear(); + mocks.connect.mockClear(); + + const errorListener = jest.fn(); + messenger.subscribe('OHLCVService:subscriptionError', errorListener); + + await service.subscribe(SUB_OPTS); + + expect(errorListener).toHaveBeenCalledWith({ + channel: EXPECTED_CHANNEL, + error: expect.stringContaining('network down'), + operation: 'subscribe', + }); + expect(mocks.forceReconnection).not.toHaveBeenCalled(); + + // 5. Now the critical assertion: a subsequent subscribe() must NOT + // silently increment a stale refCount. It must attempt a fresh + // WS subscription. + mocks.connect.mockResolvedValue(undefined); + mocks.subscribe.mockClear(); + + await service.subscribe(SUB_OPTS); + + expect(mocks.subscribe).toHaveBeenCalledWith({ + channels: [EXPECTED_CHANNEL], + channelType: 'market-data.v1', + callback: expect.any(Function), + }); + }); + }); + + it('should log and continue when resubscription fails for a channel', async () => { + await withService(async ({ service, mocks, rootMessenger }) => { + await service.subscribe(SUB_OPTS); + mocks.subscribe.mockClear(); + mocks.channelHasSubscription.mockReturnValue(false); + mocks.subscribe.mockRejectedValueOnce(new Error('resubscribe fail')); + + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + ...BASE_CONNECTION_INFO, + state: WebSocketState.CONNECTED, + connectedAt: Date.now(), + reconnectAttempts: 1, + }, + ); + await completeAsyncOperations(); + + // Should have attempted but failed silently + expect(mocks.subscribe).toHaveBeenCalledTimes(1); + }); + }); + }); + + // =========================================================================== + // Reconnect + Concurrent Mutation Safety + // =========================================================================== + + describe('resubscribe holds mutex to prevent concurrent mutation', () => { + it('should block unsubscribe until resubscription completes, preventing orphaned WS subscriptions', async () => { + await withService(async ({ service, mocks, rootMessenger }) => { + await service.subscribe(SUB_OPTS); + mocks.subscribe.mockClear(); + mocks.channelHasSubscription.mockReturnValue(false); + + // Make the WS subscribe during reconnect take time so we can + // attempt a concurrent unsubscribe while it's in progress. + let resubResolve!: () => void; + mocks.subscribe.mockImplementation( + () => + new Promise((resolve) => { + resubResolve = resolve; + }), + ); + + // Trigger reconnect — this calls #resubscribeActiveChannels which + // now holds the mutex across the entire loop. + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + ...BASE_CONNECTION_INFO, + state: WebSocketState.CONNECTED, + connectedAt: Date.now(), + reconnectAttempts: 1, + }, + ); + await flushPromises(); + + // Concurrent unsubscribe — must queue behind the mutex. + const unsubPromise = service.unsubscribe(SUB_OPTS); + + // The unsubscribe hasn't run yet because the mutex is held. + // Complete the WS resubscription. + resubResolve(); + await flushPromises(); + await unsubPromise; + + // refCount was 1 at reconnect time; after resubscribe completes + // the queued unsubscribe drops it to 0 and starts the grace timer. + const mockUnsub = jest.fn(); + mocks.getSubscriptionsByChannel.mockReturnValue([ + { unsubscribe: mockUnsub }, + ]); + + jest.advanceTimersByTime(3000); + await completeAsyncOperations(); + + // The grace-period unsubscribe fires cleanly — no orphaned subscription. + expect(mockUnsub).toHaveBeenCalledTimes(1); + }); + }); + }); + + // =========================================================================== + // Destroy + // =========================================================================== + + describe('destroy', () => { + it('should clear grace-period timers and remove channel callback', async () => { + await withService(async ({ service, mocks }) => { + const mockUnsub = jest.fn(); + mocks.getSubscriptionsByChannel.mockReturnValue([ + { unsubscribe: mockUnsub }, + ]); + + await service.subscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + // Grace timer is running — destroy should clear it + service.destroy(); + + jest.advanceTimersByTime(5000); + await completeAsyncOperations(); + + // Timer was cleared so the actual unsubscribe should NOT have fired + expect(mockUnsub).not.toHaveBeenCalled(); + + expect(mocks.removeChannelCallback).toHaveBeenCalledWith( + 'system-notifications.v1.market-data.v1', + ); + }); + }); + }); +}); diff --git a/packages/core-backend/src/ws/ohlcv/OHLCVService.ts b/packages/core-backend/src/ws/ohlcv/OHLCVService.ts new file mode 100644 index 0000000000..f729bbf8fe --- /dev/null +++ b/packages/core-backend/src/ws/ohlcv/OHLCVService.ts @@ -0,0 +1,549 @@ +/** + * OHLCV Service for real-time candlestick data streaming via WebSocket. + * + * Wraps {@link BackendWebSocketService} through the messenger pattern to + * provide subscribe/unsubscribe semantics for OHLCV market-data channels. + * Includes reference counting, grace-period unsubscribe, idempotency checks, + * chain-status forwarding, and automatic resubscription on reconnect. + */ + +import type { + TraceCallback, + TraceContext, + TraceRequest, +} from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import { Mutex } from 'async-mutex'; + +import { projectLogger, createModuleLogger } from '../../logger'; +import type { + WebSocketConnectionInfo, + BackendWebSocketServiceConnectionStateChangedEvent, + ServerNotificationMessage, +} from '../BackendWebSocketService'; +import { WebSocketState } from '../BackendWebSocketService'; +import type { BackendWebSocketServiceMethodActions } from '../BackendWebSocketService-method-action-types'; +import type { OHLCVServiceMethodActions } from './OHLCVService-method-action-types'; +import type { OHLCVBar, OHLCVSubscriptionOptions } from './types'; + +// ============================================================================= +// Constants +// ============================================================================= + +const SERVICE_NAME = 'OHLCVService'; + +const log = createModuleLogger(projectLogger, SERVICE_NAME); + +const MESSENGER_EXPOSED_METHODS = ['subscribe', 'unsubscribe'] as const; + +const SUBSCRIPTION_NAMESPACE = 'market-data.v1'; + +const SYSTEM_NOTIFICATIONS_CHANNEL = `system-notifications.v1.${SUBSCRIPTION_NAMESPACE}`; + +/** Delay before actually unsubscribing from a channel after refCount reaches 0. */ +const GRACE_PERIOD_MS = 3_000; + +// ============================================================================= +// Types — Channel Tracking +// ============================================================================= + +type ChannelEntry = { + refCount: number; + gracePeriodTimer?: ReturnType; +}; + +// ============================================================================= +// Types — System Notifications +// ============================================================================= + +/** + * System notification data for chain status updates on market-data channels. + */ +export type OHLCVSystemNotificationData = { + chainIds: string[]; + status: 'down' | 'up'; + timestamp?: number; +}; + +// ============================================================================= +// Types — Service Options +// ============================================================================= + +/** + * Configuration options for the OHLCV service. + */ +export type OHLCVServiceOptions = { + /** Optional callback to trace performance of OHLCV operations (default: no-op) */ + traceFn?: TraceCallback; +}; + +// ============================================================================= +// Action and Event Types +// ============================================================================= + +export type OHLCVServiceActions = OHLCVServiceMethodActions; + +export const OHLCV_SERVICE_ALLOWED_ACTIONS = [ + 'BackendWebSocketService:connect', + 'BackendWebSocketService:forceReconnection', + 'BackendWebSocketService:subscribe', + 'BackendWebSocketService:getConnectionInfo', + 'BackendWebSocketService:channelHasSubscription', + 'BackendWebSocketService:getSubscriptionsByChannel', + 'BackendWebSocketService:findSubscriptionsByChannelPrefix', + 'BackendWebSocketService:addChannelCallback', + 'BackendWebSocketService:removeChannelCallback', +] as const; + +export const OHLCV_SERVICE_ALLOWED_EVENTS = [ + 'BackendWebSocketService:connectionStateChanged', +] as const; + +export type AllowedActions = BackendWebSocketServiceMethodActions; + +// Events published by OHLCVService + +export type OHLCVServiceBarUpdatedEvent = { + type: `OHLCVService:barUpdated`; + payload: [{ channel: string; bar: OHLCVBar }]; +}; + +export type OHLCVServiceChainStatusChangedEvent = { + type: `OHLCVService:chainStatusChanged`; + payload: [{ chainIds: string[]; status: 'up' | 'down'; timestamp?: number }]; +}; + +export type OHLCVServiceSubscriptionErrorEvent = { + type: `OHLCVService:subscriptionError`; + payload: [{ channel: string; error: string; operation: string }]; +}; + +export type OHLCVServiceEvents = + | OHLCVServiceBarUpdatedEvent + | OHLCVServiceChainStatusChangedEvent + | OHLCVServiceSubscriptionErrorEvent; + +export type AllowedEvents = BackendWebSocketServiceConnectionStateChangedEvent; + +export type OHLCVServiceMessenger = Messenger< + typeof SERVICE_NAME, + OHLCVServiceActions | AllowedActions, + OHLCVServiceEvents | AllowedEvents +>; + +// ============================================================================= +// Main Service Class +// ============================================================================= + +/** + * Service for real-time OHLCV candlestick streaming via the backend WebSocket + * gateway. Communicates with {@link BackendWebSocketService} exclusively + * through the messenger — no direct import of the class. + * + * Features: + * - Reference counting: multiple UI consumers share one WebSocket subscription + * - Grace-period unsubscribe: avoids rapid unsub/resub during navigation + * - Idempotency: duplicate subscribe calls for the same channel are no-ops + * - Reconnect resilience: resubscribes all active channels on reconnect + * - Chain-status forwarding: listens to system-notifications for chain up/down + * + */ +export class OHLCVService { + readonly name = SERVICE_NAME; + + readonly #messenger: OHLCVServiceMessenger; + + readonly #trace: TraceCallback; + + readonly #channels = new Map(); + + readonly #mutex = new Mutex(); + + readonly #chainsUp = new Set(); + + // ============================================================================= + // Constructor + // ============================================================================= + + constructor( + options: OHLCVServiceOptions & { messenger: OHLCVServiceMessenger }, + ) { + this.#messenger = options.messenger; + + this.#trace = + options.traceFn ?? + ((( + _request: TraceRequest, + fn?: (context?: TraceContext) => Result, + ) => fn?.()) as TraceCallback); + + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + + this.#messenger.subscribe( + 'BackendWebSocketService:connectionStateChanged', + // eslint-disable-next-line @typescript-eslint/no-misused-promises + (connectionInfo: WebSocketConnectionInfo) => + this.#handleWebSocketStateChange(connectionInfo), + ); + } + + /** + * Register the system-notifications channel callback. + */ + init(): void { + log('OHLCV-WS: Initializing — registering system-notifications callback'); + this.#messenger.call('BackendWebSocketService:addChannelCallback', { + channelName: SYSTEM_NOTIFICATIONS_CHANNEL, + callback: (notification: ServerNotificationMessage) => + this.#handleSystemNotification(notification), + }); + } + + // ============================================================================= + // Public — Subscribe / Unsubscribe + // ============================================================================= + + /** + * Subscribe to an OHLCV channel. If this is the first subscriber for the + * given asset/interval/currency combination a WebSocket subscription is + * created. Additional calls for the same combination only bump the reference + * count. + * + * @param options - The subscription parameters. + * @returns A promise that resolves once the subscription is established. + */ + async subscribe(options: OHLCVSubscriptionOptions): Promise { + const channel = this.#buildChannel(options); + const releaseLock = await this.#mutex.acquire(); + try { + await this.#subscribeInner(channel); + } finally { + releaseLock(); + } + } + + async #subscribeInner(channel: string): Promise { + const entry = this.#channels.get(channel); + + if (entry?.gracePeriodTimer) { + clearTimeout(entry.gracePeriodTimer); + entry.gracePeriodTimer = undefined; + log('OHLCV-WS: Cancelled grace-period unsubscribe', { + channel, + }); + + if ( + this.#messenger.call( + 'BackendWebSocketService:channelHasSubscription', + channel, + ) + ) { + entry.refCount += 1; + log('OHLCV-WS: WS subscription still alive, bumped refCount', { + channel, + refCount: entry.refCount, + }); + return; + } + // WS subscription was lost (e.g. after disconnect/reconnect) — fall + // through to recreate it. refCount is bumped only after success below. + } else if (entry && entry.refCount > 0) { + entry.refCount += 1; + return; + } + try { + await this.#messenger.call('BackendWebSocketService:connect'); + + if ( + this.#messenger.call( + 'BackendWebSocketService:channelHasSubscription', + channel, + ) + ) { + log( + 'OHLCV-WS: Channel already has WS subscription (idempotency), skipping', + { + channel, + }, + ); + this.#channels.set(channel, { refCount: 1 }); + return; + } + + await this.#messenger.call('BackendWebSocketService:subscribe', { + channels: [channel], + channelType: SUBSCRIPTION_NAMESPACE, + callback: (notification: ServerNotificationMessage) => { + this.#handleBarUpdate(channel, notification); + }, + }); + + this.#channels.set(channel, { refCount: 1 }); + log('OHLCV-WS: Subscribe succeeded — new WS subscription created', { + channel, + }); + } catch (error) { + log('OHLCV-WS: Subscription failed', { channel, error }); + this.#channels.delete(channel); + this.#messenger.publish('OHLCVService:subscriptionError', { + channel, + error: String(error), + operation: 'subscribe', + }); + } + } + + /** + * Unsubscribe from an OHLCV channel. Decrements the reference count and, + * when it reaches zero, starts a grace-period timer before actually + * unsubscribing from the WebSocket to absorb rapid navigation patterns. + * + * @param options - The subscription parameters to unsubscribe from. + * @returns A promise that resolves once the unsubscription is processed. + */ + async unsubscribe(options: OHLCVSubscriptionOptions): Promise { + const channel = this.#buildChannel(options); + const releaseLock = await this.#mutex.acquire(); + try { + await this.#unsubscribeInner(channel); + } finally { + releaseLock(); + } + } + + async #unsubscribeInner(channel: string): Promise { + const entry = this.#channels.get(channel); + + if (!entry || entry.refCount <= 0) { + return; + } + + entry.refCount -= 1; + + if (entry.refCount > 0) { + return; + } + + entry.gracePeriodTimer = setTimeout(() => { + entry.gracePeriodTimer = undefined; + this.#performUnsubscribe(channel).catch(() => { + // no-op + }); + }, GRACE_PERIOD_MS); + } + + // ============================================================================= + // Private — WebSocket Subscription Helpers + // ============================================================================= + + async #performUnsubscribe(channel: string): Promise { + const releaseLock = await this.#mutex.acquire(); + try { + const entry = this.#channels.get(channel); + if (entry && entry.refCount > 0) { + log( + 'OHLCV-WS: Skipping unsubscribe — new subscriber arrived while queued', + { channel, refCount: entry.refCount }, + ); + return; + } + + log('OHLCV-WS: Grace period expired — performing actual WS unsubscribe', { + channel, + }); + this.#channels.delete(channel); + + try { + const subscriptions = this.#messenger.call( + 'BackendWebSocketService:getSubscriptionsByChannel', + channel, + ); + + for (const sub of subscriptions) { + await sub.unsubscribe(); + } + log('OHLCV-WS: WS unsubscribe completed', { channel }); + } catch (error) { + log('OHLCV-WS: Unsubscription failed', { channel, error }); + this.#messenger.publish('OHLCVService:subscriptionError', { + channel, + error: String(error), + operation: 'unsubscribe', + }); + } + } finally { + releaseLock(); + } + } + + /** + * Resubscribe all channels that were active before a disconnect. + * Called when WebSocket transitions to CONNECTED. + */ + async #resubscribeActiveChannels(): Promise { + const releaseLock = await this.#mutex.acquire(); + try { + const channelCount = this.#channels.size; + log('OHLCV-WS: Resubscribing active channels after reconnect', { + count: channelCount, + }); + + for (const [channel, entry] of this.#channels.entries()) { + if (entry.refCount === 0) { + continue; + } + + try { + if ( + this.#messenger.call( + 'BackendWebSocketService:channelHasSubscription', + channel, + ) + ) { + log( + 'OHLCV-WS: Channel already subscribed on server, skipping resubscribe', + { + channel, + }, + ); + continue; + } + + await this.#messenger.call('BackendWebSocketService:subscribe', { + channels: [channel], + channelType: SUBSCRIPTION_NAMESPACE, + callback: (notification: ServerNotificationMessage) => { + this.#handleBarUpdate(channel, notification); + }, + }); + log('OHLCV-WS: Resubscription succeeded', { channel }); + } catch (error) { + log('OHLCV-WS: Resubscription failed for channel', { + channel, + error, + }); + } + } + } finally { + releaseLock(); + } + } + + // ============================================================================= + // Private — Message Handlers + // ============================================================================= + + #handleBarUpdate( + channel: string, + notification: ServerNotificationMessage, + ): void { + const bar = notification.data as OHLCVBar; + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#trace( + { + name: `${SERVICE_NAME} Bar Update`, + data: { channel, timestamp: bar.timestamp }, + tags: { service: SERVICE_NAME }, + }, + () => { + this.#messenger.publish('OHLCVService:barUpdated', { channel, bar }); + }, + ); + } + + #handleSystemNotification(notification: ServerNotificationMessage): void { + const data = notification.data as OHLCVSystemNotificationData; + const { timestamp } = notification; + + if (!data.chainIds || !Array.isArray(data.chainIds) || !data.status) { + throw new Error( + 'Invalid system notification data: missing chainIds or status', + ); + } + + if (data.status === 'up') { + for (const chainId of data.chainIds) { + this.#chainsUp.add(chainId); + } + } else { + for (const chainId of data.chainIds) { + this.#chainsUp.delete(chainId); + } + } + + this.#messenger.publish('OHLCVService:chainStatusChanged', { + chainIds: data.chainIds, + status: data.status, + timestamp, + }); + + log(`OHLCV-WS: Chain status change: ${data.status}`, { + chains: data.chainIds, + status: data.status, + }); + } + + async #handleWebSocketStateChange( + connectionInfo: WebSocketConnectionInfo, + ): Promise { + const { state } = connectionInfo; + + if (state === WebSocketState.CONNECTED) { + await this.#resubscribeActiveChannels(); + } else if (state === WebSocketState.DISCONNECTED) { + const chainsToMarkDown = Array.from(this.#chainsUp); + + if (chainsToMarkDown.length > 0) { + this.#messenger.publish('OHLCVService:chainStatusChanged', { + chainIds: chainsToMarkDown, + status: 'down', + timestamp: Date.now(), + }); + + log( + 'OHLCV-WS: WebSocket disconnection — marked tracked chains as down', + { + count: chainsToMarkDown.length, + chains: chainsToMarkDown, + }, + ); + + this.#chainsUp.clear(); + } + } + } + + // ============================================================================= + // Private — Utility + // ============================================================================= + + #buildChannel(options: OHLCVSubscriptionOptions): string { + return `${SUBSCRIPTION_NAMESPACE}.${options.assetId}.${options.interval}.${options.currency}`; + } + + // ============================================================================= + // Public — Cleanup + // ============================================================================= + + /** + * Destroy the service and clean up all resources. + */ + destroy(): void { + for (const entry of this.#channels.values()) { + if (entry.gracePeriodTimer) { + clearTimeout(entry.gracePeriodTimer); + } + } + this.#channels.clear(); + this.#chainsUp.clear(); + + this.#messenger.call( + 'BackendWebSocketService:removeChannelCallback', + SYSTEM_NOTIFICATIONS_CHANNEL, + ); + } +} diff --git a/packages/core-backend/src/ws/ohlcv/index.ts b/packages/core-backend/src/ws/ohlcv/index.ts new file mode 100644 index 0000000000..e40d24d8c3 --- /dev/null +++ b/packages/core-backend/src/ws/ohlcv/index.ts @@ -0,0 +1,18 @@ +export { OHLCVService } from './OHLCVService'; +export { + OHLCV_SERVICE_ALLOWED_ACTIONS, + OHLCV_SERVICE_ALLOWED_EVENTS, +} from './OHLCVService'; +export type { + OHLCVSystemNotificationData, + OHLCVServiceOptions, + OHLCVServiceActions, + AllowedActions as OHLCVServiceAllowedActions, + OHLCVServiceBarUpdatedEvent, + OHLCVServiceChainStatusChangedEvent, + OHLCVServiceSubscriptionErrorEvent, + OHLCVServiceEvents, + AllowedEvents as OHLCVServiceAllowedEvents, + OHLCVServiceMessenger, +} from './OHLCVService'; +export type { OHLCVBar, OHLCVSubscriptionOptions } from './types'; diff --git a/packages/core-backend/src/ws/ohlcv/types.ts b/packages/core-backend/src/ws/ohlcv/types.ts new file mode 100644 index 0000000000..b8e64caf33 --- /dev/null +++ b/packages/core-backend/src/ws/ohlcv/types.ts @@ -0,0 +1,33 @@ +/** + * OHLCV WebSocket streaming types for real-time candlestick data. + */ + +/** + * A single OHLCV candlestick bar received from the market-data WebSocket stream. + */ +export type OHLCVBar = { + /** Unix timestamp (seconds) of the candle open */ + timestamp: number; + /** Opening price */ + open: number; + /** Highest price during the candle period */ + high: number; + /** Lowest price during the candle period */ + low: number; + /** Closing price (latest) */ + close: number; + /** Trading volume during the candle period */ + volume: number; +}; + +/** + * Options for subscribing to an OHLCV channel. + */ +export type OHLCVSubscriptionOptions = { + /** CAIP-19 asset identifier, e.g. "eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" */ + assetId: string; + /** Candle interval, e.g. "1m", "5m", "15m", "1h", "4h", "1d" */ + interval: string; + /** Fiat currency code, e.g. "usd", "eur" */ + currency: string; +}; diff --git a/packages/earn-controller/CHANGELOG.md b/packages/earn-controller/CHANGELOG.md index 0dd920a0e1..0079a05a77 100644 --- a/packages/earn-controller/CHANGELOG.md +++ b/packages/earn-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/account-tree-controller` from `^7.3.0` to `^7.4.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) +- Bump `@metamask/transaction-controller` from `^65.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796)) + ## [12.1.2] ### Changed diff --git a/packages/earn-controller/package.json b/packages/earn-controller/package.json index ebce8d3af3..4722d0ae41 100644 --- a/packages/earn-controller/package.json +++ b/packages/earn-controller/package.json @@ -55,7 +55,7 @@ "dependencies": { "@ethersproject/bignumber": "^5.7.0", "@ethersproject/providers": "^5.7.0", - "@metamask/account-tree-controller": "^7.3.0", + "@metamask/account-tree-controller": "^7.4.0", "@metamask/base-controller": "^9.1.0", "@metamask/controller-utils": "^12.1.0", "@metamask/keyring-api": "^23.1.0", @@ -66,7 +66,7 @@ }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", - "@metamask/transaction-controller": "^65.3.0", + "@metamask/transaction-controller": "^65.4.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", diff --git a/packages/eip-5792-middleware/CHANGELOG.md b/packages/eip-5792-middleware/CHANGELOG.md index 5d434d47d1..8f90cd1513 100644 --- a/packages/eip-5792-middleware/CHANGELOG.md +++ b/packages/eip-5792-middleware/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/keyring-controller` from `^25.3.0` to `^25.4.0` ([#8665](https://github.com/MetaMask/core/pull/8665)) - Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) -- Bump `@metamask/transaction-controller` from `^64.0.0` to `^65.3.0` ([#8432](https://github.com/MetaMask/core/pull/8432), [#8447](https://github.com/MetaMask/core/pull/8447), [#8482](https://github.com/MetaMask/core/pull/8482), [#8585](https://github.com/MetaMask/core/pull/8585), [#8613](https://github.com/MetaMask/core/pull/8613), [#8691](https://github.com/MetaMask/core/pull/8691), [#8722](https://github.com/MetaMask/core/pull/8722), [#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/transaction-controller` from `^64.0.0` to `^65.4.0` ([#8432](https://github.com/MetaMask/core/pull/8432), [#8447](https://github.com/MetaMask/core/pull/8447), [#8482](https://github.com/MetaMask/core/pull/8482), [#8585](https://github.com/MetaMask/core/pull/8585), [#8613](https://github.com/MetaMask/core/pull/8613), [#8691](https://github.com/MetaMask/core/pull/8691), [#8722](https://github.com/MetaMask/core/pull/8722), [#8755](https://github.com/MetaMask/core/pull/8755), [#8796](https://github.com/MetaMask/core/pull/8796)) ## [3.0.3] diff --git a/packages/eip-5792-middleware/package.json b/packages/eip-5792-middleware/package.json index 9a4479af6f..b1e707202d 100644 --- a/packages/eip-5792-middleware/package.json +++ b/packages/eip-5792-middleware/package.json @@ -53,7 +53,7 @@ "dependencies": { "@metamask/messenger": "^1.2.0", "@metamask/superstruct": "^3.1.0", - "@metamask/transaction-controller": "^65.3.0", + "@metamask/transaction-controller": "^65.4.0", "@metamask/utils": "^11.9.0", "lodash": "^4.17.21", "uuid": "^8.3.2" diff --git a/packages/gator-permissions-controller/CHANGELOG.md b/packages/gator-permissions-controller/CHANGELOG.md index f5d56588b3..6149c30db9 100644 --- a/packages/gator-permissions-controller/CHANGELOG.md +++ b/packages/gator-permissions-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/transaction-controller` from `^65.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796)) + ## [4.1.2] ### Changed diff --git a/packages/gator-permissions-controller/package.json b/packages/gator-permissions-controller/package.json index e55b53610f..4958f2fe27 100644 --- a/packages/gator-permissions-controller/package.json +++ b/packages/gator-permissions-controller/package.json @@ -63,7 +63,7 @@ "@metamask/snaps-controllers": "^19.0.0", "@metamask/snaps-sdk": "^11.0.0", "@metamask/snaps-utils": "^12.1.2", - "@metamask/transaction-controller": "^65.3.0", + "@metamask/transaction-controller": "^65.4.0", "@metamask/utils": "^11.9.0" }, "devDependencies": { diff --git a/packages/multichain-account-service/CHANGELOG.md b/packages/multichain-account-service/CHANGELOG.md index d6cef628ae..27129ce778 100644 --- a/packages/multichain-account-service/CHANGELOG.md +++ b/packages/multichain-account-service/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [10.0.0] + ### Changed - **BREAKING:** The service messenger now requires the `SnapAccountService:ensureReady` action to be declared ([#8715](https://github.com/MetaMask/core/pull/8715)) @@ -18,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The service messenger no longer needs `SnapController:getState`, `SnapController:stateChange` or `KeyringController:stateChange`. - **BREAKING:** Rename `SnapAccountProvider.ensureCanUseSnapPlatform()` to `ensureReady()` ([#8715](https://github.com/MetaMask/core/pull/8715)) - Bump `@metamask/accounts-controller` from `^38.0.0` to `^38.1.1` ([#8755](https://github.com/MetaMask/core/pull/8755), [#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/snap-account-service` from `^0.0.0` to `^0.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) ## [9.0.0] @@ -482,7 +485,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `MultichainAccountService` ([#6141](https://github.com/MetaMask/core/pull/6141), [#6165](https://github.com/MetaMask/core/pull/6165)) - This service manages multichain accounts/wallets. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@9.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@10.0.0...HEAD +[10.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@9.0.0...@metamask/multichain-account-service@10.0.0 [9.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@8.0.1...@metamask/multichain-account-service@9.0.0 [8.0.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@8.0.0...@metamask/multichain-account-service@8.0.1 [8.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@7.1.0...@metamask/multichain-account-service@8.0.0 diff --git a/packages/multichain-account-service/package.json b/packages/multichain-account-service/package.json index 7c5d024f77..723502301f 100644 --- a/packages/multichain-account-service/package.json +++ b/packages/multichain-account-service/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/multichain-account-service", - "version": "9.0.0", + "version": "10.0.0", "description": "Service to manage multichain accounts", "keywords": [ "Ethereum", @@ -64,7 +64,7 @@ "@metamask/keyring-snap-client": "^9.0.2", "@metamask/keyring-utils": "^3.2.1", "@metamask/messenger": "^1.2.0", - "@metamask/snap-account-service": "^0.0.0", + "@metamask/snap-account-service": "^0.1.0", "@metamask/snaps-controllers": "^19.0.0", "@metamask/snaps-sdk": "^11.0.0", "@metamask/snaps-utils": "^12.1.2", diff --git a/packages/network-enablement-controller/CHANGELOG.md b/packages/network-enablement-controller/CHANGELOG.md index 9268a902ae..0e4605c789 100644 --- a/packages/network-enablement-controller/CHANGELOG.md +++ b/packages/network-enablement-controller/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add Monad mainnet (`0x8f`, chain ID 143) to the default enabled network map for new users ([#8743](https://github.com/MetaMask/core/pull/8743)) - Bump `@metamask/network-controller` from `^31.0.0` to `^32.0.0` ([#8765](https://github.com/MetaMask/core/pull/8765), [#8774](https://github.com/MetaMask/core/pull/8774)) - Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/transaction-controller` from `^65.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796)) ## [5.1.1] diff --git a/packages/network-enablement-controller/package.json b/packages/network-enablement-controller/package.json index 2990af15be..4fd4fcd031 100644 --- a/packages/network-enablement-controller/package.json +++ b/packages/network-enablement-controller/package.json @@ -60,7 +60,7 @@ "@metamask/multichain-network-controller": "^3.1.1", "@metamask/network-controller": "^32.0.0", "@metamask/slip44": "^4.3.0", - "@metamask/transaction-controller": "^65.3.0", + "@metamask/transaction-controller": "^65.4.0", "@metamask/utils": "^11.9.0", "reselect": "^5.1.1" }, diff --git a/packages/notification-services-controller/CHANGELOG.md b/packages/notification-services-controller/CHANGELOG.md index 6af7aa9410..8e3ce5ae25 100644 --- a/packages/notification-services-controller/CHANGELOG.md +++ b/packages/notification-services-controller/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/profile-sync-controller` from `^28.0.2` to `^28.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) ## [23.1.1] diff --git a/packages/notification-services-controller/package.json b/packages/notification-services-controller/package.json index ee73118105..ccd44352c4 100644 --- a/packages/notification-services-controller/package.json +++ b/packages/notification-services-controller/package.json @@ -110,7 +110,7 @@ "@metamask/controller-utils": "^12.1.0", "@metamask/keyring-controller": "^25.5.0", "@metamask/messenger": "^1.2.0", - "@metamask/profile-sync-controller": "^28.0.2", + "@metamask/profile-sync-controller": "^28.1.0", "@metamask/utils": "^11.9.0", "bignumber.js": "^9.1.2", "firebase": "^11.2.0", diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 2605d219fd..400ad2bcfa 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/transaction-controller` from `^65.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796)) ## [6.0.1] diff --git a/packages/perps-controller/package.json b/packages/perps-controller/package.json index 76dd2e996b..82fa67c4db 100644 --- a/packages/perps-controller/package.json +++ b/packages/perps-controller/package.json @@ -67,15 +67,15 @@ "uuid": "^8.3.2" }, "devDependencies": { - "@metamask/account-tree-controller": "^7.3.0", + "@metamask/account-tree-controller": "^7.4.0", "@metamask/auto-changelog": "^6.1.0", "@metamask/geolocation-controller": "^0.1.3", "@metamask/keyring-controller": "^25.5.0", "@metamask/keyring-internal-api": "^11.0.1", "@metamask/network-controller": "^32.0.0", - "@metamask/profile-sync-controller": "^28.0.2", + "@metamask/profile-sync-controller": "^28.1.0", "@metamask/remote-feature-flag-controller": "^4.2.1", - "@metamask/transaction-controller": "^65.3.0", + "@metamask/transaction-controller": "^65.4.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^29.5.14", "@types/uuid": "^8.3.0", diff --git a/packages/phishing-controller/CHANGELOG.md b/packages/phishing-controller/CHANGELOG.md index e7041f37dd..dcc64775a6 100644 --- a/packages/phishing-controller/CHANGELOG.md +++ b/packages/phishing-controller/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/transaction-controller` from `^65.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796)) ## [17.1.2] diff --git a/packages/phishing-controller/package.json b/packages/phishing-controller/package.json index 62727c5dab..00caa198c3 100644 --- a/packages/phishing-controller/package.json +++ b/packages/phishing-controller/package.json @@ -56,7 +56,7 @@ "@metamask/base-controller": "^9.1.0", "@metamask/controller-utils": "^12.1.0", "@metamask/messenger": "^1.2.0", - "@metamask/transaction-controller": "^65.3.0", + "@metamask/transaction-controller": "^65.4.0", "@noble/hashes": "^1.8.0", "@types/punycode": "^2.1.0", "ethereum-cryptography": "^2.1.2", diff --git a/packages/profile-metrics-controller/CHANGELOG.md b/packages/profile-metrics-controller/CHANGELOG.md index 9a5a6b88a1..0f9f4bb4d1 100644 --- a/packages/profile-metrics-controller/CHANGELOG.md +++ b/packages/profile-metrics-controller/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/accounts-controller` from `^38.1.0` to `^38.1.1` ([#8774](https://github.com/MetaMask/core/pull/8774)) - Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/profile-sync-controller` from `^28.0.2` to `^28.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) +- Bump `@metamask/transaction-controller` from `^65.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796)) ## [3.1.4] diff --git a/packages/profile-metrics-controller/package.json b/packages/profile-metrics-controller/package.json index 4c5eac6cb4..45cefe2d26 100644 --- a/packages/profile-metrics-controller/package.json +++ b/packages/profile-metrics-controller/package.json @@ -59,8 +59,8 @@ "@metamask/keyring-controller": "^25.5.0", "@metamask/messenger": "^1.2.0", "@metamask/polling-controller": "^16.0.5", - "@metamask/profile-sync-controller": "^28.0.2", - "@metamask/transaction-controller": "^65.3.0", + "@metamask/profile-sync-controller": "^28.1.0", + "@metamask/transaction-controller": "^65.4.0", "@metamask/utils": "^11.9.0", "async-mutex": "^0.5.0" }, diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index 20b6815533..6eda7d7936 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [28.1.0] + ### Added - Add SRP profile pairing support (Accounts ADR 0006) ([#8504](https://github.com/MetaMask/core/pull/8504), [#8642](https://github.com/MetaMask/core/pull/8642)) @@ -865,7 +867,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/profile-sync-controller@28.0.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/profile-sync-controller@28.1.0...HEAD +[28.1.0]: https://github.com/MetaMask/core/compare/@metamask/profile-sync-controller@28.0.2...@metamask/profile-sync-controller@28.1.0 [28.0.2]: https://github.com/MetaMask/core/compare/@metamask/profile-sync-controller@28.0.1...@metamask/profile-sync-controller@28.0.2 [28.0.1]: https://github.com/MetaMask/core/compare/@metamask/profile-sync-controller@28.0.0...@metamask/profile-sync-controller@28.0.1 [28.0.0]: https://github.com/MetaMask/core/compare/@metamask/profile-sync-controller@27.1.0...@metamask/profile-sync-controller@28.0.0 diff --git a/packages/profile-sync-controller/package.json b/packages/profile-sync-controller/package.json index f695c0e145..fdf4da9086 100644 --- a/packages/profile-sync-controller/package.json +++ b/packages/profile-sync-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/profile-sync-controller", - "version": "28.0.2", + "version": "28.1.0", "description": "The profile sync helps developers synchronize data across multiple clients and devices in a privacy-preserving way. All data saved in the user storage database is encrypted client-side to preserve privacy. The user storage provides a modular design, giving developers the flexibility to construct and manage their storage spaces in a way that best suits their needs", "keywords": [ "Ethereum", diff --git a/packages/shield-controller/CHANGELOG.md b/packages/shield-controller/CHANGELOG.md index f54289d54e..f877851da9 100644 --- a/packages/shield-controller/CHANGELOG.md +++ b/packages/shield-controller/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/signature-controller` from `^39.2.1` to `^39.2.2` ([#8774](https://github.com/MetaMask/core/pull/8774)) - Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/transaction-controller` from `^65.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796)) ## [5.1.2] diff --git a/packages/shield-controller/package.json b/packages/shield-controller/package.json index e1a3982bb4..98f3700cb3 100644 --- a/packages/shield-controller/package.json +++ b/packages/shield-controller/package.json @@ -57,7 +57,7 @@ "@metamask/controller-utils": "^12.1.0", "@metamask/messenger": "^1.2.0", "@metamask/signature-controller": "^39.2.2", - "@metamask/transaction-controller": "^65.3.0", + "@metamask/transaction-controller": "^65.4.0", "@metamask/utils": "^11.9.0", "cockatiel": "^3.1.2" }, diff --git a/packages/snap-account-service/CHANGELOG.md b/packages/snap-account-service/CHANGELOG.md index 2d89203d8f..4f7d12cebc 100644 --- a/packages/snap-account-service/CHANGELOG.md +++ b/packages/snap-account-service/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.0] + ### Added - Add `SnapAccountService` ([#8414](https://github.com/MetaMask/core/pull/8414)) @@ -36,5 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/account-tree-controller` from `^7.3.0` to `^7.4.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) -[Unreleased]: https://github.com/MetaMask/core/ +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/snap-account-service@0.1.0...HEAD +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/snap-account-service@0.1.0 diff --git a/packages/snap-account-service/package.json b/packages/snap-account-service/package.json index a566d7e575..8ecbc9933f 100644 --- a/packages/snap-account-service/package.json +++ b/packages/snap-account-service/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/snap-account-service", - "version": "0.0.0", + "version": "0.1.0", "description": "Service for Account Management Snaps", "keywords": [ "Ethereum", @@ -54,7 +54,7 @@ }, "dependencies": { "@metamask/account-api": "^1.0.4", - "@metamask/account-tree-controller": "^7.3.0", + "@metamask/account-tree-controller": "^7.4.0", "@metamask/eth-snap-keyring": "^22.0.1", "@metamask/keyring-controller": "^25.5.0", "@metamask/messenger": "^1.2.0", diff --git a/packages/social-controllers/CHANGELOG.md b/packages/social-controllers/CHANGELOG.md index 136a6cb94b..812fe69e53 100644 --- a/packages/social-controllers/CHANGELOG.md +++ b/packages/social-controllers/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/profile-sync-controller` from `^28.0.2` to `^28.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) ## [2.2.1] diff --git a/packages/social-controllers/package.json b/packages/social-controllers/package.json index 17df8bf5d8..f0d194c423 100644 --- a/packages/social-controllers/package.json +++ b/packages/social-controllers/package.json @@ -57,7 +57,7 @@ "@metamask/base-data-service": "^0.1.2", "@metamask/controller-utils": "^12.1.0", "@metamask/messenger": "^1.2.0", - "@metamask/profile-sync-controller": "^28.0.2", + "@metamask/profile-sync-controller": "^28.1.0", "@metamask/superstruct": "^3.1.0" }, "devDependencies": { diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index 603776d04c..12766fa292 100644 --- a/packages/subscription-controller/CHANGELOG.md +++ b/packages/subscription-controller/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/profile-sync-controller` from `^28.0.2` to `^28.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) +- Bump `@metamask/transaction-controller` from `^65.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796)) ## [6.1.3] diff --git a/packages/subscription-controller/package.json b/packages/subscription-controller/package.json index 2b4c81643d..1ae4ea99e0 100644 --- a/packages/subscription-controller/package.json +++ b/packages/subscription-controller/package.json @@ -57,8 +57,8 @@ "@metamask/controller-utils": "^12.1.0", "@metamask/messenger": "^1.2.0", "@metamask/polling-controller": "^16.0.5", - "@metamask/profile-sync-controller": "^28.0.2", - "@metamask/transaction-controller": "^65.3.0", + "@metamask/profile-sync-controller": "^28.1.0", + "@metamask/transaction-controller": "^65.4.0", "@metamask/utils": "^11.9.0", "bignumber.js": "^9.1.2" }, diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 265b87eb90..50b409eb64 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [65.4.0] + ### Added - Add optional `fiat` object (with `orderId` and `provider` properties) to `MetamaskPayMetadata` type for persisting fiat on-ramp order data on transactions ([#8694](https://github.com/MetaMask/core/pull/8694)) @@ -2429,7 +2431,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@65.3.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@65.4.0...HEAD +[65.4.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@65.3.0...@metamask/transaction-controller@65.4.0 [65.3.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@65.2.0...@metamask/transaction-controller@65.3.0 [65.2.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@65.1.0...@metamask/transaction-controller@65.2.0 [65.1.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@65.0.0...@metamask/transaction-controller@65.1.0 diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index 9e74edb8f4..87a81c9bd1 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/transaction-controller", - "version": "65.3.0", + "version": "65.4.0", "description": "Stores transactions alongside their periodically updated statuses and manages interactions such as approval and cancellation", "keywords": [ "Ethereum", diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index fbe8309fb6..699fcd6d41 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,10 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [22.4.0] + +### Added + +- Add Across quote support for post-quote Predict withdraw flows ([#8760](https://github.com/MetaMask/core/pull/8760)) + ### Changed - Derive fiat order source amount from on-chain transaction data (`order.txHash`) with fallback to `order.cryptoAmount` ([#8694](https://github.com/MetaMask/core/pull/8694)) - Persist fiat order ID and provider code on `transaction.metamaskPay` before polling, so activity views can query order status after controller state cleanup ([#8694](https://github.com/MetaMask/core/pull/8694)) +- Bump `@metamask/assets-controller` from `^7.1.1` to `^7.1.2` ([#8783](https://github.com/MetaMask/core/pull/8783)) +- Bump `@metamask/assets-controllers` from `^108.0.0` to `^108.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) +- Bump `@metamask/transaction-controller` from `^65.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796)) + +### Fixed + +- For postquote payments payment token for MM Pay transaction should not be reset when accountOverride is changed. ([#8787](https://github.com/MetaMask/core/pull/8787)) ## [22.3.1] @@ -872,7 +885,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.3.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.4.0...HEAD +[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 [22.2.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.1.0...@metamask/transaction-pay-controller@22.2.0 diff --git a/packages/transaction-pay-controller/package.json b/packages/transaction-pay-controller/package.json index 066e8be7f5..18355ad1d7 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.3.1", + "version": "22.4.0", "description": "Manages alternate payment strategies to provide required funds for transactions in MetaMask", "keywords": [ "Ethereum", @@ -57,8 +57,8 @@ "@ethersproject/abi": "^5.7.0", "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", - "@metamask/assets-controller": "^7.1.1", - "@metamask/assets-controllers": "^108.0.0", + "@metamask/assets-controller": "^7.1.2", + "@metamask/assets-controllers": "^108.1.0", "@metamask/base-controller": "^9.1.0", "@metamask/bridge-controller": "^72.0.4", "@metamask/bridge-status-controller": "^71.1.4", @@ -69,7 +69,7 @@ "@metamask/network-controller": "^32.0.0", "@metamask/ramps-controller": "^13.3.1", "@metamask/remote-feature-flag-controller": "^4.2.1", - "@metamask/transaction-controller": "^65.3.0", + "@metamask/transaction-controller": "^65.4.0", "@metamask/utils": "^11.9.0", "bignumber.js": "^9.1.2", "bn.js": "^5.2.1", diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 04d209a576..f86daa2642 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -372,6 +372,49 @@ describe('TransactionPayController', () => { ).toBeDefined(); }); + it('does not clear paymentToken when accountOverride changes if isPostQuote is true', () => { + const controller = createController(); + const accountOverride = + '0xdeadbeef00000000000000000000000000000002' as Hex; + + controller.setTransactionConfig(TRANSACTION_ID_MOCK, (config) => { + config.isPostQuote = true; + }); + + controller.updatePaymentToken({ + transactionId: TRANSACTION_ID_MOCK, + tokenAddress: TOKEN_ADDRESS_MOCK, + chainId: CHAIN_ID_MOCK, + }); + + const { updateTransactionData } = updatePaymentTokenMock.mock.calls[0][1]; + + updateTransactionData(TRANSACTION_ID_MOCK, (data) => { + data.paymentToken = { + address: TOKEN_ADDRESS_MOCK, + balanceFiat: '1', + balanceHuman: '1', + balanceRaw: '1', + balanceUsd: '1', + chainId: CHAIN_ID_MOCK, + decimals: 6, + symbol: 'USDC', + }; + }); + + expect( + controller.state.transactionData[TRANSACTION_ID_MOCK].paymentToken, + ).toBeDefined(); + + controller.setTransactionConfig(TRANSACTION_ID_MOCK, (config) => { + config.accountOverride = accountOverride; + }); + + expect( + controller.state.transactionData[TRANSACTION_ID_MOCK].paymentToken, + ).toBeDefined(); + }); + it('updates multiple config properties at once', () => { const controller = createController(); const refundTo = '0xdeadbeef00000000000000000000000000000001' as Hex; diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 69ba398283..f9bd0e53ae 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -144,7 +144,10 @@ export class TransactionPayController extends BaseController< transactionData.isHyperliquidSource = config.isHyperliquidSource; transactionData.refundTo = config.refundTo; - if (config.accountOverride !== previousAccountOverride) { + if ( + !config.isPostQuote && + config.accountOverride !== previousAccountOverride + ) { transactionData.paymentToken = undefined; } }); diff --git a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts index 6539a87c93..c63d7ba7a8 100644 --- a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts @@ -7,6 +7,7 @@ import type { Hex } from '@metamask/utils'; import { ARBITRUM_USDC_ADDRESS, CHAIN_ID_ARBITRUM } from '../../constants'; import type { + PayStrategyCheckQuoteSupportRequest, PayStrategyExecuteRequest, PayStrategyGetQuotesRequest, TransactionPayQuote, @@ -56,6 +57,25 @@ describe('AcrossStrategy', () => { ], } as PayStrategyGetQuotesRequest; + const quoteWithAuthorizationList = { + request: { + ...baseRequest.requests[0], + }, + original: { + metamask: { + gasLimits: [], + is7702: true, + requiresAuthorizationList: true, + }, + quote: {}, + request: { + actions: [], + amount: '100', + tradeType: 'exactInput', + }, + }, + } as TransactionPayQuote; + beforeEach(() => { jest.resetAllMocks(); getPayStrategiesConfigMock.mockReturnValue({ @@ -351,6 +371,79 @@ describe('AcrossStrategy', () => { expect(result).toBe(false); }); + it('supports source 7702 authorization lists for Predict withdraw post-quote quotes without Across actions', () => { + const strategy = new AcrossStrategy(); + const request = { + messenger, + quotes: [ + { + ...quoteWithAuthorizationList, + request: { + ...quoteWithAuthorizationList.request, + isPostQuote: true, + }, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.predictWithdraw, + } as TransactionMeta, + } as PayStrategyCheckQuoteSupportRequest; + + expect(strategy.checkQuoteSupport(request)).toBe(true); + }); + + it('does not support first-time 7702 authorization lists for non-post-quote Across quotes', () => { + const strategy = new AcrossStrategy(); + const request = { + messenger, + quotes: [quoteWithAuthorizationList], + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.predictWithdraw, + } as TransactionMeta, + } as PayStrategyCheckQuoteSupportRequest; + + expect(strategy.checkQuoteSupport(request)).toBe(false); + }); + + it('does not support first-time 7702 authorization lists when the Across quote has embedded destination actions', () => { + const strategy = new AcrossStrategy(); + const request = { + messenger, + quotes: [ + { + ...quoteWithAuthorizationList, + request: { + ...quoteWithAuthorizationList.request, + isPostQuote: true, + }, + original: { + ...quoteWithAuthorizationList.original, + request: { + ...quoteWithAuthorizationList.original.request, + actions: [ + { + args: [], + functionSignature: 'function transfer(address,uint256)', + isNativeTransfer: false, + target: '0xdef' as Hex, + value: '0', + }, + ], + }, + }, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.predictWithdraw, + } as TransactionMeta, + } as PayStrategyCheckQuoteSupportRequest; + + expect(strategy.checkQuoteSupport(request)).toBe(false); + }); + it('supports 7702 quotes that do not require an authorization list', () => { const strategy = new AcrossStrategy(); const quote = { diff --git a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts index fc141acfa0..0c942dd7d0 100644 --- a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts @@ -83,9 +83,26 @@ export class AcrossStrategy implements PayStrategy { // Gas planning can discover that TransactionController would add an // authorization list for a first-time 7702 upgrade. `is7702` alone is not a // blocker because it also covers already-upgraded accounts. - return !request.quotes.some( + const requiresAuthorizationList = request.quotes.some( (quote) => quote.original.metamask.requiresAuthorizationList, ); + + if (!requiresAuthorizationList) { + return true; + } + + if (!isPredictWithdrawTransaction(request.transaction)) { + return false; + } + + // A first-time 7702 authorization list is acceptable here only because it is + // attached to MetaMask's source-chain batch transaction. It must not be + // smuggled into Across destination post-swap actions. + return request.quotes.every( + (quote) => + quote.request.isPostQuote === true && + quote.original.request.actions.length === 0, + ); } async getQuotes( diff --git a/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts index 2a872d6611..50e56e589c 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts @@ -54,6 +54,12 @@ const TRANSACTION_META_MOCK = { from: FROM_MOCK, }, } as TransactionMeta; +const PREDICT_WITHDRAW_TRANSACTION_MOCK = { + txParams: { + from: FROM_MOCK, + }, + nestedTransactions: [{ type: TransactionType.predictWithdraw }], +} as TransactionMeta; const QUOTE_REQUEST_MOCK: QuoteRequest = { from: FROM_MOCK, @@ -329,6 +335,203 @@ describe('Across Quotes', () => { expect(params.get('amount')).toBe(QUOTE_REQUEST_MOCK.sourceTokenAmount); }); + it('uses exactInput trade type without destination actions for post-quote predict withdraws with source-chain authorization lists', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + authorizationList: [{ address: '0xabc' as Hex }], + data: '0x12345678' as Hex, + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }); + + const [url] = successfulFetchMock.mock.calls[0]; + const params = new URL(url as string).searchParams; + + expect(params.get('tradeType')).toBe('exactInput'); + expect(params.get('amount')).toBe(QUOTE_REQUEST_MOCK.sourceTokenAmount); + expect(params.get('refundAddress')).toBe(refundTo); + expect(getRequestBody().actions).toStrictEqual([]); + }); + + it('ignores invalid original transaction gas for post-quote predict withdraws', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + const result = await getAcrossQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + gas: '0x0', + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }); + + expect(result[0].original.metamask.gasLimits).toStrictEqual([ + { + estimate: 21000, + max: 21000, + }, + ]); + }); + + it('adds original transaction gas to EIP-7702 gas limits for post-quote predict withdraws', async () => { + estimateGasBatchMock.mockResolvedValue({ + gasLimits: [51000], + }); + + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + approvalTxns: [ + { + chainId: 1, + data: '0xaaaa' as Hex, + to: '0xapprove1' as Hex, + }, + ], + }), + } as Response); + + const result = await getAcrossQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + gas: '0x5208', + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }); + + expect(result[0].original.metamask.gasLimits).toStrictEqual([ + { + estimate: 72000, + max: 72000, + }, + ]); + expect(result[0].original.metamask.is7702).toBe(true); + }); + + it('preserves Across per-leg gas pricing when adding original post-quote gas fees', async () => { + estimateGasBatchMock.mockResolvedValue({ + gasLimits: [21000, 21000], + }); + + const amountByMaxFeePerGas = { + '0x2': { estimate: '20', max: '200' }, + '0x3': { estimate: '30', max: '300' }, + '0x5': { estimate: '50', max: '500' }, + }; + + calculateGasCostMock.mockImplementation(({ isMax, maxFeePerGas }) => { + const amounts = + amountByMaxFeePerGas[ + maxFeePerGas as keyof typeof amountByMaxFeePerGas + ]; + const raw = isMax ? amounts.max : amounts.estimate; + + return { + fiat: raw, + human: raw, + raw, + usd: raw, + }; + }); + + successfulFetchMock.mockResolvedValue({ + json: async () => + ({ + ...QUOTE_MOCK, + approvalTxns: [ + { + chainId: 1, + data: '0xaaaa' as Hex, + maxFeePerGas: '0x2', + maxPriorityFeePerGas: '0x1', + to: '0xapprove1' as Hex, + }, + ], + swapTx: { + ...QUOTE_MOCK.swapTx, + maxFeePerGas: '0x3', + maxPriorityFeePerGas: '0x1', + }, + }) as unknown as AcrossSwapApprovalResponse, + } as Response); + + const result = await getAcrossQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + chainId: '0x1', + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + gas: '0x5208', + maxFeePerGas: '0x5', + maxPriorityFeePerGas: '0x1', + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }); + + expect(result[0].fees.sourceNetwork.estimate.raw).toBe('100'); + expect(result[0].fees.sourceNetwork.max.raw).toBe('1000'); + expect(calculateGasCostMock).toHaveBeenCalledWith( + expect.objectContaining({ + gas: 21000, + maxFeePerGas: '0x5', + }), + ); + }); + it('re-quotes max amount quotes after reserving source token for gas fee token', async () => { const adjustedSourceAmount = '999999999999999900'; @@ -361,6 +564,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], transaction: TRANSACTION_META_MOCK, @@ -381,6 +585,261 @@ describe('Across Quotes', () => { expect(result[0].fees.isSourceGasFeeToken).toBe(true); }); + it('re-quotes post-quote predict withdraws after reserving source token for gas fee token', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + const adjustedSourceAmount = '900'; + + getTokenBalanceMock.mockReturnValue('0'); + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + + successfulFetchMock + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: '1000', + }), + } as Response) + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: adjustedSourceAmount, + }), + } as Response); + + const result = await getAcrossQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + sourceBalanceRaw: '0', + sourceTokenAmount: '1000', + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + gas: '0x5208', + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(2); + expect(getGasFeeTokensMock).toHaveBeenCalledWith( + expect.objectContaining({ + from: FROM_MOCK, + }), + ); + + const [phase1Url] = successfulFetchMock.mock.calls[0]; + const [phase2Url] = successfulFetchMock.mock.calls[1]; + expect(new URL(phase1Url as string).searchParams.get('amount')).toBe( + '1000', + ); + expect(new URL(phase2Url as string).searchParams.get('amount')).toBe( + adjustedSourceAmount, + ); + expect(result[0].sourceAmount.raw).toBe(adjustedSourceAmount); + expect(result[0].fees.isSourceGasFeeToken).toBe(true); + }); + + it('does not return unsafe post-quote predict withdraw source gas quotes when gas consumes the source amount', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + + getTokenBalanceMock.mockReturnValue('0'); + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: '100', + }), + } as Response); + + await expect( + getAcrossQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + sourceBalanceRaw: '0', + sourceTokenAmount: '100', + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + gas: '0x5208', + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }), + ).rejects.toThrow(/cannot cover source gas fee token/u); + + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + }); + + it('rejects post-quote predict withdraws when phase 2 loses gas fee token eligibility', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + const sourceFiatRate = { + fiatRate: '4.0', + usdRate: '2.0', + }; + + getTokenFiatRateMock + .mockReturnValueOnce(sourceFiatRate) + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(sourceFiatRate) + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(undefined); + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock + .mockResolvedValueOnce([GAS_FEE_TOKEN_MOCK]) + .mockResolvedValueOnce([]); + + successfulFetchMock + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: '1000', + }), + } as Response) + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: '900', + }), + } as Response); + + await expect( + getAcrossQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + sourceTokenAmount: '1000', + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + gas: '0x5208', + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }), + ).rejects.toThrow(/lost source gas fee token eligibility/u); + + expect(successfulFetchMock).toHaveBeenCalledTimes(2); + }); + + it('rejects post-quote predict withdraws when phase 2 exceeds the source amount', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + calculateGasFeeTokenCostMock + .mockReturnValueOnce({ + fiat: '0.0004', + human: '0.0001', + raw: '100', + usd: '0.0002', + }) + .mockReturnValueOnce({ + fiat: '0.000404', + human: '0.000101', + raw: '101', + usd: '0.000202', + }); + + successfulFetchMock + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: '1000', + }), + } as Response) + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: '900', + }), + } as Response); + + await expect( + getAcrossQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + sourceTokenAmount: '1000', + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + gas: '0x5208', + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }), + ).rejects.toThrow(/exceeds source amount/u); + + expect(successfulFetchMock).toHaveBeenCalledTimes(2); + }); + + it('estimates post-quote predict withdraw Across transactions from the EOA', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + targetAmountMinimum: '0', + }, + ], + transaction: PREDICT_WITHDRAW_TRANSACTION_MOCK, + }); + + expect(estimateGasMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: QUOTE_MOCK.swapTx.data, + from: FROM_MOCK, + to: QUOTE_MOCK.swapTx.to, + }), + 'mainnet', + ); + }); + it('falls back to phase 1 max amount quote when adjusted quote is not affordable', async () => { getTokenBalanceMock.mockReturnValue('0'); isEIP7702ChainMock.mockReturnValue(true); 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 8e3ddd5c22..d7ff9627bd 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts @@ -1,4 +1,5 @@ import { successfulFetch, toHex } from '@metamask/controller-utils'; +import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; @@ -26,7 +27,10 @@ import { getTokenBalance, getTokenFiatRate, } from '../../utils/token'; +import { isPredictWithdrawTransaction } from '../../utils/transaction'; +import type { AcrossDestination } from './across-actions'; import { getAcrossDestination } from './across-actions'; +import { hasUnsupportedTransactionAuthorizationList } from './authorization-list'; import { normalizeAcrossRequest } from './perps'; import { isAcrossQuoteRequest } from './requests'; import { getAcrossOrderedTransactions } from './transactions'; @@ -65,7 +69,12 @@ export async function getAcrossQuotes( return []; } - if (request.transaction.txParams?.authorizationList?.length) { + if ( + hasUnsupportedTransactionAuthorizationList( + request.transaction, + normalizedRequests, + ) + ) { throw new Error(UNSUPPORTED_AUTHORIZATION_LIST_ERROR); } @@ -104,9 +113,17 @@ async function getSingleQuote( sourceTokenAddress, ); - const amount = isMaxAmount ? sourceTokenAmount : targetAmountMinimum; - const tradeType = isMaxAmount ? 'exactInput' : 'exactOutput'; - const destination = getAcrossDestination(transaction, request); + const useExactInput = isMaxAmount + ? true + : normalizedRequest.isPostQuote === true; + const amount = useExactInput ? sourceTokenAmount : targetAmountMinimum; + const tradeType = useExactInput ? 'exactInput' : 'exactOutput'; + const destination = getAcrossDestinationForRequest( + transaction, + request, + from, + ); + const quote = await requestAcrossApproval({ actions: destination.actions, amount, @@ -118,6 +135,7 @@ async function getSingleQuote( outputToken: targetTokenAddress, recipient: destination.recipient, signal, + refundAddress: normalizedRequest.refundTo, slippage: slippageDecimal, tradeType, }); @@ -125,6 +143,7 @@ async function getSingleQuote( const originalQuote: AcrossQuoteWithoutMetaMask = { quote, request: { + actions: destination.actions, amount, tradeType, }, @@ -133,26 +152,57 @@ async function getSingleQuote( return await normalizeQuote(originalQuote, normalizedRequest, fullRequest); } +function getAcrossDestinationForRequest( + transaction: TransactionMeta, + request: QuoteRequest, + recipient: Hex, +): AcrossDestination { + if (request.isPostQuote) { + return { + actions: [], + recipient, + }; + } + + return getAcrossDestination(transaction, request); +} + async function getQuoteWithGasStationHandling( request: QuoteRequest, fullRequest: PayStrategyGetQuotesRequest, ): Promise> { + // Phase 1 uses the requested source amount to discover whether Across will + // pay source-chain gas with the source token, and what the max gas cost is. + // Phase 2 repeats the quote with that max gas cost reserved from the source + // amount, so execution can fund both the Across deposit and token-paid gas. const phase1Quote = await getSingleQuote(request, fullRequest); - if (!request.isMaxAmount || !phase1Quote.fees.isSourceGasFeeToken) { + if ( + (!request.isMaxAmount && !request.isPostQuote) || + !phase1Quote.fees.isSourceGasFeeToken + ) { return phase1Quote; } + const requiresSourceGasReservation = + request.isPostQuote === true && + isPredictWithdrawTransaction(fullRequest.transaction); + const adjustedSourceAmount = new BigNumber(request.sourceTokenAmount) .minus(phase1Quote.fees.sourceNetwork.max.raw) .integerValue(BigNumber.ROUND_DOWN); if (!adjustedSourceAmount.isGreaterThan(0)) { - log('Insufficient balance after gas subtraction for Across max quote'); + log('Insufficient balance after gas subtraction for Across quote'); + if (requiresSourceGasReservation) { + throw new Error( + 'Across Predict withdraw source amount cannot cover source gas fee token', + ); + } return phase1Quote; } - log('Subtracting gas from source for Across max quote', { + log('Subtracting gas from source for Across quote', { adjustedSourceAmount: adjustedSourceAmount.toString(10), gasCostRaw: phase1Quote.fees.sourceNetwork.max.raw, originalSourceAmount: request.sourceTokenAmount, @@ -171,7 +221,12 @@ async function getQuoteWithGasStationHandling( ); if (!phase2Quote.fees.isSourceGasFeeToken) { - log('Across max phase 2 lost gas fee token eligibility'); + log('Across phase 2 lost gas fee token eligibility'); + if (requiresSourceGasReservation) { + throw new Error( + 'Across Predict withdraw quote lost source gas fee token eligibility', + ); + } return phase1Quote; } @@ -182,17 +237,30 @@ async function getQuoteWithGasStationHandling( .plus(phase2GasCost) .isGreaterThan(request.sourceTokenAmount) ) { - log('Across max phase 2 quote exceeds original source amount', { + log('Across phase 2 quote exceeds original source amount', { adjustedSourceAmount: adjustedSourceAmount.toString(10), gasCostRaw: phase2GasCost.toString(10), originalSourceAmount: request.sourceTokenAmount, }); + if (requiresSourceGasReservation) { + throw new Error( + 'Across Predict withdraw source gas fee token quote exceeds source amount', + ); + } return phase1Quote; } return phase2Quote; } catch (error) { - log('Across max phase 2 quote failed, falling back to phase 1', { error }); + log( + requiresSourceGasReservation + ? 'Across phase 2 quote failed after source gas reservation' + : 'Across phase 2 quote failed, falling back to phase 1', + { error }, + ); + if (requiresSourceGasReservation) { + throw error; + } return phase1Quote; } } @@ -207,6 +275,7 @@ type AcrossApprovalRequest = { originChainId: Hex; outputToken: Hex; recipient: Hex; + refundAddress?: Hex; signal?: AbortSignal; slippage?: number; tradeType: 'exactInput' | 'exactOutput'; @@ -225,6 +294,7 @@ async function requestAcrossApproval( originChainId, outputToken, recipient, + refundAddress, signal, slippage, tradeType, @@ -240,6 +310,10 @@ async function requestAcrossApproval( params.set('depositor', depositor); params.set('recipient', recipient); + if (refundAddress !== undefined) { + params.set('refundAddress', refundAddress); + } + if (slippage !== undefined) { params.set('slippage', String(slippage)); } @@ -255,6 +329,7 @@ async function requestAcrossApproval( method: 'POST', signal, }; + const response = await successfulFetch(url, options); return (await response.json()) as AcrossSwapApprovalResponse; @@ -282,7 +357,12 @@ async function normalizeQuote( isGasFeeToken: isSourceGasFeeToken, requiresAuthorizationList, sourceNetwork, - } = await calculateSourceNetworkCost(quote, messenger, request); + } = await calculateSourceNetworkCost( + quote, + messenger, + request, + fullRequest.transaction, + ); const targetNetwork = getFiatValueFromUsd(new BigNumber(0), usdToFiatRate); @@ -466,6 +546,7 @@ async function calculateSourceNetworkCost( quote: AcrossSwapApprovalResponse, messenger: TransactionPayControllerMessenger, request: QuoteRequest, + transaction: TransactionMeta, ): Promise<{ sourceNetwork: TransactionPayQuote['fees']['sourceNetwork']; gasLimits: AcrossGasLimits; @@ -482,15 +563,16 @@ async function calculateSourceNetworkCost( const gasEstimates = await estimateQuoteGasLimits({ fallbackGas: acrossFallbackGas, messenger, - transactions: orderedTransactions.map((transaction) => ({ - chainId: toHex(transaction.chainId), - data: transaction.data, + transactions: orderedTransactions.map((orderedTransaction) => ({ + chainId: toHex(orderedTransaction.chainId), + data: orderedTransaction.data, from, - gas: transaction.gas, - to: transaction.to, - value: transaction.value ?? '0x0', + gas: orderedTransaction.gas, + to: orderedTransaction.to, + value: orderedTransaction.value ?? '0x0', })), }); + const { batchGasLimit, is7702, requiresAuthorizationList, totalGasEstimate } = gasEstimates; @@ -530,32 +612,32 @@ async function calculateSourceNetworkCost( ]; } else { const transactionGasLimits = orderedTransactions.map( - (transaction, index) => ({ + (orderedTransaction, index) => ({ gasEstimate: gasEstimates.gasLimits[index], - transaction, + orderedTransaction, }), ); const estimate = sumAmounts( - transactionGasLimits.map(({ gasEstimate, transaction }) => + transactionGasLimits.map(({ gasEstimate, orderedTransaction }) => calculateGasCost({ - chainId: toHex(transaction.chainId), + chainId: toHex(orderedTransaction.chainId), gas: gasEstimate.estimate, - maxFeePerGas: transaction.maxFeePerGas, - maxPriorityFeePerGas: transaction.maxPriorityFeePerGas, + maxFeePerGas: orderedTransaction.maxFeePerGas, + maxPriorityFeePerGas: orderedTransaction.maxPriorityFeePerGas, messenger, }), ), ); const max = sumAmounts( - transactionGasLimits.map(({ gasEstimate, transaction }) => + transactionGasLimits.map(({ gasEstimate, orderedTransaction }) => calculateGasCost({ - chainId: toHex(transaction.chainId), + chainId: toHex(orderedTransaction.chainId), gas: gasEstimate.max, isMax: true, - maxFeePerGas: transaction.maxFeePerGas, - maxPriorityFeePerGas: transaction.maxPriorityFeePerGas, + maxFeePerGas: orderedTransaction.maxFeePerGas, + maxPriorityFeePerGas: orderedTransaction.maxPriorityFeePerGas, messenger, }), ), @@ -576,19 +658,26 @@ async function calculateSourceNetworkCost( is7702, ...(requiresAuthorizationList ? { requiresAuthorizationList } : {}), gasLimits, + totalGasEstimate, + totalGasLimit: gasEstimates.totalGasLimit, }; + const finalResult = request.isPostQuote + ? combinePostQuoteGas(result, transaction, swapTx, messenger) + : result; + const nativeBalance = getTokenBalance( messenger, from, sourceChainId, getNativeToken(sourceChainId), ); + const hasNativeBalance = new BigNumber(nativeBalance).isGreaterThanOrEqualTo( + finalResult.sourceNetwork.max.raw, + ); - if ( - new BigNumber(nativeBalance).isGreaterThanOrEqualTo(sourceNetwork.max.raw) - ) { - return result; + if (hasNativeBalance) { + return finalResult; } const gasStationEligibility = getGasStationEligibility( @@ -598,14 +687,14 @@ async function calculateSourceNetworkCost( if (gasStationEligibility.isDisabledChain) { log('Skipping Across gas station as disabled chain', { sourceChainId }); - return result; + return finalResult; } if (!gasStationEligibility.chainSupportsGasStation) { log('Skipping Across gas station as chain does not support EIP-7702', { sourceChainId, }); - return result; + return finalResult; } const firstTransaction = orderedTransactions[0]; @@ -622,12 +711,15 @@ async function calculateSourceNetworkCost( sourceChainId, sourceTokenAddress, }, - totalGasEstimate, - totalItemCount: Math.max(orderedTransactions.length, gasLimits.length), + totalGasEstimate: finalResult.totalGasEstimate, + totalItemCount: Math.max( + orderedTransactions.length + (request.isPostQuote ? 1 : 0), + finalResult.gasLimits.length, + ), }); if (!gasFeeTokenCost) { - return result; + return finalResult; } log('Using gas fee token for Across source network', { @@ -640,8 +732,125 @@ async function calculateSourceNetworkCost( estimate: gasFeeTokenCost, max: gasFeeTokenCost, }, - is7702, + is7702: finalResult.is7702, ...(requiresAuthorizationList ? { requiresAuthorizationList } : {}), + gasLimits: finalResult.gasLimits, + }; +} + +function combinePostQuoteGas( + gasResult: { + sourceNetwork: TransactionPayQuote['fees']['sourceNetwork']; + gasLimits: AcrossGasLimits; + is7702: boolean; + requiresAuthorizationList?: true; + totalGasEstimate: number; + totalGasLimit: number; + }, + transaction: TransactionMeta, + swapTx: AcrossSwapApprovalResponse['swapTx'], + messenger: TransactionPayControllerMessenger, +): typeof gasResult { + const originalTxGas = getOriginalTransactionGas(transaction); + + if (originalTxGas === undefined) { + return gasResult; + } + + const gasLimits = gasResult.is7702 + ? [ + { + estimate: gasResult.gasLimits[0].estimate + originalTxGas, + max: gasResult.gasLimits[0].max + originalTxGas, + }, + ] + : [ + { + estimate: originalTxGas, + max: originalTxGas, + }, + ...gasResult.gasLimits, + ]; + + const totalGasEstimate = gasResult.totalGasEstimate + originalTxGas; + const totalGasLimit = gasResult.totalGasLimit + originalTxGas; + const originalSourceNetwork = calculateOriginalSourceNetworkCost({ + gas: originalTxGas, + messenger, + swapTx, + transaction, + }); + + return { + ...gasResult, + sourceNetwork: { + estimate: sumAmounts([ + gasResult.sourceNetwork.estimate, + originalSourceNetwork.estimate, + ]), + max: sumAmounts([gasResult.sourceNetwork.max, originalSourceNetwork.max]), + }, gasLimits, + totalGasEstimate, + totalGasLimit, + }; +} + +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, + swapTx, + transaction, +}: { + gas: number; + messenger: TransactionPayControllerMessenger; + swapTx: AcrossSwapApprovalResponse['swapTx']; + transaction: TransactionMeta; +}): TransactionPayQuote['fees']['sourceNetwork'] { + const originalTransactionWithGas = transaction.nestedTransactions?.find( + (tx) => tx.gas, + ); + const maxFeePerGas = + originalTransactionWithGas?.maxFeePerGas ?? + transaction.txParams.maxFeePerGas; + const maxPriorityFeePerGas = + originalTransactionWithGas?.maxPriorityFeePerGas ?? + transaction.txParams.maxPriorityFeePerGas; + + return { + estimate: calculateGasCost({ + chainId: transaction.chainId ?? toHex(swapTx.chainId), + gas, + maxFeePerGas, + maxPriorityFeePerGas, + messenger, + }), + max: calculateGasCost({ + chainId: transaction.chainId ?? toHex(swapTx.chainId), + gas, + isMax: true, + maxFeePerGas, + maxPriorityFeePerGas, + messenger, + }), }; } diff --git a/packages/transaction-pay-controller/src/strategy/across/types.ts b/packages/transaction-pay-controller/src/strategy/across/types.ts index af0f66a3f6..9e11895633 100644 --- a/packages/transaction-pay-controller/src/strategy/across/types.ts +++ b/packages/transaction-pay-controller/src/strategy/across/types.ts @@ -91,6 +91,7 @@ export type AcrossQuote = { }; quote: AcrossSwapApprovalResponse; request: { + actions: AcrossAction[]; amount: string; tradeType: 'exactOutput' | 'exactInput'; }; diff --git a/packages/user-operation-controller/CHANGELOG.md b/packages/user-operation-controller/CHANGELOG.md index 22190972c7..69409e9976 100644 --- a/packages/user-operation-controller/CHANGELOG.md +++ b/packages/user-operation-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/transaction-controller` from `^65.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796)) + ## [41.2.2] ### Changed diff --git a/packages/user-operation-controller/package.json b/packages/user-operation-controller/package.json index 2a94ed5d9f..a8e4d6397d 100644 --- a/packages/user-operation-controller/package.json +++ b/packages/user-operation-controller/package.json @@ -65,7 +65,7 @@ "@metamask/polling-controller": "^16.0.5", "@metamask/rpc-errors": "^7.0.2", "@metamask/superstruct": "^3.1.0", - "@metamask/transaction-controller": "^65.3.0", + "@metamask/transaction-controller": "^65.4.0", "@metamask/utils": "^11.9.0", "bn.js": "^5.2.1", "immer": "^9.0.6", diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md new file mode 100644 index 0000000000..b518709c7b --- /dev/null +++ b/packages/wallet/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/wallet/LICENSE b/packages/wallet/LICENSE new file mode 100644 index 0000000000..f9f85c6d4e --- /dev/null +++ b/packages/wallet/LICENSE @@ -0,0 +1,6 @@ +This project is licensed under either of + + * MIT license ([LICENSE.MIT](LICENSE.MIT)) + * Apache License, Version 2.0 ([LICENSE.APACHE2](LICENSE.APACHE2)) + +at your option. diff --git a/packages/wallet/LICENSE.APACHE2 b/packages/wallet/LICENSE.APACHE2 new file mode 100644 index 0000000000..18002eac9a --- /dev/null +++ b/packages/wallet/LICENSE.APACHE2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 MetaMask + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/packages/wallet/LICENSE.MIT b/packages/wallet/LICENSE.MIT new file mode 100644 index 0000000000..e027864340 --- /dev/null +++ b/packages/wallet/LICENSE.MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/wallet/README.md b/packages/wallet/README.md new file mode 100644 index 0000000000..da275a947d --- /dev/null +++ b/packages/wallet/README.md @@ -0,0 +1,15 @@ +# `@metamask/wallet` + +Provides a shared framework for building MetaMask wallets + +## Installation + +`yarn add @metamask/wallet` + +or + +`npm install @metamask/wallet` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/wallet/jest.config.js b/packages/wallet/jest.config.js new file mode 100644 index 0000000000..ca08413339 --- /dev/null +++ b/packages/wallet/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/wallet/package.json b/packages/wallet/package.json new file mode 100644 index 0000000000..e067b95767 --- /dev/null +++ b/packages/wallet/package.json @@ -0,0 +1,70 @@ +{ + "name": "@metamask/wallet", + "version": "0.0.0", + "description": "Provides a shared framework for building MetaMask wallets", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/wallet#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "(MIT OR Apache-2.0)", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/wallet", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/wallet", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --generate", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^29.5.14", + "deepmerge": "^4.2.2", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/wallet/src/index.test.ts b/packages/wallet/src/index.test.ts new file mode 100644 index 0000000000..bc062d3694 --- /dev/null +++ b/packages/wallet/src/index.test.ts @@ -0,0 +1,9 @@ +import greeter from '.'; + +describe('Test', () => { + it('greets', () => { + const name = 'Huey'; + const result = greeter(name); + expect(result).toBe('Hello, Huey!'); + }); +}); diff --git a/packages/wallet/src/index.ts b/packages/wallet/src/index.ts new file mode 100644 index 0000000000..6972c11729 --- /dev/null +++ b/packages/wallet/src/index.ts @@ -0,0 +1,9 @@ +/** + * Example function that returns a greeting for the given name. + * + * @param name - The name to greet. + * @returns The greeting. + */ +export default function greeter(name: string): string { + return `Hello, ${name}!`; +} diff --git a/packages/wallet/tsconfig.build.json b/packages/wallet/tsconfig.build.json new file mode 100644 index 0000000000..02a0eea03f --- /dev/null +++ b/packages/wallet/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [], + "include": ["../../types", "./src"] +} diff --git a/packages/wallet/tsconfig.json b/packages/wallet/tsconfig.json new file mode 100644 index 0000000000..025ba2ef7f --- /dev/null +++ b/packages/wallet/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [], + "include": ["../../types", "./src"] +} diff --git a/packages/wallet/typedoc.json b/packages/wallet/typedoc.json new file mode 100644 index 0000000000..c9da015dbf --- /dev/null +++ b/packages/wallet/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/teams.json b/teams.json index e00a3166a2..58a21a46f3 100644 --- a/teams.json +++ b/teams.json @@ -56,6 +56,7 @@ "metamask/rate-limit-controller": "team-core-platform", "metamask/react-data-query": "team-core-platform", "metamask/profile-metrics-controller": "team-core-platform", + "metamask/wallet": "team-core-platform", "metamask/passkey-controller": "team-onboarding", "metamask/seedless-onboarding-controller": "team-onboarding", "metamask/shield-controller": "team-shield", diff --git a/tsconfig.build.json b/tsconfig.build.json index eb60f52c10..1604f654f5 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -255,6 +255,9 @@ }, { "path": "./packages/user-operation-controller/tsconfig.build.json" + }, + { + "path": "./packages/wallet/tsconfig.build.json" } ], "files": [], diff --git a/tsconfig.json b/tsconfig.json index e882beebed..9ca28b560c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -244,6 +244,9 @@ }, { "path": "./packages/user-operation-controller" + }, + { + "path": "./packages/wallet" } ], "files": [], diff --git a/yarn.lock b/yarn.lock index 5cbd27294f..dfef61c543 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2530,7 +2530,7 @@ __metadata: languageName: node linkType: hard -"@metamask/account-tree-controller@npm:^7.3.0, @metamask/account-tree-controller@workspace:packages/account-tree-controller": +"@metamask/account-tree-controller@npm:^7.4.0, @metamask/account-tree-controller@workspace:packages/account-tree-controller": version: 0.0.0-use.local resolution: "@metamask/account-tree-controller@workspace:packages/account-tree-controller" dependencies: @@ -2541,8 +2541,8 @@ __metadata: "@metamask/keyring-api": "npm:^23.1.0" "@metamask/keyring-controller": "npm:^25.5.0" "@metamask/messenger": "npm:^1.2.0" - "@metamask/multichain-account-service": "npm:^9.0.0" - "@metamask/profile-sync-controller": "npm:^28.0.2" + "@metamask/multichain-account-service": "npm:^10.0.0" + "@metamask/profile-sync-controller": "npm:^28.1.0" "@metamask/providers": "npm:^22.1.0" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-sdk": "npm:^11.0.0" @@ -2768,16 +2768,16 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controller@npm:^7.1.1, @metamask/assets-controller@workspace:packages/assets-controller": +"@metamask/assets-controller@npm:^7.1.2, @metamask/assets-controller@workspace:packages/assets-controller": version: 0.0.0-use.local resolution: "@metamask/assets-controller@workspace:packages/assets-controller" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/account-tree-controller": "npm:^7.3.0" + "@metamask/account-tree-controller": "npm:^7.4.0" "@metamask/accounts-controller": "npm:^38.1.1" - "@metamask/assets-controllers": "npm:^108.0.0" + "@metamask/assets-controllers": "npm:^108.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/client-controller": "npm:^1.0.1" @@ -2796,7 +2796,7 @@ __metadata: "@metamask/preferences-controller": "npm:^23.1.0" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/transaction-controller": "npm:^65.3.0" + "@metamask/transaction-controller": "npm:^65.4.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" @@ -2815,7 +2815,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controllers@npm:^108.0.0, @metamask/assets-controllers@workspace:packages/assets-controllers": +"@metamask/assets-controllers@npm:^108.1.0, @metamask/assets-controllers@workspace:packages/assets-controllers": version: 0.0.0-use.local resolution: "@metamask/assets-controllers@workspace:packages/assets-controllers" dependencies: @@ -2828,7 +2828,7 @@ __metadata: "@ethersproject/providers": "npm:^5.7.0" "@metamask/abi-utils": "npm:^2.0.3" "@metamask/account-api": "npm:^1.0.4" - "@metamask/account-tree-controller": "npm:^7.3.0" + "@metamask/account-tree-controller": "npm:^7.4.0" "@metamask/accounts-controller": "npm:^38.1.1" "@metamask/approval-controller": "npm:^9.0.1" "@metamask/auto-changelog": "npm:^6.1.0" @@ -2844,21 +2844,21 @@ __metadata: "@metamask/keyring-snap-client": "npm:^9.0.2" "@metamask/messenger": "npm:^1.2.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/multichain-account-service": "npm:^9.0.0" + "@metamask/multichain-account-service": "npm:^10.0.0" "@metamask/network-controller": "npm:^32.0.0" "@metamask/network-enablement-controller": "npm:^5.1.1" "@metamask/permission-controller": "npm:^13.1.1" "@metamask/phishing-controller": "npm:^17.1.2" "@metamask/polling-controller": "npm:^16.0.5" "@metamask/preferences-controller": "npm:^23.1.0" - "@metamask/profile-sync-controller": "npm:^28.0.2" + "@metamask/profile-sync-controller": "npm:^28.1.0" "@metamask/providers": "npm:^22.1.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/snaps-utils": "npm:^12.1.2" "@metamask/storage-service": "npm:^1.0.1" - "@metamask/transaction-controller": "npm:^65.3.0" + "@metamask/transaction-controller": "npm:^65.4.0" "@metamask/utils": "npm:^11.9.0" "@tanstack/query-core": "npm:^5.62.16" "@ts-bridge/cli": "npm:^0.6.4" @@ -3025,8 +3025,8 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^38.1.1" - "@metamask/assets-controller": "npm:^7.1.1" - "@metamask/assets-controllers": "npm:^108.0.0" + "@metamask/assets-controller": "npm:^7.1.2" + "@metamask/assets-controllers": "npm:^108.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.1.0" @@ -3038,11 +3038,11 @@ __metadata: "@metamask/multichain-network-controller": "npm:^3.1.1" "@metamask/network-controller": "npm:^32.0.0" "@metamask/polling-controller": "npm:^16.0.5" - "@metamask/profile-sync-controller": "npm:^28.0.2" + "@metamask/profile-sync-controller": "npm:^28.1.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.1" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^65.3.0" + "@metamask/transaction-controller": "npm:^65.4.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" @@ -3076,10 +3076,10 @@ __metadata: "@metamask/messenger": "npm:^1.2.0" "@metamask/network-controller": "npm:^32.0.0" "@metamask/polling-controller": "npm:^16.0.5" - "@metamask/profile-sync-controller": "npm:^28.0.2" + "@metamask/profile-sync-controller": "npm:^28.1.0" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^65.3.0" + "@metamask/transaction-controller": "npm:^65.4.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" @@ -3180,7 +3180,7 @@ __metadata: "@metamask/controller-utils": "npm:^12.1.0" "@metamask/keyring-controller": "npm:^25.5.0" "@metamask/messenger": "npm:^1.2.0" - "@metamask/profile-sync-controller": "npm:^28.0.2" + "@metamask/profile-sync-controller": "npm:^28.1.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" @@ -3269,7 +3269,7 @@ __metadata: "@metamask/keyring-controller": "npm:^25.5.0" "@metamask/messenger": "npm:^1.2.0" "@metamask/polling-controller": "npm:^16.0.5" - "@metamask/profile-sync-controller": "npm:^28.0.2" + "@metamask/profile-sync-controller": "npm:^28.1.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.1" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.9.0" @@ -3377,11 +3377,12 @@ __metadata: "@metamask/controller-utils": "npm:^12.1.0" "@metamask/keyring-controller": "npm:^25.5.0" "@metamask/messenger": "npm:^1.2.0" - "@metamask/profile-sync-controller": "npm:^28.0.2" + "@metamask/profile-sync-controller": "npm:^28.1.0" "@metamask/utils": "npm:^11.9.0" "@tanstack/query-core": "npm:^5.62.16" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" + async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" jest: "npm:^29.7.0" jest-environment-jsdom: "npm:^29.7.0" @@ -3525,7 +3526,7 @@ __metadata: dependencies: "@ethersproject/bignumber": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/account-tree-controller": "npm:^7.3.0" + "@metamask/account-tree-controller": "npm:^7.4.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.1.0" @@ -3533,7 +3534,7 @@ __metadata: "@metamask/messenger": "npm:^1.2.0" "@metamask/network-controller": "npm:^32.0.0" "@metamask/stake-sdk": "npm:^3.2.1" - "@metamask/transaction-controller": "npm:^65.3.0" + "@metamask/transaction-controller": "npm:^65.4.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" @@ -3556,7 +3557,7 @@ __metadata: "@metamask/messenger": "npm:^1.2.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^65.3.0" + "@metamask/transaction-controller": "npm:^65.4.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" @@ -4138,7 +4139,7 @@ __metadata: "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/transaction-controller": "npm:^65.3.0" + "@metamask/transaction-controller": "npm:^65.4.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" @@ -4564,7 +4565,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/multichain-account-service@npm:^9.0.0, @metamask/multichain-account-service@workspace:packages/multichain-account-service": +"@metamask/multichain-account-service@npm:^10.0.0, @metamask/multichain-account-service@workspace:packages/multichain-account-service": version: 0.0.0-use.local resolution: "@metamask/multichain-account-service@workspace:packages/multichain-account-service" dependencies: @@ -4584,7 +4585,7 @@ __metadata: "@metamask/keyring-utils": "npm:^3.2.1" "@metamask/messenger": "npm:^1.2.0" "@metamask/providers": "npm:^22.1.0" - "@metamask/snap-account-service": "npm:^0.0.0" + "@metamask/snap-account-service": "npm:^0.1.0" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/snaps-utils": "npm:^12.1.2" @@ -4791,7 +4792,7 @@ __metadata: "@metamask/multichain-network-controller": "npm:^3.1.1" "@metamask/network-controller": "npm:^32.0.0" "@metamask/slip44": "npm:^4.3.0" - "@metamask/transaction-controller": "npm:^65.3.0" + "@metamask/transaction-controller": "npm:^65.4.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" @@ -4830,7 +4831,7 @@ __metadata: "@metamask/controller-utils": "npm:^12.1.0" "@metamask/keyring-controller": "npm:^25.5.0" "@metamask/messenger": "npm:^1.2.0" - "@metamask/profile-sync-controller": "npm:^28.0.2" + "@metamask/profile-sync-controller": "npm:^28.1.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" @@ -4976,7 +4977,7 @@ __metadata: resolution: "@metamask/perps-controller@workspace:packages/perps-controller" dependencies: "@metamask/abi-utils": "npm:^2.0.3" - "@metamask/account-tree-controller": "npm:^7.3.0" + "@metamask/account-tree-controller": "npm:^7.4.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.1.0" @@ -4985,9 +4986,9 @@ __metadata: "@metamask/keyring-internal-api": "npm:^11.0.1" "@metamask/messenger": "npm:^1.2.0" "@metamask/network-controller": "npm:^32.0.0" - "@metamask/profile-sync-controller": "npm:^28.0.2" + "@metamask/profile-sync-controller": "npm:^28.1.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.1" - "@metamask/transaction-controller": "npm:^65.3.0" + "@metamask/transaction-controller": "npm:^65.4.0" "@metamask/utils": "npm:^11.9.0" "@myx-trade/sdk": "npm:^0.1.265" "@nktkas/hyperliquid": "npm:^0.32.2" @@ -5018,7 +5019,7 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.1.0" "@metamask/messenger": "npm:^1.2.0" - "@metamask/transaction-controller": "npm:^65.3.0" + "@metamask/transaction-controller": "npm:^65.4.0" "@noble/hashes": "npm:^1.8.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" @@ -5103,8 +5104,8 @@ __metadata: "@metamask/keyring-internal-api": "npm:^11.0.1" "@metamask/messenger": "npm:^1.2.0" "@metamask/polling-controller": "npm:^16.0.5" - "@metamask/profile-sync-controller": "npm:^28.0.2" - "@metamask/transaction-controller": "npm:^65.3.0" + "@metamask/profile-sync-controller": "npm:^28.1.0" + "@metamask/transaction-controller": "npm:^65.4.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" @@ -5120,7 +5121,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/profile-sync-controller@npm:^28.0.2, @metamask/profile-sync-controller@workspace:packages/profile-sync-controller": +"@metamask/profile-sync-controller@npm:^28.1.0, @metamask/profile-sync-controller@workspace:packages/profile-sync-controller": version: 0.0.0-use.local resolution: "@metamask/profile-sync-controller@workspace:packages/profile-sync-controller" dependencies: @@ -5399,7 +5400,7 @@ __metadata: "@metamask/controller-utils": "npm:^12.1.0" "@metamask/messenger": "npm:^1.2.0" "@metamask/signature-controller": "npm:^39.2.2" - "@metamask/transaction-controller": "npm:^65.3.0" + "@metamask/transaction-controller": "npm:^65.4.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" @@ -5454,12 +5455,12 @@ __metadata: languageName: node linkType: hard -"@metamask/snap-account-service@npm:^0.0.0, @metamask/snap-account-service@workspace:packages/snap-account-service": +"@metamask/snap-account-service@npm:^0.1.0, @metamask/snap-account-service@workspace:packages/snap-account-service": version: 0.0.0-use.local resolution: "@metamask/snap-account-service@workspace:packages/snap-account-service" dependencies: "@metamask/account-api": "npm:^1.0.4" - "@metamask/account-tree-controller": "npm:^7.3.0" + "@metamask/account-tree-controller": "npm:^7.4.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/eth-snap-keyring": "npm:^22.0.1" "@metamask/keyring-controller": "npm:^25.5.0" @@ -5607,7 +5608,7 @@ __metadata: "@metamask/base-data-service": "npm:^0.1.2" "@metamask/controller-utils": "npm:^12.1.0" "@metamask/messenger": "npm:^1.2.0" - "@metamask/profile-sync-controller": "npm:^28.0.2" + "@metamask/profile-sync-controller": "npm:^28.1.0" "@metamask/superstruct": "npm:^3.1.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" @@ -5656,8 +5657,8 @@ __metadata: "@metamask/controller-utils": "npm:^12.1.0" "@metamask/messenger": "npm:^1.2.0" "@metamask/polling-controller": "npm:^16.0.5" - "@metamask/profile-sync-controller": "npm:^28.0.2" - "@metamask/transaction-controller": "npm:^65.3.0" + "@metamask/profile-sync-controller": "npm:^28.1.0" + "@metamask/transaction-controller": "npm:^65.4.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" @@ -5704,7 +5705,7 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:^65.3.0, @metamask/transaction-controller@workspace:packages/transaction-controller": +"@metamask/transaction-controller@npm:^65.4.0, @metamask/transaction-controller@workspace:packages/transaction-controller": version: 0.0.0-use.local resolution: "@metamask/transaction-controller@workspace:packages/transaction-controller" dependencies: @@ -5768,8 +5769,8 @@ __metadata: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/assets-controller": "npm:^7.1.1" - "@metamask/assets-controllers": "npm:^108.0.0" + "@metamask/assets-controller": "npm:^7.1.2" + "@metamask/assets-controllers": "npm:^108.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/bridge-controller": "npm:^72.0.4" @@ -5781,7 +5782,7 @@ __metadata: "@metamask/network-controller": "npm:^32.0.0" "@metamask/ramps-controller": "npm:^13.3.1" "@metamask/remote-feature-flag-controller": "npm:^4.2.1" - "@metamask/transaction-controller": "npm:^65.3.0" + "@metamask/transaction-controller": "npm:^65.4.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" @@ -5816,7 +5817,7 @@ __metadata: "@metamask/polling-controller": "npm:^16.0.5" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^65.3.0" + "@metamask/transaction-controller": "npm:^65.4.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" @@ -5872,6 +5873,23 @@ __metadata: languageName: node linkType: hard +"@metamask/wallet@workspace:packages/wallet": + version: 0.0.0-use.local + resolution: "@metamask/wallet@workspace:packages/wallet" + dependencies: + "@metamask/auto-changelog": "npm:^6.1.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^29.5.14" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + tsx: "npm:^4.20.5" + typedoc: "npm:^0.25.13" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@myx-trade/sdk@npm:^0.1.265": version: 0.1.265 resolution: "@myx-trade/sdk@npm:0.1.265"