diff --git a/package.json b/package.json index 5ead8c13ac6..7acf16f4706 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "651.0.0", + "version": "652.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 29464344c0c..76766501abf 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING:** Modify AccountTrackerController events and actions ([#6938](https://github.com/MetaMask/core/pull/6938)) + - `AccountsController:selectedAccountChange` is no longer part of the list of allowed events + - `NetworkController:getState` refetches balances + - `TransactionController:unapprovedTransactionAdded` refetches balances + - `TransactionController:unapprovedTransactionAdded'` refetches balances for the transaction from address and network + - `TransactionController:transactionConfirmed` refetches balances for the transaction from address and network +- Add optional `fetchingEnabled` callback to `AccountTrackerController` constructor to stop it from fetching balances ([#6938](https://github.com/MetaMask/core/pull/6938)) + ## [85.0.0] ### Added @@ -15,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `fetchExchangeRates` function to fetch exchange rates from price-api ([#6863](https://github.com/MetaMask/core/pull/6863)) - Added `ignoreAssets` to allow ignoring assets for non-EVM chains ([#6981](https://github.com/MetaMask/core/pull/6981)) +- Added `searchTokens` function to search for tokens across multiple networks using CAIP format chain IDs ([#7004](https://github.com/MetaMask/core/pull/7004)) + ### Changed - Bump `@metamask/controller-utils` from `^11.14.1` to `^11.15.0` ([#7003](https://github.com/MetaMask/core/pull/7003)) diff --git a/packages/assets-controllers/src/AccountTrackerController.test.ts b/packages/assets-controllers/src/AccountTrackerController.test.ts index 3926729a360..dd660819432 100644 --- a/packages/assets-controllers/src/AccountTrackerController.test.ts +++ b/packages/assets-controllers/src/AccountTrackerController.test.ts @@ -8,6 +8,7 @@ import { type MessengerEvents, type MockAnyNamespace, } from '@metamask/messenger'; +import type { NetworkConfiguration } from '@metamask/network-controller'; import { type NetworkClientId, type NetworkClientConfiguration, @@ -167,7 +168,7 @@ describe('AccountTrackerController', () => { mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ tokenBalances: { '0x0000000000000000000000000000000000000000': { - [CHECKSUM_ADDRESS_1]: new BN('123456', 16), + [ADDRESS_1]: new BN('123456', 16), }, }, stakedBalances: {}, @@ -208,7 +209,7 @@ describe('AccountTrackerController', () => { mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ tokenBalances: { '0x0000000000000000000000000000000000000000': { - [CHECKSUM_ADDRESS_1]: new BN('abcdef', 16), + [ADDRESS_1]: new BN('abcdef', 16), }, }, stakedBalances: {}, @@ -239,7 +240,88 @@ describe('AccountTrackerController', () => { ); }); + it('refreshes addresses when network is added', async () => { + await withController( + { + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1], + }, + async ({ controller, messenger }) => { + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + [CHECKSUM_ADDRESS_1]: new BN('abcdef', 16), + }, + }, + stakedBalances: {}, + }); + + messenger.publish( + 'NetworkController:networkAdded', + {} as NetworkConfiguration, + ); + + await clock.tickAsync(1); + + expect( + controller.state.accountsByChainId['0x1'][CHECKSUM_ADDRESS_1].balance, + ).toBe('0xabcdef'); + }, + ); + }); + + it('refreshes addresses when keyring is unlocked', async () => { + await withController( + { + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1], + }, + async ({ controller, messenger }) => { + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + [CHECKSUM_ADDRESS_1]: new BN('abcdef', 16), + }, + }, + stakedBalances: {}, + }); + + messenger.publish('KeyringController:unlock'); + + await clock.tickAsync(1); + + expect( + controller.state.accountsByChainId['0x1'][CHECKSUM_ADDRESS_1].balance, + ).toBe('0xabcdef'); + }, + ); + }); + describe('refresh', () => { + it('does not refresh when fetching is disabled', async () => { + const expectedState = { + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { balance: '0x0' }, + [CHECKSUM_ADDRESS_2]: { balance: '0x0' }, + }, + }, + }; + + await withController( + { + options: { fetchingEnabled: () => false }, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + }, + async ({ controller, refresh }) => { + await refresh(clock, ['mainnet'], true); + + expect(controller.state).toStrictEqual(expectedState); + }, + ); + }); + describe('without networkClientId', () => { it('should sync addresses', async () => { await withController( @@ -1613,6 +1695,8 @@ async function withController( 'AccountsController:selectedEvmAccountChange', 'TransactionController:unapprovedTransactionAdded', 'TransactionController:transactionConfirmed', + 'NetworkController:networkAdded', + 'KeyringController:unlock', ], }); diff --git a/packages/assets-controllers/src/AccountTrackerController.ts b/packages/assets-controllers/src/AccountTrackerController.ts index 627dc2a0fe6..527d99cccde 100644 --- a/packages/assets-controllers/src/AccountTrackerController.ts +++ b/packages/assets-controllers/src/AccountTrackerController.ts @@ -3,7 +3,6 @@ import type { AccountsControllerSelectedEvmAccountChangeEvent, AccountsControllerGetSelectedAccountAction, AccountsControllerListAccountsAction, - AccountsControllerSelectedAccountChangeEvent, } from '@metamask/accounts-controller'; import type { ControllerStateChangeEvent, @@ -16,6 +15,7 @@ import { toChecksumHexAddress, } from '@metamask/controller-utils'; import EthQuery from '@metamask/eth-query'; +import type { KeyringControllerUnlockEvent } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { Messenger } from '@metamask/messenger'; import type { @@ -23,9 +23,9 @@ import type { NetworkClientId, NetworkControllerGetNetworkClientByIdAction, NetworkControllerGetStateAction, + NetworkControllerNetworkAddedEvent, } from '@metamask/network-controller'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; -import type { PreferencesControllerGetStateAction } from '@metamask/preferences-controller'; import type { TransactionControllerTransactionConfirmedEvent, TransactionControllerUnapprovedTransactionAddedEvent, @@ -174,7 +174,10 @@ export type AccountTrackerControllerActions = */ export type AllowedActions = | AccountsControllerListAccountsAction - | PreferencesControllerGetStateAction + | { + type: 'PreferencesController:getState'; + handler: () => { isMultiAccountBalancesEnabled: boolean }; + } | AccountsControllerGetSelectedAccountAction | NetworkControllerGetStateAction | NetworkControllerGetNetworkClientByIdAction; @@ -199,9 +202,10 @@ export type AccountTrackerControllerEvents = */ export type AllowedEvents = | AccountsControllerSelectedEvmAccountChangeEvent - | AccountsControllerSelectedAccountChangeEvent | TransactionControllerUnapprovedTransactionAddedEvent - | TransactionControllerTransactionConfirmedEvent; + | TransactionControllerTransactionConfirmedEvent + | NetworkControllerNetworkAddedEvent + | KeyringControllerUnlockEvent; /** * The messenger of the {@link AccountTrackerController}. @@ -236,6 +240,8 @@ export class AccountTrackerController extends StaticIntervalPollingController boolean; + /** * Creates an AccountTracker instance. * @@ -247,6 +253,7 @@ export class AccountTrackerController extends StaticIntervalPollingController [], allowExternalServices = () => true, + fetchingEnabled = () => true, }: { interval?: number; state?: Partial; @@ -264,6 +272,7 @@ export class AccountTrackerController extends StaticIntervalPollingController ChainIdHex[]; allowExternalServices?: () => boolean; + fetchingEnabled?: () => boolean; }) { const { selectedNetworkClientId } = messenger.call( 'NetworkController:getState', @@ -302,6 +311,8 @@ export class AccountTrackerController extends StaticIntervalPollingController event.address, ); + this.messenger.subscribe('NetworkController:networkAdded', async () => { + await this.refresh(this.#getNetworkClientIds()); + }); + + this.messenger.subscribe('KeyringController:unlock', async () => { + await this.refresh(this.#getNetworkClientIds()); + }); + this.messenger.subscribe( 'TransactionController:unapprovedTransactionAdded', async (transactionMeta: TransactionMeta) => { - await this.#refreshAddress( - [transactionMeta.networkClientId], - transactionMeta.txParams.from, - ); + const addresses = [transactionMeta.txParams.from]; + if (transactionMeta.txParams.to) { + addresses.push(transactionMeta.txParams.to); + } + await this.refreshAddresses({ + networkClientIds: [transactionMeta.networkClientId], + addresses, + }); }, ); this.messenger.subscribe( 'TransactionController:transactionConfirmed', async (transactionMeta: TransactionMeta) => { - await this.#refreshAddress( - [transactionMeta.networkClientId], - transactionMeta.txParams.from, - ); + const addresses = [transactionMeta.txParams.from]; + if (transactionMeta.txParams.to) { + addresses.push(transactionMeta.txParams.to); + } + await this.refreshAddresses({ + networkClientIds: [transactionMeta.networkClientId], + addresses, + }); }, ); @@ -540,13 +567,28 @@ export class AccountTrackerController extends StaticIntervalPollingController + toChecksumHexAddress(address), + ); + + const accounts = this.messenger + .call('AccountsController:listAccounts') + .filter((account) => + checksummedAddresses.includes(toChecksumHexAddress(account.address)), + ); + await this.#refreshAccounts({ networkClientIds, - queryAllAccounts: false, - selectedAccount: checksumAddress, - allAccounts: [], + queryAllAccounts: true, + selectedAccount: '0x0', + allAccounts: accounts, }); } @@ -570,6 +612,10 @@ export class AccountTrackerController extends StaticIntervalPollingController { describe('fetchTokenListByChainId', () => { @@ -437,4 +485,228 @@ describe('Token service', () => { ).rejects.toThrow(TOKEN_METADATA_NO_SUPPORT_ERROR); }); }); + + describe('searchTokens', () => { + it('should call the search api and return the list of matching tokens for single chain', async () => { + const searchQuery = 'USD'; + const mockResponse = { + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery); + + expect(results).toStrictEqual({ + count: sampleSearchResults.length, + data: sampleSearchResults, + }); + }); + + it('should call the search api with custom limit parameter', async () => { + const searchQuery = 'USDC'; + const customLimit = 5; + const mockResponse = { + count: 1, + data: [sampleSearchResults[0]], + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=${customLimit}`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery, { + limit: customLimit, + }); + + expect(results).toStrictEqual({ + count: 1, + data: [sampleSearchResults[0]], + }); + }); + + it('should properly encode search queries with special characters', async () => { + const searchQuery = 'USD Coin & Token'; + const encodedQuery = 'USD%20Coin%20%26%20Token'; + const mockResponse = { + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${encodedQuery}&limit=10`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery); + + expect(results).toStrictEqual({ + count: sampleSearchResults.length, + data: sampleSearchResults, + }); + }); + + it('should search across multiple chains in a single request', async () => { + const searchQuery = 'USD'; + const encodedChainIds = [sampleCaipChainId, polygonCaipChainId] + .map((id) => encodeURIComponent(id)) + .join(','); + const mockResponse = { + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?chainIds=${encodedChainIds}&query=${searchQuery}&limit=10`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens( + [sampleCaipChainId, polygonCaipChainId], + searchQuery, + ); + + expect(results).toStrictEqual({ + count: sampleSearchResults.length, + data: sampleSearchResults, + }); + }); + + it('should return empty array if the fetch fails with a network error', async () => { + const searchQuery = 'USD'; + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`, + ) + .replyWithError('Example network error') + .persist(); + + const result = await searchTokens([sampleCaipChainId], searchQuery); + + expect(result).toStrictEqual({ count: 0, data: [] }); + }); + + it('should return empty array if the fetch fails with 400 error', async () => { + const searchQuery = 'USD'; + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`, + ) + .reply(400, { error: 'Bad Request' }) + .persist(); + + const result = await searchTokens([sampleCaipChainId], searchQuery); + + expect(result).toStrictEqual({ count: 0, data: [] }); + }); + + it('should return empty array if the fetch fails with 500 error', async () => { + const searchQuery = 'USD'; + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`, + ) + .reply(500) + .persist(); + + const result = await searchTokens([sampleCaipChainId], searchQuery); + + expect(result).toStrictEqual({ count: 0, data: [] }); + }); + + it('should handle empty search results', async () => { + const searchQuery = 'NONEXISTENT'; + const mockResponse = { + count: 0, + data: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery); + + expect(results).toStrictEqual({ count: 0, data: [] }); + }); + + it('should return empty array when no chainIds are provided', async () => { + const searchQuery = 'USD'; + const results = await searchTokens([], searchQuery); + + expect(results).toStrictEqual({ count: 0, data: [] }); + }); + + it('should handle API error responses in JSON format', async () => { + const searchQuery = 'USD'; + const errorResponse = { error: 'Invalid search query' }; + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`, + ) + .reply(200, errorResponse) + .persist(); + + const result = await searchTokens([sampleCaipChainId], searchQuery); + + // Non-array responses should be converted to empty object with count 0 + expect(result).toStrictEqual({ count: 0, data: [] }); + }); + + it('should handle supported CAIP format chain IDs', async () => { + const searchQuery = 'USD'; + const solanaChainId: CaipChainId = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; + const tronChainId: CaipChainId = 'tron:728126428'; + + const multiChainIds: CaipChainId[] = [ + sampleCaipChainId, + solanaChainId, + tronChainId, + ]; + const encodedChainIds = multiChainIds + .map((id) => encodeURIComponent(id)) + .join(','); + const mockResponse = { + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?chainIds=${encodedChainIds}&query=${searchQuery}&limit=10`, + ) + .reply(200, mockResponse) + .persist(); + + const result = await searchTokens(multiChainIds, searchQuery); + + expect(result).toStrictEqual({ + count: sampleSearchResults.length, + data: sampleSearchResults, + }); + }); + }); }); diff --git a/packages/assets-controllers/src/token-service.ts b/packages/assets-controllers/src/token-service.ts index b347079e02d..24fc7228e81 100644 --- a/packages/assets-controllers/src/token-service.ts +++ b/packages/assets-controllers/src/token-service.ts @@ -1,9 +1,10 @@ import { ChainId, convertHexToDecimal, + handleFetch, timeoutFetch, } from '@metamask/controller-utils'; -import type { Hex } from '@metamask/utils'; +import type { CaipChainId, Hex } from '@metamask/utils'; import { isTokenListSupportedForNetwork } from './assetsUtil'; @@ -41,6 +42,22 @@ function getTokenMetadataURL(chainId: Hex, tokenAddress: string) { )}?address=${tokenAddress}`; } +/** + * Get the token search URL for the given networks and search query. + * + * @param chainIds - Array of CAIP format chain IDs (e.g., 'eip155:1', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'). + * @param query - The search query (token name, symbol, or address). + * @param limit - Optional limit for the number of results (defaults to 10). + * @returns The token search URL. + */ +function getTokenSearchURL(chainIds: CaipChainId[], query: string, limit = 10) { + const encodedQuery = encodeURIComponent(query); + const encodedChainIds = chainIds + .map((id) => encodeURIComponent(id)) + .join(','); + return `${TOKEN_END_POINT_API}/tokens/search?chainIds=${encodedChainIds}&query=${encodedQuery}&limit=${limit}`; +} + const tenSecondsInMilliseconds = 10_000; // Token list averages 1.6 MB in size @@ -77,6 +94,46 @@ export async function fetchTokenListByChainId( return undefined; } +/** + * Search for tokens across one or more networks by query string using CAIP format chain IDs. + * + * @param chainIds - Array of CAIP format chain IDs (e.g., ['eip155:1', 'eip155:137', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp']). + * @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. + * @returns Object containing count and data array. Returns { count: 0, data: [] } if request fails. + */ +export async function searchTokens( + chainIds: CaipChainId[], + query: string, + { limit = 10 } = {}, +): Promise<{ count: number; data: unknown[] }> { + if (chainIds.length === 0) { + return { count: 0, data: [] }; + } + + const tokenSearchURL = getTokenSearchURL(chainIds, query, limit); + + try { + const result = 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, + data: result.data, + }; + } + + // Handle non-expected responses + return { count: 0, data: [] }; + } catch (error) { + // Handle 400 errors and other failures by returning count 0 and empty array + console.log('Search request failed:', error); + return { count: 0, data: [] }; + } +} + /** * Fetch metadata for the token address provided for a given network. This request is cancellable * using the abort signal passed in. diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 705594d0005..7ed4623d1ad 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING:** `noFee` flag was replaced with `fee` flag in bridge api requests ([#6964](https://github.com/MetaMask/core/pull/6964)) + ## [57.0.0] ### Changed diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index 4cb7cd36a19..b44f2e4bd4d 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -2625,7 +2625,7 @@ describe('BridgeController', function () { [FeatureId.PERPS]: { aggIds: ['debridge', 'socket'], bridgeIds: ['bridge1', 'bridge2'], - noFee: true, + fee: 0, }, }, }); @@ -2634,7 +2634,7 @@ describe('BridgeController', function () { })); }); - it('should override aggIds and noFee in perps request', async () => { + it('should override aggIds and fee in perps request', async () => { const fetchBridgeQuotesSpy = jest .spyOn(fetchUtils, 'fetchBridgeQuotes') .mockResolvedValueOnce({ @@ -2656,7 +2656,7 @@ describe('BridgeController', function () { bridgeIds: ['other', 'debridge'], gasIncluded: false, gasIncluded7702: false, - noFee: false, + fee: 0, }, null, FeatureId.PERPS, @@ -2677,9 +2677,9 @@ describe('BridgeController', function () { ], "destChainId": "1", "destTokenAddress": "0x1234", + "fee": 0, "gasIncluded": false, "gasIncluded7702": false, - "noFee": true, "slippage": 0.5, "srcChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "srcTokenAddress": "NATIVE", @@ -2722,7 +2722,6 @@ describe('BridgeController', function () { bridgeIds: ['other', 'debridge'], gasIncluded: false, gasIncluded7702: false, - noFee: false, } as never, null, FeatureId.PERPS, @@ -2733,7 +2732,7 @@ describe('BridgeController', function () { expect(bridgeController.state).toStrictEqual(expectedControllerState); }); - it('should add aggIds and noFee to perps request', async () => { + it('should add aggIds and fee to perps request', async () => { const fetchBridgeQuotesSpy = jest .spyOn(fetchUtils, 'fetchBridgeQuotes') .mockResolvedValueOnce({ @@ -2773,9 +2772,9 @@ describe('BridgeController', function () { ], "destChainId": "1", "destTokenAddress": "0x1234", + "fee": 0, "gasIncluded": false, "gasIncluded7702": false, - "noFee": true, "slippage": 0.5, "srcChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "srcTokenAddress": "NATIVE", @@ -2795,7 +2794,7 @@ describe('BridgeController', function () { expect(bridgeController.state).toStrictEqual(expectedControllerState); }); - it('should not add aggIds and noFee if featureId is not specified', async () => { + it('should not add aggIds and fee if featureId is not specified', async () => { const fetchBridgeQuotesSpy = jest .spyOn(fetchUtils, 'fetchBridgeQuotes') .mockResolvedValueOnce({ diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index af2f293ed22..cc30e8b50e5 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -215,7 +215,10 @@ export type QuoteRequest< * Whether to request quotes that use EIP-7702 delegated gasless execution */ gasIncluded7702: boolean; - noFee?: boolean; + /** + * The fee that will be charged by MetaMask + */ + fee?: number; }; export enum StatusTypes { diff --git a/packages/bridge-controller/src/utils/fetch.test.ts b/packages/bridge-controller/src/utils/fetch.test.ts index 9627e9bec82..2411edeef49 100644 --- a/packages/bridge-controller/src/utils/fetch.test.ts +++ b/packages/bridge-controller/src/utils/fetch.test.ts @@ -354,7 +354,7 @@ describe('fetch', () => { mockConsoleWarn.mockRestore(); }); - it('should fetch bridge quotes successfully, with aggIds, bridgeIds and noFee=true', async () => { + it('should fetch bridge quotes successfully, with aggIds, bridgeIds and fee=0', async () => { mockFetchFn.mockResolvedValue(mockBridgeQuotesNativeErc20); const { signal } = new AbortController(); @@ -371,7 +371,7 @@ describe('fetch', () => { gasIncluded7702: false, aggIds: ['socket', 'lifi'], bridgeIds: ['bridge1', 'bridge2'], - noFee: true, + fee: 0, }, signal, BridgeClientId.EXTENSION, @@ -382,7 +382,7 @@ describe('fetch', () => { ); expect(mockFetchFn).toHaveBeenCalledWith( - 'https://bridge.api.cx.metamask.io/getQuote?walletAddress=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&destWalletAddress=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&srcChainId=1&destChainId=10&srcTokenAddress=0x0000000000000000000000000000000000000000&destTokenAddress=0x0000000000000000000000000000000000000000&srcTokenAmount=20000&insufficientBal=false&resetApproval=false&gasIncluded=false&gasIncluded7702=false&slippage=0.5&noFee=true&aggIds=socket%2Clifi&bridgeIds=bridge1%2Cbridge2', + 'https://bridge.api.cx.metamask.io/getQuote?walletAddress=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&destWalletAddress=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&srcChainId=1&destChainId=10&srcTokenAddress=0x0000000000000000000000000000000000000000&destTokenAddress=0x0000000000000000000000000000000000000000&srcTokenAmount=20000&insufficientBal=false&resetApproval=false&gasIncluded=false&gasIncluded7702=false&slippage=0.5&fee=0&aggIds=socket%2Clifi&bridgeIds=bridge1%2Cbridge2', { headers: { 'X-Client-Id': 'extension', 'Client-Version': '1.0.0' }, signal, diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts index 96e9311ad13..95f994d14de 100644 --- a/packages/bridge-controller/src/utils/fetch.ts +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -82,8 +82,8 @@ const formatQueryParams = (request: GenericQuoteRequest): URLSearchParams => { if (request.slippage !== undefined) { normalizedRequest.slippage = request.slippage; } - if (request.noFee !== undefined) { - normalizedRequest.noFee = request.noFee; + if (request.fee !== undefined) { + normalizedRequest.fee = request.fee; } if (request.aggIds && request.aggIds.length > 0) { normalizedRequest.aggIds = request.aggIds; diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 3082ae0f196..be950ab8564 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -122,7 +122,7 @@ export const PriceImpactThresholdSchema = type({ const GenericQuoteRequestSchema = type({ aggIds: optional(array(string())), bridgeIds: optional(array(string())), - noFee: optional(boolean()), + fee: optional(number()), }); const FeatureIdSchema = enums(Object.values(FeatureId)); diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 9a8750a18d2..50ebd5cec35 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [6.0.0] + ### Added - Added new public method, `checkIsSeedlessOnboardingUserAuthenticated` to validate the controller authenticate tokens state. ([#6998](https://github.com/MetaMask/core/pull/6998)) @@ -216,7 +218,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `checkIsPasswordOutdated`: Check if the password is current device is outdated, i.e. user changed password in another device. - `clearState`: Reset the state of the controller to the defaults. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/seedless-onboarding-controller@5.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/seedless-onboarding-controller@6.0.0...HEAD +[6.0.0]: https://github.com/MetaMask/core/compare/@metamask/seedless-onboarding-controller@5.0.0...@metamask/seedless-onboarding-controller@6.0.0 [5.0.0]: https://github.com/MetaMask/core/compare/@metamask/seedless-onboarding-controller@4.1.1...@metamask/seedless-onboarding-controller@5.0.0 [4.1.1]: https://github.com/MetaMask/core/compare/@metamask/seedless-onboarding-controller@4.1.0...@metamask/seedless-onboarding-controller@4.1.1 [4.1.0]: https://github.com/MetaMask/core/compare/@metamask/seedless-onboarding-controller@4.0.0...@metamask/seedless-onboarding-controller@4.1.0 diff --git a/packages/seedless-onboarding-controller/package.json b/packages/seedless-onboarding-controller/package.json index a4402c9c3b6..8dfb5027c95 100644 --- a/packages/seedless-onboarding-controller/package.json +++ b/packages/seedless-onboarding-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/seedless-onboarding-controller", - "version": "5.0.0", + "version": "6.0.0", "description": "Backup and rehydrate SRP(s) using social login and password", "keywords": [ "MetaMask", diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index bbe35622d1a..ccb7cc1d150 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Calculate operator fee for OP stack networks and include it in `layer1GasFee` ([#6979](https://github.com/MetaMask/core/pull/6979)) + ## [61.1.0] ### Added diff --git a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts index 8ee08e9ed85..3b1c80d35b6 100644 --- a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts +++ b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts @@ -2,13 +2,18 @@ import type { TypedTransaction } from '@ethereumjs/tx'; import { TransactionFactory } from '@ethereumjs/tx'; import { Contract } from '@ethersproject/contracts'; import type { Provider } from '@metamask/network-controller'; -import type { Hex } from '@metamask/utils'; +import { add0x, type Hex } from '@metamask/utils'; +import BN from 'bn.js'; import { OracleLayer1GasFeeFlow } from './OracleLayer1GasFeeFlow'; import { CHAIN_IDS } from '../constants'; import type { TransactionControllerMessenger } from '../TransactionController'; -import type { Layer1GasFeeFlowRequest, TransactionMeta } from '../types'; -import { TransactionStatus } from '../types'; +import { + type Layer1GasFeeFlowRequest, + type TransactionMeta, + TransactionStatus, +} from '../types'; +import { bnFromHex, padHexToEvenLength } from '../utils/utils'; jest.mock('@ethersproject/contracts', () => ({ Contract: jest.fn(), @@ -36,7 +41,8 @@ const TRANSACTION_META_MOCK: TransactionMeta = { const SERIALIZED_TRANSACTION_MOCK = '0x1234'; const ORACLE_ADDRESS_MOCK = '0x5678' as Hex; -const LAYER_1_FEE_MOCK = '0x9ABCD'; +const LAYER_1_FEE_MOCK = '0x09abcd'; +const OPERATOR_FEE_MOCK = '0x5'; const DEFAULT_GAS_PRICE_ORACLE_ADDRESS = '0x420000000000000000000000000000000000000F'; @@ -98,9 +104,9 @@ class DefaultOracleLayer1GasFeeFlow extends OracleLayer1GasFeeFlow { describe('OracleLayer1GasFeeFlow', () => { const contractMock = jest.mocked(Contract); - const contractGetL1FeeMock: jest.MockedFn< - () => Promise<{ toHexString: () => string }> - > = jest.fn(); + const contractGetL1FeeMock: jest.MockedFn<() => Promise> = jest.fn(); + const contractGetOperatorFeeMock: jest.MockedFn<() => Promise> = + jest.fn(); let request: Layer1GasFeeFlowRequest; @@ -112,13 +118,14 @@ describe('OracleLayer1GasFeeFlow', () => { contractMock.mockClear(); contractGetL1FeeMock.mockClear(); + contractGetOperatorFeeMock.mockClear(); - contractGetL1FeeMock.mockResolvedValue({ - toHexString: () => LAYER_1_FEE_MOCK, - }); + contractGetL1FeeMock.mockResolvedValue(bnFromHex(LAYER_1_FEE_MOCK)); + contractGetOperatorFeeMock.mockResolvedValue(new BN(0)); contractMock.mockReturnValue({ getL1Fee: contractGetL1FeeMock, + getOperatorFee: contractGetOperatorFeeMock, } as unknown as Contract); }); @@ -156,6 +163,7 @@ describe('OracleLayer1GasFeeFlow', () => { expect(contractGetL1FeeMock).toHaveBeenCalledWith( serializedTransactionMock, ); + expect(contractGetOperatorFeeMock).not.toHaveBeenCalled(); }); it('signs transaction with dummy key if supported by flow', async () => { @@ -180,6 +188,7 @@ describe('OracleLayer1GasFeeFlow', () => { }); expect(typedTransactionMock.sign).toHaveBeenCalledTimes(1); + expect(contractGetOperatorFeeMock).not.toHaveBeenCalled(); }); describe('throws', () => { @@ -228,5 +237,79 @@ describe('OracleLayer1GasFeeFlow', () => { expect(oracleAddress).toBe(DEFAULT_GAS_PRICE_ORACLE_ADDRESS); expect(typedTransactionMock.sign).not.toHaveBeenCalled(); }); + + it('adds operator fee when gas used is available', async () => { + const gasUsed = '0x5208'; + request = { + ...request, + transactionMeta: { + ...request.transactionMeta, + gasUsed, + }, + }; + + contractGetOperatorFeeMock.mockResolvedValueOnce( + bnFromHex(OPERATOR_FEE_MOCK), + ); + + const flow = new MockOracleLayer1GasFeeFlow(false); + const response = await flow.getLayer1Fee(request); + + expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1); + expect(contractGetOperatorFeeMock).toHaveBeenCalledWith(gasUsed); + expect(response).toStrictEqual({ + layer1Fee: add0x( + padHexToEvenLength( + bnFromHex(LAYER_1_FEE_MOCK) + .add(bnFromHex(OPERATOR_FEE_MOCK)) + .toString(16), + ), + ) as Hex, + }); + }); + + it('defaults operator fee to zero when call fails', async () => { + const gasUsed = '0x1'; + request = { + ...request, + transactionMeta: { + ...request.transactionMeta, + gasUsed, + }, + }; + + contractGetOperatorFeeMock.mockRejectedValueOnce(new Error('revert')); + + const flow = new MockOracleLayer1GasFeeFlow(false); + const response = await flow.getLayer1Fee(request); + + expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1); + expect(response).toStrictEqual({ + layer1Fee: LAYER_1_FEE_MOCK, + }); + }); + + it('defaults operator fee to zero when call returns undefined', async () => { + const gasUsed = '0x2'; + request = { + ...request, + transactionMeta: { + ...request.transactionMeta, + gasUsed, + }, + }; + + contractGetOperatorFeeMock.mockResolvedValueOnce( + undefined as unknown as BN, + ); + + const flow = new MockOracleLayer1GasFeeFlow(false); + const response = await flow.getLayer1Fee(request); + + expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1); + expect(response).toStrictEqual({ + layer1Fee: LAYER_1_FEE_MOCK, + }); + }); }); }); diff --git a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts index 4648ed7f13e..78d986a2bc1 100644 --- a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts +++ b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts @@ -1,7 +1,8 @@ import { Contract } from '@ethersproject/contracts'; import { Web3Provider, type ExternalProvider } from '@ethersproject/providers'; import type { Hex } from '@metamask/utils'; -import { createModuleLogger } from '@metamask/utils'; +import { add0x, createModuleLogger } from '@metamask/utils'; +import BN from 'bn.js'; import { projectLogger } from '../logger'; import type { TransactionControllerMessenger } from '../TransactionController'; @@ -12,17 +13,52 @@ import type { TransactionMeta, } from '../types'; import { prepareTransaction } from '../utils/prepare'; +import { padHexToEvenLength, toBN } from '../utils/utils'; const log = createModuleLogger(projectLogger, 'oracle-layer1-gas-fee-flow'); +const ZERO = new BN(0); + const DUMMY_KEY = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789'; const GAS_PRICE_ORACLE_ABI = [ { - inputs: [{ internalType: 'bytes', name: '_data', type: 'bytes' }], + inputs: [ + { + internalType: 'bytes', + name: '_data', + type: 'bytes', + }, + ], name: 'getL1Fee', - outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + // only available post Isthmus + { + inputs: [ + { + internalType: 'uint256', + name: '_gasUsed', + type: 'uint256', + }, + ], + name: 'getOperatorFee', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], stateMutability: 'view', type: 'function', }, @@ -73,7 +109,27 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow { request: Layer1GasFeeFlowRequest, ): Promise { try { - return await this.#getOracleLayer1GasFee(request); + const { provider, transactionMeta } = request; + + const contract = this.#getGasPriceOracleContract( + provider, + transactionMeta.chainId, + ); + + const oracleFee = await this.#getOracleLayer1GasFee( + contract, + transactionMeta, + ); + const operatorFee = await this.#getOperatorLayer1GasFee( + contract, + transactionMeta, + ); + + return { + layer1Fee: add0x( + padHexToEvenLength(oracleFee.add(operatorFee).toString(16)), + ) as Hex, + }; } catch (error) { log('Failed to get oracle layer 1 gas fee', error); throw new Error(`Failed to get oracle layer 1 gas fee`); @@ -81,17 +137,9 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow { } async #getOracleLayer1GasFee( - request: Layer1GasFeeFlowRequest, - ): Promise { - const { provider, transactionMeta } = request; - - const contract = new Contract( - this.getOracleAddressForChain(transactionMeta.chainId), - GAS_PRICE_ORACLE_ABI, - // Network controller provider type is incompatible with ethers provider - new Web3Provider(provider as unknown as ExternalProvider), - ); - + contract: Contract, + transactionMeta: TransactionMeta, + ): Promise { const serializedTransaction = this.#buildUnserializedTransaction( transactionMeta, this.shouldSignTransaction(), @@ -103,9 +151,31 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow { throw new Error('No value returned from oracle contract'); } - return { - layer1Fee: result.toHexString(), - }; + return toBN(result); + } + + async #getOperatorLayer1GasFee( + contract: Contract, + transactionMeta: TransactionMeta, + ): Promise { + const { gasUsed } = transactionMeta; + + if (!gasUsed) { + return ZERO; + } + + try { + const result = await contract.getOperatorFee(gasUsed); + + if (result === undefined) { + return ZERO; + } + + return toBN(result); + } catch (error) { + log('Failed to get operator layer 1 gas fee', error); + return ZERO; + } } #buildUnserializedTransaction( @@ -134,4 +204,16 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow { gasLimit: transactionMeta.txParams.gas, }; } + + #getGasPriceOracleContract( + provider: Layer1GasFeeFlowRequest['provider'], + chainId: Hex, + ) { + return new Contract( + this.getOracleAddressForChain(chainId), + GAS_PRICE_ORACLE_ABI, + // Network controller provider type is incompatible with ethers provider + new Web3Provider(provider as unknown as ExternalProvider), + ); + } } diff --git a/packages/transaction-controller/src/utils/utils.test.ts b/packages/transaction-controller/src/utils/utils.test.ts index 3b6c124c41e..fbdd94576db 100644 --- a/packages/transaction-controller/src/utils/utils.test.ts +++ b/packages/transaction-controller/src/utils/utils.test.ts @@ -1,4 +1,4 @@ -import { BN } from 'bn.js'; +import BN from 'bn.js'; import * as util from './utils'; import type { @@ -274,4 +274,85 @@ describe('utils', () => { expect(util.getPercentageChange(new BN(2), new BN(-1))).toBe(150); }); }); + + describe('bnFromHex', () => { + it('parses hex with 0x prefix', () => { + const result = util.bnFromHex('0x1a'); + expect(result.eq(new BN(26))).toBe(true); + }); + + it('parses hex without prefix', () => { + const result = util.bnFromHex('1a'); + expect(result.eq(new BN(26))).toBe(true); + }); + + it('parses uppercase 0X prefix', () => { + const result = util.bnFromHex('0XFF'); + expect(result.eq(new BN(255))).toBe(true); + }); + + it('returns zero for empty data with 0x', () => { + const result = util.bnFromHex('0x'); + expect(result.isZero()).toBe(true); + }); + + it('returns zero for empty string', () => { + const result = util.bnFromHex(''); + expect(result.isZero()).toBe(true); + }); + + it('throws for invalid hex', () => { + expect(() => util.bnFromHex('0xzz')).toThrow(Error); + }); + }); + + describe('toBN', () => { + it('returns the same BN instance', () => { + const input = new BN(123); + const result = util.toBN(input); + expect(result).toBe(input); + }); + + it('converts ethers-like BigNumber with toHexString', () => { + const bigNumberLike = { toHexString: () => '0x2a' }; + const result = util.toBN(bigNumberLike); + expect(result.eq(new BN(42))).toBe(true); + }); + + it('converts object with _hex property', () => { + const hexLike = { _hex: '0x2a' }; + const result = util.toBN(hexLike); + expect(result.eq(new BN(42))).toBe(true); + }); + + it('converts hex string values', () => { + expect(util.toBN('0x10').eq(new BN(16))).toBe(true); + expect(util.toBN('10').eq(new BN(16))).toBe(true); + }); + + it('converts bigint values', () => { + const result = util.toBN(123n); + expect(result.eq(new BN(123))).toBe(true); + }); + + it('converts number values', () => { + const result = util.toBN(456); + expect(result.eq(new BN(456))).toBe(true); + }); + + it('throws for unsupported types', () => { + expect(() => util.toBN(true as unknown)).toThrow( + 'Unexpected value returned from oracle contract', + ); + expect(() => util.toBN(null as unknown)).toThrow( + 'Unexpected value returned from oracle contract', + ); + expect(() => util.toBN(undefined as unknown)).toThrow( + 'Unexpected value returned from oracle contract', + ); + expect(() => util.toBN({} as unknown)).toThrow( + 'Unexpected value returned from oracle contract', + ); + }); + }); }); diff --git a/packages/transaction-controller/src/utils/utils.ts b/packages/transaction-controller/src/utils/utils.ts index a33e11c65a3..672d4b21bd7 100644 --- a/packages/transaction-controller/src/utils/utils.ts +++ b/packages/transaction-controller/src/utils/utils.ts @@ -1,20 +1,20 @@ import type { AccessList, AuthorizationList } from '@ethereumjs/common'; +import type { Hex, Json } from '@metamask/utils'; import { add0x, getKnownPropertyNames, isStrictHexString, } from '@metamask/utils'; -import type { Json } from '@metamask/utils'; import BN from 'bn.js'; -import { TransactionEnvelopeType, TransactionStatus } from '../types'; import type { - TransactionParams, - TransactionMeta, - TransactionError, - GasPriceValue, FeeMarketEIP1559Values, + GasPriceValue, + TransactionError, + TransactionMeta, + TransactionParams, } from '../types'; +import { TransactionEnvelopeType, TransactionStatus } from '../types'; export const ESTIMATE_GAS_ERROR = 'eth_estimateGas rpc method error'; @@ -189,6 +189,66 @@ export function padHexToEvenLength(hex: string) { return prefix + evenData; } +/** + * Create a BN from a hex string, accepting an optional 0x prefix. + * + * @param hex - Hex string with or without 0x prefix. + * @returns BN parsed as base-16. + */ +export function bnFromHex(hex: string | Hex): BN { + const str = typeof hex === 'string' ? hex : (hex as string); + const withoutPrefix = + str.startsWith('0x') || str.startsWith('0X') ? str.slice(2) : str; + if (withoutPrefix.length === 0) { + return new BN(0); + } + return new BN(withoutPrefix, 16); +} + +/** + * Convert various numeric-like values to a BN instance. + * Accepts BN, ethers BigNumber, hex string, bigint, or number. + * + * @param value - The value to convert. + * @returns BN representation of the input value. + */ +export function toBN(value: unknown): BN { + if (value instanceof BN) { + return value as BN; + } + if ( + typeof (BN as unknown as { isBN?: (v: unknown) => boolean }).isBN === + 'function' && + (BN as unknown as { isBN: (v: unknown) => boolean }).isBN(value) + ) { + return value as BN; + } + if ( + value !== null && + typeof value === 'object' && + typeof (value as { toHexString?: () => string }).toHexString === 'function' + ) { + return bnFromHex((value as { toHexString: () => string }).toHexString()); + } + if ( + value !== null && + typeof value === 'object' && + typeof (value as { _hex?: string })._hex === 'string' + ) { + return bnFromHex((value as { _hex: string })._hex); + } + if (typeof value === 'string') { + return bnFromHex(value); + } + if (typeof value === 'bigint') { + return new BN(value.toString()); + } + if (typeof value === 'number') { + return new BN(value); + } + throw new Error('Unexpected value returned from oracle contract'); +} + /** * Calculate the absolute percentage change between two values. *