diff --git a/packages/base-data-service/CHANGELOG.md b/packages/base-data-service/CHANGELOG.md index b518709c7b8..3cc5491ed05 100644 --- a/packages/base-data-service/CHANGELOG.md +++ b/packages/base-data-service/CHANGELOG.md @@ -7,4 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Initial release ([#8039](https://github.com/MetaMask/core/pull/8039)) + [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/base-data-service/package.json b/packages/base-data-service/package.json index 7e49071c90e..edf84b2a1e1 100644 --- a/packages/base-data-service/package.json +++ b/packages/base-data-service/package.json @@ -47,12 +47,20 @@ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, + "dependencies": { + "@metamask/controller-utils": "^11.19.0", + "@metamask/messenger": "^0.3.0", + "@metamask/utils": "^11.9.0", + "@tanstack/query-core": "^4.43.0", + "fast-deep-equal": "^3.1.3" + }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", "jest": "^29.7.0", + "nock": "^13.3.1", "ts-jest": "^29.2.5", "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts new file mode 100644 index 00000000000..a98a135ed0c --- /dev/null +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -0,0 +1,283 @@ +import { BrokenCircuitError } from '@metamask/controller-utils'; +import { Messenger } from '@metamask/messenger'; +import { hashQueryKey } from '@tanstack/query-core'; +import { cleanAll } from 'nock'; + +import { ExampleDataService, serviceName } from '../tests/ExampleDataService'; +import { + mockAssets, + mockTransactionsPage1, + mockTransactionsPage2, + mockTransactionsPage3, + TRANSACTIONS_PAGE_2_CURSOR, + TRANSACTIONS_PAGE_3_CURSOR, +} from '../tests/mocks'; + +const TEST_ADDRESS = '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520'; + +const MOCK_ASSETS = [ + 'eip155:1/slip44:60', + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', +]; + +describe('BaseDataService', () => { + beforeEach(() => { + mockAssets(); + mockTransactionsPage1(); + mockTransactionsPage2(); + mockTransactionsPage3(); + }); + + it('handles basic queries', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + expect(await service.getAssets(MOCK_ASSETS)).toStrictEqual([ + { + assetId: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + decimals: 18, + name: 'Dai Stablecoin', + symbol: 'DAI', + }, + { + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + decimals: 8, + name: 'Bitcoin', + symbol: 'BTC', + }, + { + assetId: 'eip155:1/slip44:60', + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + ]); + }); + + it('handles paginated queries', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + const page1 = await service.getActivity(TEST_ADDRESS); + + expect(page1.data).toHaveLength(3); + + const page2 = await service.getActivity(TEST_ADDRESS, { + after: page1.pageInfo.endCursor, + }); + + expect(page2.data).toHaveLength(3); + + expect(page2.data).not.toStrictEqual(page1.data); + }); + + it('handles paginated queries starting at a specific page', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + const page2 = await service.getActivity(TEST_ADDRESS, { + after: TRANSACTIONS_PAGE_2_CURSOR, + }); + + expect(page2.data).toHaveLength(3); + + const page3 = await service.getActivity(TEST_ADDRESS, { + after: page2.pageInfo.endCursor, + }); + + expect(page3.data).toHaveLength(3); + + expect(page3.data).not.toStrictEqual(page2.data); + }); + + it('handles backwards queries starting at a specific page', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + const page3 = await service.getActivity(TEST_ADDRESS, { + after: TRANSACTIONS_PAGE_3_CURSOR, + }); + + expect(page3.data).toHaveLength(3); + + const page2 = await service.getActivity(TEST_ADDRESS, { + before: page3.pageInfo.startCursor, + }); + + expect(page2.data).toHaveLength(3); + expect(page2.data).not.toStrictEqual(page3.data); + }); + + it('emits `:cacheUpdated` events when cache is updated', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + const publishSpy = jest.spyOn(messenger, 'publish'); + + await service.getAssets(MOCK_ASSETS); + + const queryKey = ['ExampleDataService:getAssets', MOCK_ASSETS]; + + const hash = hashQueryKey(queryKey); + + expect(publishSpy).toHaveBeenNthCalledWith( + 6, + `ExampleDataService:cacheUpdated:${hash}`, + { + type: 'updated', + state: { + mutations: [], + queries: [ + expect.objectContaining({ + state: expect.objectContaining({ + status: 'success', + data: [ + { + assetId: + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + decimals: 18, + name: 'Dai Stablecoin', + symbol: 'DAI', + }, + { + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + decimals: 8, + name: 'Bitcoin', + symbol: 'BTC', + }, + { + assetId: 'eip155:1/slip44:60', + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + ], + }), + }), + ], + }, + }, + ); + }); + + it('emits `:cacheUpdated` events when cache entry is removed', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + const publishSpy = jest.spyOn(messenger, 'publish'); + + await service.getAssets(MOCK_ASSETS); + + // Wait for GC + await new Promise((resolve) => setTimeout(resolve, 0)); + + const queryKey = ['ExampleDataService:getAssets', MOCK_ASSETS]; + + const hash = hashQueryKey(queryKey); + + expect(publishSpy).toHaveBeenNthCalledWith( + 8, + `ExampleDataService:cacheUpdated:${hash}`, + { + type: 'removed', + state: null, + }, + ); + }); + + it('does not emit events after being destroyed', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + const publishSpy = jest.spyOn(messenger, 'publish'); + + service.destroy(); + + await service.getAssets(MOCK_ASSETS); + + expect(publishSpy).toHaveBeenCalledTimes(0); + }); + + it('invalidates queries when requested', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + const publishSpy = jest.spyOn(messenger, 'publish'); + + await service.getAssets(MOCK_ASSETS); + + expect(publishSpy).toHaveBeenCalledTimes(6); + + const queryKey = ['ExampleDataService:getAssets', MOCK_ASSETS]; + await service.invalidateQueries({ queryKey }); + + expect(publishSpy).toHaveBeenCalledTimes(8); + }); + + describe('service policy', () => { + beforeEach(() => { + cleanAll(); + }); + + it('retries failed queries using the service policy', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + mockAssets({ status: 500 }); + mockAssets({ status: 500 }); + mockAssets(); + + const result = await service.getAssets(MOCK_ASSETS); + + expect(result).toStrictEqual([ + { + assetId: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + decimals: 18, + name: 'Dai Stablecoin', + symbol: 'DAI', + }, + { + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + decimals: 8, + name: 'Bitcoin', + symbol: 'BTC', + }, + { + assetId: 'eip155:1/slip44:60', + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + ]); + }); + + it('throws after exhausting service policy retries', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + mockAssets({ status: 500 }); + mockAssets({ status: 500 }); + mockAssets({ status: 500 }); + + await expect(service.getAssets(MOCK_ASSETS)).rejects.toThrow( + 'invalid json response body', + ); + }); + + it('breaks the circuit after consecutive failures', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + mockAssets({ status: 500 }); + mockAssets({ status: 500 }); + mockAssets({ status: 500 }); + + await expect(service.getAssets(MOCK_ASSETS)).rejects.toThrow( + 'invalid json response body', + ); + + await expect(service.getAssets(MOCK_ASSETS)).rejects.toThrow( + BrokenCircuitError, + ); + }); + }); +}); diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts new file mode 100644 index 00000000000..a6408ed91cc --- /dev/null +++ b/packages/base-data-service/src/BaseDataService.ts @@ -0,0 +1,307 @@ +import { + createServicePolicy, + CreateServicePolicyOptions, + ServicePolicy, +} from '@metamask/controller-utils'; +import { + Messenger, + ActionConstraint, + EventConstraint, +} from '@metamask/messenger'; +import { Duration, inMilliseconds } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; +import { + DefaultOptions, + DehydratedState, + FetchInfiniteQueryOptions, + FetchQueryOptions, + InfiniteData, + InvalidateOptions, + InvalidateQueryFilters, + OmitKeyof, + QueryClient, + QueryClientConfig, + WithRequired, + dehydrate, +} from '@tanstack/query-core'; +import deepEqual from 'fast-deep-equal'; + +// Data service queries use the following format: ['ServiceActionName', ...params] +export type QueryKey = [string, ...Json[]]; + +export type GranularCacheUpdatedPayload = + | { type: 'added' | 'updated'; state: DehydratedState } + | { + type: 'removed'; + state: null; + }; + +export type CacheUpdatedPayload = GranularCacheUpdatedPayload & { + hash: string; +}; + +type CacheUpdatedType = CacheUpdatedPayload['type']; + +export type DataServiceInvalidateQueriesAction = { + type: `${ServiceName}:invalidateQueries`; + handler: ( + filters?: InvalidateQueryFilters, + options?: InvalidateOptions, + ) => Promise; +}; + +type DataServiceActions = + DataServiceInvalidateQueriesAction; + +export type DataServiceCacheUpdatedEvent = { + type: `${ServiceName}:cacheUpdated`; + payload: [CacheUpdatedPayload]; +}; + +export type DataServiceGranularCacheUpdatedEvent = { + type: `${ServiceName}:cacheUpdated:${string}`; + payload: [GranularCacheUpdatedPayload]; +}; + +type DataServiceEvents = + | DataServiceCacheUpdatedEvent + | DataServiceGranularCacheUpdatedEvent; + +// Defaults to apply to all data service queries if no default option specified +const queryClientDefaults: DefaultOptions = { + queries: { + retry: false, + staleTime: inMilliseconds(1, Duration.Minute), + }, +}; + +export class BaseDataService< + ServiceName extends string, + ServiceMessenger extends Messenger< + ServiceName, + ActionConstraint, + EventConstraint, + // Use `any` to allow any parent to be set. `any` is harmless in a type constraint anyway, + // it's the one totally safe place to use it. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any + >, +> { + public readonly name: ServiceName; + + readonly #messenger: Messenger< + ServiceName, + DataServiceActions, + DataServiceEvents + >; + + protected messenger: ServiceMessenger; + + readonly #policy: ServicePolicy; + + readonly #queryClient: QueryClient; + + readonly #queryCacheUnsubscribe: () => void; + + constructor({ + name, + messenger, + queryClientConfig = {}, + servicePolicyOptions, + }: { + name: ServiceName; + messenger: ServiceMessenger; + queryClientConfig?: QueryClientConfig; + servicePolicyOptions?: CreateServicePolicyOptions; + }) { + this.name = name; + + // We are storing a separately typed messenger for known actions and events provided by data services + // and a generic public one that is typed using the generic parameters and accessible to implementations. + this.#messenger = messenger as unknown as Messenger< + ServiceName, + DataServiceActions, + DataServiceEvents + >; + this.messenger = messenger; + + this.#queryClient = new QueryClient({ + ...queryClientConfig, + defaultOptions: { + queries: { + ...queryClientDefaults.queries, + ...queryClientConfig.defaultOptions?.queries, + }, + mutations: queryClientConfig.defaultOptions?.mutations, + }, + }); + + this.#policy = createServicePolicy(servicePolicyOptions); + + this.#queryCacheUnsubscribe = this.#queryClient + .getQueryCache() + .subscribe((event) => { + if (['added', 'updated', 'removed'].includes(event.type)) { + this.#publishCacheUpdate( + event.query.queryHash, + event.type as CacheUpdatedType, + ); + } + }); + + this.#messenger.registerActionHandler( + `${this.name}:invalidateQueries`, + this.invalidateQueries.bind(this), + ); + } + + /** + * Fetch a query. + * + * @param options - The options defining the query. Keep in mind that `queryKey` and `queryFn` are required when using data services. + * Additionally `retry` and `retryDelay` are not available, retries can be customized using the `servicePolicyOptions`. + * @returns The query results. + */ + protected async fetchQuery< + TQueryFnData extends Json, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + >( + options: WithRequired< + OmitKeyof< + FetchQueryOptions, + 'retry' | 'retryDelay' + >, + 'queryKey' | 'queryFn' + >, + ): Promise { + return this.#queryClient.fetchQuery({ + ...options, + queryFn: (context) => + this.#policy.execute(() => options.queryFn(context)), + }); + } + + /** + * Fetch a paginated query. + * + * @param options - The options defining the query. Keep in mind that `queryKey` and `queryFn` are required when using data services. + * Additionally `retry` and `retryDelay` are not available, retries can be customized using the `servicePolicyOptions`. + * @param pageParam - An optional page parameter. + * @returns The query result, exclusively the requested page is returned. + */ + protected async fetchInfiniteQuery< + TQueryFnData extends Json, + TError = unknown, + TData extends TQueryFnData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam extends Json = Json, + >( + options: WithRequired< + OmitKeyof< + FetchInfiniteQueryOptions, + 'retry' | 'retryDelay' + >, + 'queryKey' | 'queryFn' + >, + pageParam?: TPageParam, + ): Promise { + const cache = this.#queryClient.getQueryCache(); + + const query = cache.find>({ + queryKey: options.queryKey, + }); + + if (!query?.state.data || pageParam === undefined) { + const result = await this.#queryClient.fetchInfiniteQuery({ + ...options, + queryFn: (context) => + this.#policy.execute(() => + options.queryFn({ + ...context, + pageParam: context.pageParam ?? pageParam, + }), + ), + }); + + return result.pages[0]; + } + + const { pages } = query.state.data; + const previous = options.getPreviousPageParam?.(pages[0], pages); + + const direction = deepEqual(pageParam, previous) ? 'backward' : 'forward'; + + const result = await query.fetch(undefined, { + meta: { + fetchMore: { + direction, + pageParam, + }, + }, + }); + + const pageIndex = result.pageParams.findIndex((param) => + deepEqual(param, pageParam), + ); + + return result.pages[pageIndex]; + } + + /** + * Invalidate queries serviced by this data service. + * + * @param filters - Optional filter for selecting specific queries. + * @param options - Additional optional options for query invalidations. + * @returns Nothing. + */ + async invalidateQueries( + filters?: InvalidateQueryFilters, + options?: InvalidateOptions, + ): Promise { + return this.#queryClient.invalidateQueries(filters, options); + } + + /** + * Prepares the service for garbage collection. This should be extended + * by any subclasses to clean up any additional connections or events. + */ + protected destroy(): void { + this.#queryCacheUnsubscribe(); + this.messenger.clearSubscriptions(); + this.messenger.clearActions(); + } + + /** + * Publish `cacheUpdated` events when a given query changes. + * + * @param hash The hash of the query. + * @param type The type of cache update. + */ + #publishCacheUpdate(hash: string, type: CacheUpdatedType): void { + const state = + type === 'added' || type === 'updated' + ? dehydrate(this.#queryClient, { + shouldDehydrateQuery: (query) => query.queryHash === hash, + }) + : null; + + this.#messenger.publish( + `${this.name}:cacheUpdated` as const, + { + type, + hash, + state, + } as CacheUpdatedPayload, + ); + + this.#messenger.publish( + `${this.name}:cacheUpdated:${hash}` as const, + { + type, + state, + } as GranularCacheUpdatedPayload, + ); + } +} diff --git a/packages/base-data-service/src/index.test.ts b/packages/base-data-service/src/index.test.ts deleted file mode 100644 index bc062d3694a..00000000000 --- a/packages/base-data-service/src/index.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import greeter from '.'; - -describe('Test', () => { - it('greets', () => { - const name = 'Huey'; - const result = greeter(name); - expect(result).toBe('Hello, Huey!'); - }); -}); diff --git a/packages/base-data-service/src/index.ts b/packages/base-data-service/src/index.ts index 6972c117292..9375eec75b3 100644 --- a/packages/base-data-service/src/index.ts +++ b/packages/base-data-service/src/index.ts @@ -1,9 +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}!`; -} +export type { + CacheUpdatedPayload, + GranularCacheUpdatedPayload, + DataServiceInvalidateQueriesAction, + DataServiceCacheUpdatedEvent, + DataServiceGranularCacheUpdatedEvent, + QueryKey, +} from './BaseDataService'; +export { BaseDataService } from './BaseDataService'; diff --git a/packages/base-data-service/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts new file mode 100644 index 00000000000..e46178a2fd6 --- /dev/null +++ b/packages/base-data-service/tests/ExampleDataService.ts @@ -0,0 +1,145 @@ +import { ConstantBackoff } from '@metamask/controller-utils'; +import { Messenger } from '@metamask/messenger'; +import { CaipAssetId, Duration, inMilliseconds, Json } from '@metamask/utils'; + +import { + BaseDataService, + DataServiceActions, + DataServiceEvents, +} from '../src/BaseDataService'; + +export const serviceName = 'ExampleDataService'; + +export type ExampleDataServiceGetAssetsAction = { + type: `${typeof serviceName}:getAssets`; + handler: ExampleDataService['getAssets']; +}; + +export type ExampleDataServiceGetActivityAction = { + type: `${typeof serviceName}:getActivity`; + handler: ExampleDataService['getActivity']; +}; + +export type ExampleDataServiceActions = + | ExampleDataServiceGetAssetsAction + | ExampleDataServiceGetActivityAction + | DataServiceActions; + +export type ExampleDataServiceEvents = DataServiceEvents; + +export type ExampleMessenger = Messenger< + typeof serviceName, + ExampleDataServiceActions, + ExampleDataServiceEvents +>; + +export type GetAssetsResponse = { + assetId: CaipAssetId; + decimals: number; + name: string; + symbol: string; +}; + +export type GetActivityResponse = { + data: Json[]; + pageInfo: { + count: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string; + endCursor: string; + }; +}; + +export type PageParam = + | { + before: string; + } + | { after: string }; + +export class ExampleDataService extends BaseDataService< + typeof serviceName, + ExampleMessenger +> { + readonly #accountsBaseUrl = 'https://accounts.api.cx.metamask.io'; + + readonly #tokensBaseUrl = 'https://tokens.api.cx.metamask.io'; + + constructor(messenger: ExampleMessenger) { + super({ + name: serviceName, + messenger, + servicePolicyOptions: { + maxRetries: 2, + maxConsecutiveFailures: 3, + backoff: new ConstantBackoff(0), + }, + }); + + messenger.registerActionHandler( + `${this.name}:getAssets`, + this.getAssets.bind(this), + ); + + messenger.registerActionHandler( + `${this.name}:getActivity`, + this.getActivity.bind(this), + ); + } + + async getAssets(assets: string[]): Promise { + return this.fetchQuery({ + queryKey: [`${this.name}:getAssets`, assets], + queryFn: async () => { + const url = new URL( + `${this.#tokensBaseUrl}/v3/assets?assetIds=${assets.join(',')}`, + ); + + const response = await fetch(url); + + return response.json(); + }, + staleTime: inMilliseconds(1, Duration.Day), + cacheTime: 0, // Not recommended in production, just for testing purposes. + }); + } + + async getActivity( + address: string, + page?: PageParam, + ): Promise { + return this.fetchInfiniteQuery( + { + queryKey: [`${this.name}:getActivity`, address], + queryFn: async ({ pageParam }) => { + const caipAddress = `eip155:0:${address.toLowerCase()}`; + const url = new URL( + `${this.#accountsBaseUrl}/v4/multiaccount/transactions?limit=3&accountAddresses=${caipAddress}`, + ); + + if (pageParam?.after) { + url.searchParams.set('after', pageParam.after); + } else if (pageParam?.before) { + url.searchParams.set('before', pageParam.before); + } + + const response = await fetch(url); + + return response.json(); + }, + getPreviousPageParam: ({ pageInfo }) => + pageInfo.hasPreviousPage + ? { before: pageInfo.startCursor } + : undefined, + getNextPageParam: ({ pageInfo }) => + pageInfo.hasNextPage ? { after: pageInfo.endCursor } : undefined, + staleTime: inMilliseconds(5, Duration.Minute), + }, + page, + ); + } + + destroy(): void { + super.destroy(); + } +} diff --git a/packages/base-data-service/tests/mocks.ts b/packages/base-data-service/tests/mocks.ts new file mode 100644 index 00000000000..82341e06721 --- /dev/null +++ b/packages/base-data-service/tests/mocks.ts @@ -0,0 +1,418 @@ +import nock from 'nock'; + +type MockReply = { + status: nock.StatusCode; + body?: nock.Body; +}; + +export function mockAssets(mockReply?: MockReply): nock.Scope { + const reply = mockReply ?? { + status: 200, + body: [ + { + assetId: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + decimals: 18, + name: 'Dai Stablecoin', + symbol: 'DAI', + }, + { + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + decimals: 8, + name: 'Bitcoin', + symbol: 'BTC', + }, + { + assetId: 'eip155:1/slip44:60', + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + ], + }; + + return nock('https://tokens.api.cx.metamask.io:443', { + encodedQueryParams: true, + }) + .get('/v3/assets') + .query({ + assetIds: + 'eip155%3A1%2Fslip44%3A60%2Cbip122%3A000000000019d6689c085ae165831e93%2Fslip44%3A0%2Ceip155%3A1%2Ferc20%3A0x6b175474e89094c44da98b954eedeac495271d0f', + }) + .reply(reply.status, reply.body); +} + +export const TRANSACTIONS_PAGE_2_CURSOR = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTg0NjQ5fQ.btHnBzYlpbZtAA0kgdyZ5rZ-BC91PZyZQPUuXj1jj6M'; + +export const TRANSACTIONS_PAGE_3_START_CURSOR = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6MDY6MDIuMDAwWiIsImhhc1ByZXZpb3VzUGFnZSI6dHJ1ZX0sImVpcDE1NToxMDoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6MDY6MDIuMDAwWiIsImhhc1ByZXZpb3VzUGFnZSI6dHJ1ZX0sImVpcDE1NToxMzc6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjA2OjAyLjAwMFoiLCJoYXNQcmV2aW91c1BhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjA2OjAyLjAwMFoiLCJoYXNQcmV2aW91c1BhZ2UiOnRydWV9LCJlaXAxNTU6NTM0MzUyOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjowNjowMi4wMDBaIiwiaGFzUHJldmlvdXNQYWdlIjp0cnVlfSwiZWlwMTU1OjU2OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjowNjowMi4wMDBaIiwiaGFzUHJldmlvdXNQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjowNjowMi4wMDBaIiwiaGFzUHJldmlvdXNQYWdlIjp0cnVlfSwiZWlwMTU1Ojg0NTM6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjA2OjAyLjAwMFoiLCJoYXNQcmV2aW91c1BhZ2UiOnRydWV9LCJpYXQiOjE3NzIxODQ4MjJ9.mQOxvn8fFy8yLtntxJspuvL0i4A7QoyjGoJOn-XcnJI'; + +export const TRANSACTIONS_PAGE_3_CURSOR = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjU1OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjU1OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTg0NzE4fQ.3bzO_0SLGmIbhN8HoN_JTqaiOOcVqF25U8ftRuth2ow'; + +export function mockTransactionsPage1(mockReply?: MockReply): nock.Scope { + const reply = mockReply ?? { + status: 200, + body: { + data: [ + { + hash: '0xb398bcc8a9287ca18b5a7c4d6f52eaf4ae599d5ac85b860143f5293ed57724fb', + timestamp: '2026-02-07T22:44:17.000Z', + chainId: 8453, + accountId: 'eip155:8453:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 41857455, + blockHash: + '0x6700e8704b880e83081f3dadcf745eb5bb95ffd1c6557ecdd5dc78d0eb310e52', + gas: 20037644, + gasUsed: 19878709, + gasPrice: '3289893', + effectiveGasPrice: '3289893', + nonce: 800, + cumulativeGasUsed: 55796136, + methodId: '0x9ec68f0f', + value: '0', + to: '0x671fdde61d38f00dffb4f8ce8701d0aabb4b405d', + from: '0x6d052d8e0c666ed8011b966d94f240713cf08ea1', + isError: false, + valueTransfers: [ + { + from: '0x671fdde61d38f00dffb4f8ce8701d0aabb4b405d', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: '100000000000000000000', + decimal: 18, + contractAddress: '0x491b67a94ec0a59b81b784f4719d0387c4510c36', + symbol: 'PF', + name: 'Purple Frog', + transferType: 'erc20', + }, + ], + }, + { + hash: '0x8e773bc374095ef6410b40b3c95e898077a30c70a9b74297738c60deb888dc34', + timestamp: '2026-02-02T02:25:59.000Z', + chainId: 1, + accountId: 'eip155:1:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 24366180, + blockHash: + '0x3e057041ce87230e33a95d9dc7b9018bd86d2982c00a9a4d43d2f8ae6e9c5bac', + gas: 16000000, + gasUsed: 13402794, + gasPrice: '93000000', + effectiveGasPrice: '93000000', + nonce: 94, + cumulativeGasUsed: 42756417, + methodId: '0x60806040', + value: '0', + to: '0x0000000000000000000000000000000000000000', + from: '0x07838cbd1a74c6ad20cab35cb464bb36c1c761e3', + isError: false, + valueTransfers: [ + { + from: '0x340eb3a94d7e6802742d0a82c1afe852629f7b08', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: '10000000000000000', + decimal: 18, + contractAddress: '0x94f31ac896c9823d81cf9c2c93feceed4923218f', + symbol: 'YFTE', + name: 'YfTether.io', + transferType: 'erc20', + }, + ], + }, + { + hash: '0x3147f8bf154e854b27b24caf51ecb8e87ba625bb9c6b0bab60ac8f44057defc4', + timestamp: '2026-01-16T20:16:16.000Z', + chainId: 137, + accountId: 'eip155:137:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 81737302, + blockHash: + '0x397ad0a9bde0c50ade4ed009178a6d658abd7ee3fa32e34410e40be970ba0f13', + gas: 119472, + gasUsed: 98586, + gasPrice: '295049518159', + effectiveGasPrice: '295049518159', + nonce: 999, + cumulativeGasUsed: 874735, + methodId: '0xd47e107e', + value: '0', + to: '0xe581b0a826de8c199be934604c1962ee306ba292', + from: '0xca6e515cc0f52a255cb430c3c2e291e0b7c4476a', + isError: false, + valueTransfers: [ + { + from: '0x0000000000000000000000000000000000000000', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + tokenId: '1106', + contractAddress: '0xe581b0a826de8c199be934604c1962ee306ba292', + transferType: 'erc721', + }, + ], + }, + ], + unprocessedNetworks: [], + pageInfo: { + count: 3, + hasNextPage: true, + hasPreviousPage: false, + startCursor: null, + endCursor: TRANSACTIONS_PAGE_2_CURSOR, + }, + }, + }; + return nock('https://accounts.api.cx.metamask.io:443', { + encodedQueryParams: true, + }) + .get('/v4/multiaccount/transactions') + .query({ + limit: '3', + accountAddresses: + 'eip155%3A0%3A0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + }) + .reply(reply.status, reply.body); +} + +export function mockTransactionsPage2(mockReply?: MockReply): nock.Scope { + const reply = mockReply ?? { + status: 200, + body: { + data: [ + { + hash: '0xcecd28aa5bd781ffd2a6d960578ffc6c89ac390e8d02baebc977a827956394e9', + timestamp: '2025-12-29T11:51:08.000Z', + chainId: 56, + accountId: 'eip155:56:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 73342543, + blockHash: + '0xf229f9ef08e817dbcbb53595cb1e3a502107314b0b8b73a5f055770b457cd3f3', + gas: 5825657, + gasUsed: 5778628, + gasPrice: '78650000', + effectiveGasPrice: '78650000', + nonce: 1746, + cumulativeGasUsed: 8070157, + methodId: '0x1239ec8c', + value: '0', + to: '0x72fe31aae72fea4e1f9048a8a3ca580eeba3cd58', + from: '0x053577f23edd3d6bf15fc53db9ca8042d4796fa7', + isError: false, + valueTransfers: [ + { + from: '0x053577f23edd3d6bf15fc53db9ca8042d4796fa7', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: '29006498000000000', + decimal: 18, + contractAddress: '0x18d0e455b3491e09210292d3953157a4bf104444', + symbol: '比特币', + name: '比特币', + transferType: 'erc20', + }, + ], + }, + { + hash: '0xdb40973b60f774a14616e6e2be7af6e426b559d29e25e9b2938b3a733f361b78', + timestamp: '2025-12-22T09:18:48.000Z', + chainId: 56, + accountId: 'eip155:56:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 72524170, + blockHash: + '0xd43d7bb4c06ccfc0ecd172ed08fccacb774ed29e1c58b727687c5b075bc3343d', + gas: 85408, + gasUsed: 56133, + gasPrice: '52330000', + effectiveGasPrice: '52330000', + nonce: 104, + cumulativeGasUsed: 24011496, + methodId: '0xa9059cbb', + value: '0', + to: '0xcba411922349ecd7eec13aac1825b1ddca223fc8', + from: '0x0325f3aa3ef51e24b3f31a0c390e0bc984b5490f', + isError: false, + valueTransfers: [ + { + from: '0x0325f3aa3ef51e24b3f31a0c390e0bc984b5490f', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: '100000000000000000000', + decimal: 18, + contractAddress: '0xcba411922349ecd7eec13aac1825b1ddca223fc8', + symbol: 'MOB', + name: 'MOB', + transferType: 'erc20', + }, + ], + }, + { + hash: '0x07bb21d1937b66aab9dfe1632e4eee9b96e82f54f41f17b3cc4378ec0188af61', + timestamp: '2025-12-14T12:55:16.000Z', + chainId: 56, + accountId: 'eip155:56:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 71620155, + blockHash: + '0xe0e71f46bba84eb4060565b376bc3ede99a45e84fad2e6588bbd003e5e623313', + gas: 30424536, + gasUsed: 3138845, + gasPrice: '50500000', + effectiveGasPrice: '50500000', + nonce: 968, + cumulativeGasUsed: 18618033, + methodId: '0x729ad39e', + value: '0', + to: '0xdd7eb7809d283ae3ffa880183f20e7016ebe8374', + from: '0x6c604c63fb280ca69559f42f6c5a4a4bfcf661d5', + isError: false, + valueTransfers: [ + { + from: '0x6c604c63fb280ca69559f42f6c5a4a4bfcf661d5', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: 1, + tokenId: '0', + contractAddress: '0xdd7eb7809d283ae3ffa880183f20e7016ebe8374', + transferType: 'erc1155', + }, + ], + }, + ], + unprocessedNetworks: [], + pageInfo: { + count: 3, + hasNextPage: true, + hasPreviousPage: false, + startCursor: null, + endCursor: TRANSACTIONS_PAGE_3_CURSOR, + }, + }, + }; + return nock('https://accounts.api.cx.metamask.io:443', { + encodedQueryParams: true, + }) + .get('/v4/multiaccount/transactions') + .query( + (args) => + args.limit === '3' && + args.accountAddresses === + 'eip155:0:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520' && + (args.before === TRANSACTIONS_PAGE_3_START_CURSOR || + args.after === TRANSACTIONS_PAGE_2_CURSOR), + ) + .reply(reply.status, reply.body); +} + +export function mockTransactionsPage3(mockReply?: MockReply): nock.Scope { + const reply = mockReply ?? { + status: 200, + body: { + data: [ + { + hash: '0xb7cec2f0aab8013c0f69a6e8841a565d925e9d9dff39d6f55236ef62df11f2ae', + timestamp: '2025-12-14T12:06:02.000Z', + chainId: 534352, + accountId: 'eip155:534352:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 26534356, + blockHash: + '0xca1eadb6d82aa3ae9ab3dfb4cde81c69537152b54242b5dc53a8f7167beaf68e', + gas: 20000000, + gasUsed: 13860597, + gasPrice: '120118', + effectiveGasPrice: '120118', + nonce: 270515, + cumulativeGasUsed: 13860597, + methodId: '0xc204642c', + value: '0', + to: '0x20cc3197f82c389978d70ec3169eecccf0d63cef', + from: '0x8245637968c2e16e9c28d45067bf6dd4334e6db0', + isError: false, + valueTransfers: [ + { + from: '0xaf061718473fbcfc4315e33cd29ccba0bb3f8ac8', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: 1, + tokenId: '1', + contractAddress: '0x20cc3197f82c389978d70ec3169eecccf0d63cef', + transferType: 'erc1155', + }, + ], + }, + { + hash: '0x0fd46d8c05d0817fbfff845d32a39f1eadb0ced2a10136f9cca3603ab21f577d', + timestamp: '2025-12-14T11:25:35.000Z', + chainId: 1, + accountId: 'eip155:1:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 24010531, + blockHash: + '0x24ffc87ef6dee436018f114a9e1756ea874e3a10c79744465e5f297e03f3b914', + gas: 21000, + gasUsed: 21000, + gasPrice: '20000000000', + effectiveGasPrice: '20000000000', + nonce: 2, + cumulativeGasUsed: 14457098, + methodId: null, + value: '5000000000000000', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + from: '0xc50103d72598734f6d6007cedc5d1d22d227710d', + isError: false, + valueTransfers: [ + { + from: '0xc50103d72598734f6d6007cedc5d1d22d227710d', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: '5000000000000000', + decimal: 18, + transferType: 'normal', + }, + ], + }, + { + hash: '0x136142885cf873cb681cfe2967bc96b28d696b7a5d8b23d00dacd4e395a001b0', + timestamp: '2025-12-13T04:59:23.000Z', + chainId: 1, + accountId: 'eip155:1:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 24001456, + blockHash: + '0x50f4c60b4f7aa5944f0bff7f51e2417afa8ae3ce1a010ed4af5046c85bf01809', + gas: 16000000, + gasUsed: 12517751, + gasPrice: '50000000', + effectiveGasPrice: '50000000', + nonce: 242, + cumulativeGasUsed: 35408463, + methodId: '0x60806040', + value: '0', + to: '0x0000000000000000000000000000000000000000', + from: '0x8c984ec1dea4ecb9ae790ccca1e7ebb92b9631b0', + isError: false, + valueTransfers: [ + { + from: '0xadae2631d69c848698ac4a73a9b1fc38f478fb8a', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: '3682800000000000000', + decimal: 18, + contractAddress: '0xcb696c86917175dfb4f0037ddc4f2e877a9f081a', + symbol: 'MD+', + name: 'MoonDayPlus.com', + transferType: 'erc20', + }, + ], + }, + ], + unprocessedNetworks: [], + pageInfo: { + count: 3, + hasNextPage: true, + hasPreviousPage: true, + startCursor: TRANSACTIONS_PAGE_3_START_CURSOR, + endCursor: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTNUMDQ6NTk6MjMuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xM1QwNDo1OToyMy4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xM1QwNDo1OToyMy4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTEzVDA0OjU5OjIzLjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTEzVDA0OjU5OjIzLjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTNUMDQ6NTk6MjMuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xM1QwNDo1OToyMy4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTNUMDQ6NTk6MjMuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTg0ODIyfQ.-JOxS3Ly3j0XLp9P-PfRHJuzVsHQh6uRzvYJvcW_PGs', + }, + }, + }; + return nock('https://accounts.api.cx.metamask.io:443', { + encodedQueryParams: true, + }) + .get('/v4/multiaccount/transactions') + .query({ + limit: '3', + accountAddresses: + 'eip155%3A0%3A0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + after: TRANSACTIONS_PAGE_3_CURSOR, + }) + .reply(reply.status, reply.body); +} diff --git a/packages/base-data-service/tsconfig.build.json b/packages/base-data-service/tsconfig.build.json index 02a0eea03fe..f83c71b4af8 100644 --- a/packages/base-data-service/tsconfig.build.json +++ b/packages/base-data-service/tsconfig.build.json @@ -5,6 +5,9 @@ "outDir": "./dist", "rootDir": "./src" }, - "references": [], + "references": [ + { "path": "../messenger/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" } + ], "include": ["../../types", "./src"] } diff --git a/packages/base-data-service/tsconfig.json b/packages/base-data-service/tsconfig.json index 025ba2ef7f4..3dbaffd259b 100644 --- a/packages/base-data-service/tsconfig.json +++ b/packages/base-data-service/tsconfig.json @@ -3,6 +3,6 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [], - "include": ["../../types", "./src"] + "references": [{ "path": "../messenger" }, { "path": "../controller-utils" }], + "include": ["../../types", "./src", "./tests"] } diff --git a/packages/react-data-query/CHANGELOG.md b/packages/react-data-query/CHANGELOG.md index b518709c7b8..3cc5491ed05 100644 --- a/packages/react-data-query/CHANGELOG.md +++ b/packages/react-data-query/CHANGELOG.md @@ -7,4 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Initial release ([#8039](https://github.com/MetaMask/core/pull/8039)) + [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/react-data-query/package.json b/packages/react-data-query/package.json index a161bb23099..c9a33812315 100644 --- a/packages/react-data-query/package.json +++ b/packages/react-data-query/package.json @@ -46,6 +46,12 @@ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, + "dependencies": { + "@metamask/base-data-service": "^0.0.0", + "@metamask/utils": "^11.9.0", + "@tanstack/query-core": "^4.43.0", + "@tanstack/react-query": "^4.43.0" + }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", @@ -57,6 +63,11 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-native": "*" + }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/react-data-query/src/createUIQueryClient.test.ts b/packages/react-data-query/src/createUIQueryClient.test.ts new file mode 100644 index 00000000000..18bf7817f80 --- /dev/null +++ b/packages/react-data-query/src/createUIQueryClient.test.ts @@ -0,0 +1,333 @@ +import { Messenger } from '@metamask/messenger'; +import { + hashQueryKey, + InfiniteData, + InfiniteQueryObserver, + QueryClient, + QueryObserver, +} from '@tanstack/query-core'; + +import { createUIQueryClient } from './createUIQueryClient'; +import { + ExampleDataService, + ExampleDataServiceActions, + ExampleDataServiceEvents, + GetActivityResponse, + PageParam, +} from '../../base-data-service/tests/ExampleDataService'; +import { + mockAssets, + mockTransactionsPage1, + mockTransactionsPage2, +} from '../../base-data-service/tests/mocks'; + +const DATA_SERVICES = ['ExampleDataService']; + +function createClients(): { + service: ExampleDataService; + clientA: QueryClient; + clientB: QueryClient; + messenger: Messenger< + 'ExampleDataService', + ExampleDataServiceActions, + ExampleDataServiceEvents + >; +} { + const serviceMessenger = new Messenger< + 'ExampleDataService', + ExampleDataServiceActions, + ExampleDataServiceEvents + >({ namespace: 'ExampleDataService' }); + const service = new ExampleDataService(serviceMessenger); + + const clientA = createUIQueryClient(DATA_SERVICES, serviceMessenger); + const clientB = createUIQueryClient(DATA_SERVICES, serviceMessenger); + + return { service, clientA, clientB, messenger: serviceMessenger }; +} + +const getAssetsQueryKey = [ + 'ExampleDataService:getAssets', + [ + 'eip155:1/slip44:60', + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + ], +]; + +const getActivityQueryKey = [ + 'ExampleDataService:getActivity', + '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520', +]; + +describe('createUIQueryClient', () => { + beforeEach(() => { + mockAssets(); + mockTransactionsPage1(); + mockTransactionsPage2(); + }); + + it('proxies requests to the underlying service', async () => { + const { clientA: client } = createClients(); + + const result = await client.fetchQuery({ + queryKey: getAssetsQueryKey, + }); + + expect(result).toStrictEqual([ + { + assetId: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + decimals: 18, + name: 'Dai Stablecoin', + symbol: 'DAI', + }, + { + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + decimals: 8, + name: 'Bitcoin', + symbol: 'BTC', + }, + { + assetId: 'eip155:1/slip44:60', + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + ]); + }); + + it('fetches using observers', async () => { + const { clientA, clientB } = createClients(); + + const observerA = new QueryObserver(clientA, { + queryKey: getAssetsQueryKey, + }); + + const observerB = new QueryObserver(clientB, { + queryKey: getAssetsQueryKey, + }); + + const promiseA = new Promise((resolve) => { + observerA.subscribe((event) => { + if (event.status === 'success') { + resolve(event.data); + } + }); + }); + + const resultA = await promiseA; + + expect(resultA).toHaveLength(3); + + const promiseB = new Promise((resolve) => { + observerB.subscribe((event) => { + if (event.status === 'success') { + resolve(event.data); + } + }); + }); + + const resultB = await promiseB; + expect(resultA).toStrictEqual(resultB); + + observerA.destroy(); + observerB.destroy(); + }); + + it('synchronizes caches after invalidation', async () => { + const { clientA, clientB } = createClients(); + + const observerA = new QueryObserver(clientA, { + queryKey: getAssetsQueryKey, + }); + + const observerB = new QueryObserver(clientB, { + queryKey: getAssetsQueryKey, + }); + + const promiseA = new Promise((resolve) => { + observerA.subscribe((event) => { + if (event.status === 'success') { + resolve(event.data); + } + }); + }); + + const promiseB = new Promise((resolve) => { + observerB.subscribe((event) => { + if (event.status === 'success') { + resolve(event.data); + } + }); + }); + + await Promise.all([promiseA, promiseB]); + + // Replace the mock response and invalidate + mockAssets({ + status: 200, + body: [], + }); + + await clientA.invalidateQueries(); + + const queryData = clientA.getQueryData(getAssetsQueryKey); + + expect(queryData).toStrictEqual([]); + expect(queryData).toStrictEqual(clientB.getQueryData(getAssetsQueryKey)); + + observerA.destroy(); + observerB.destroy(); + }); + + it('synchronizes cache removal after remove event', async () => { + const { messenger, clientA, clientB } = createClients(); + + const observerA = new QueryObserver(clientA, { + queryKey: getAssetsQueryKey, + }); + + const observerB = new QueryObserver(clientB, { + queryKey: getAssetsQueryKey, + }); + + const promiseA = new Promise((resolve) => { + observerA.subscribe((event) => { + if (event.status === 'success') { + resolve(event.data); + } + }); + }); + + const promiseB = new Promise((resolve) => { + observerB.subscribe((event) => { + if (event.status === 'success') { + resolve(event.data); + } + }); + }); + + await Promise.all([promiseA, promiseB]); + + const hash = hashQueryKey(getAssetsQueryKey); + + messenger.publish(`ExampleDataService:cacheUpdated:${hash}`, { + type: 'removed', + state: null, + }); + + const queryData = clientA.getQueryData(getAssetsQueryKey); + + expect(queryData).toBeUndefined(); + expect(queryData).toStrictEqual(clientB.getQueryData(getAssetsQueryKey)); + + observerA.destroy(); + observerB.destroy(); + }); + + it('fetches using paginated observers', async () => { + const { clientA, clientB } = createClients(); + + const getPreviousPageParam = ({ + pageInfo, + }: GetActivityResponse): PageParam | undefined => + pageInfo.hasPreviousPage ? { before: pageInfo.startCursor } : undefined; + const getNextPageParam = ({ + pageInfo, + }: GetActivityResponse): PageParam | undefined => + pageInfo.hasNextPage ? { after: pageInfo.endCursor } : undefined; + + const observerA = new InfiniteQueryObserver(clientA, { + queryKey: getActivityQueryKey, + getNextPageParam, + getPreviousPageParam, + }); + + const observerB = new InfiniteQueryObserver(clientB, { + queryKey: getActivityQueryKey, + getNextPageParam, + getPreviousPageParam, + }); + + const promiseA = new Promise>( + (resolve) => { + observerA.subscribe((event) => { + if (event.status === 'success') { + resolve(event.data); + } + }); + }, + ); + + const resultA = await promiseA; + + expect(resultA.pages[0].data).toHaveLength(3); + + const promiseB = new Promise>( + (resolve) => { + observerB.subscribe((event) => { + if (event.status === 'success') { + resolve(event.data); + } + }); + }, + ); + + const resultB = await promiseB; + expect(resultA).toStrictEqual(resultB); + + const nextPageResult = await observerA.fetchNextPage(); + expect(nextPageResult.data?.pages).toHaveLength(2); + + expect(clientA.getQueryData(getActivityQueryKey)).toStrictEqual( + clientB.getQueryData(getActivityQueryKey), + ); + + observerA.destroy(); + observerB.destroy(); + }); + + it('errors if observer attempts to use default query function without a data service', async () => { + const { clientA } = createClients(); + + const observer = new QueryObserver(clientA, { + queryKey: ['query'], + retry: false, + }); + + const promise = new Promise((_resolve, reject) => { + observer.subscribe((event) => { + if (event.status === 'error') { + reject(event.error as Error); + } + }); + }); + + await expect(promise).rejects.toThrow( + 'Queries must use data service actions.', + ); + }); + + it('ignores attempts to invalidate non data service queries', async () => { + const { clientA, messenger } = createClients(); + + const spy = jest.spyOn(messenger, 'call'); + + const observer = new QueryObserver(clientA, { + queryKey: ['query'], + retry: false, + }); + + const promise = new Promise((resolve) => { + observer.subscribe(() => { + resolve(); + }); + }); + + await promise; + + await clientA.invalidateQueries({ queryKey: ['query'] }); + + expect(spy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-data-query/src/createUIQueryClient.ts b/packages/react-data-query/src/createUIQueryClient.ts new file mode 100644 index 00000000000..2cf9849f839 --- /dev/null +++ b/packages/react-data-query/src/createUIQueryClient.ts @@ -0,0 +1,166 @@ +import { GranularCacheUpdatedPayload } from '@metamask/base-data-service'; +import { assert, Json } from '@metamask/utils'; +import { + hydrate, + QueryClient, + InvalidateQueryFilters, + InvalidateOptions, + OmitKeyof, + parseFilterArgs, + QueryKey, +} from '@tanstack/query-core'; + +type SubscriptionCallback = (payload: GranularCacheUpdatedPayload) => void; +type JsonSubscriptionCallback = (data: Json) => void; + +// TODO: Figure out if we can replace with a better Messenger type +type MessengerAdapter = { + call: (method: string, ...params: Json[]) => Promise; + subscribe: (method: string, callback: JsonSubscriptionCallback) => void; + unsubscribe: (method: string, callback: JsonSubscriptionCallback) => void; +}; + +/** + * Create a QueryClient queries and subscribes to data services using the messenger. + * + * @param dataServices - A list of data services. + * @param messenger - A messenger adapter. + * @returns The QueryClient. + */ +export function createUIQueryClient( + dataServices: string[], + messenger: MessengerAdapter, +): QueryClient { + const subscriptions = new Map(); + + const getServiceFromQueryKey = (queryKey: QueryKey): string | null => { + try { + const action = queryKey[0]; + assert(typeof action === 'string'); + + const service = action.split(':')[0]; + assert(dataServices.includes(service)); + + return service; + } catch { + return null; + } + }; + + const client: QueryClient = new QueryClient({ + defaultOptions: { + queries: { + queryFn: async (options): Promise => { + const { queryKey } = options; + + const action = queryKey?.[0]; + + assert( + typeof action === 'string', + 'The first element of a query key must be a string.', + ); + + assert( + dataServices.includes(action.split(':')?.[0]), + 'Queries must use data service actions.', + ); + + return (await messenger.call( + action, + ...(options.queryKey.slice(1) as Json[]), + options.pageParam, + )) as Json; + }, + staleTime: 0, + }, + }, + }); + + const cache = client.getQueryCache(); + + cache.subscribe((event) => { + const { query } = event; + + const hash = query.queryHash; + const hasSubscription = subscriptions.has(hash); + const observerCount = query.getObserversCount(); + + const service = getServiceFromQueryKey(query.queryKey); + + if (!service) { + return; + } + + if ( + !hasSubscription && + event.type === 'observerAdded' && + observerCount === 1 + ) { + const cacheListener = (payload: GranularCacheUpdatedPayload): void => { + if (payload.type === 'removed') { + cache.remove(query); + } else { + hydrate(client, payload.state); + } + }; + + subscriptions.set(hash, cacheListener); + messenger.subscribe( + `${service}:cacheUpdated:${hash}`, + cacheListener as unknown as JsonSubscriptionCallback, + ); + } else if ( + event.type === 'observerRemoved' && + observerCount === 0 && + hasSubscription + ) { + const subscriptionListener = subscriptions.get( + hash, + ) as unknown as JsonSubscriptionCallback; + + messenger.unsubscribe( + `${service}:cacheUpdated:${hash}`, + subscriptionListener, + ); + subscriptions.delete(hash); + } + }); + + // Override invalidateQueries to ensure the data service is invalidated as well. + const originalInvalidate = client.invalidateQueries.bind(client); + + // This function is defined in this way to have full support for all function overloads. + client.invalidateQueries = async ( + arg1?: QueryKey | InvalidateQueryFilters, + arg2?: OmitKeyof | InvalidateOptions, + arg3?: InvalidateOptions, + ): Promise => { + const [filters, options] = parseFilterArgs(arg1, arg2, arg3); + + const queries = client.getQueryCache().findAll(filters); + + const services = [ + ...new Set( + queries.map((query) => getServiceFromQueryKey(query.queryKey)), + ), + ]; + + await Promise.all( + services.map(async (service) => { + if (!service) { + return null; + } + + return messenger.call( + `${service}:invalidateQueries`, + filters as Json, + options as Json, + ); + }), + ); + + return originalInvalidate(filters, options); + }; + + return client; +} diff --git a/packages/react-data-query/src/hooks.test.ts b/packages/react-data-query/src/hooks.test.ts new file mode 100644 index 00000000000..e639e82e1e3 --- /dev/null +++ b/packages/react-data-query/src/hooks.test.ts @@ -0,0 +1,27 @@ +import { + useQuery as useQueryTanStack, + useInfiniteQuery as useInfiniteQueryTanStack, +} from '@tanstack/react-query'; + +import { useInfiniteQuery, useQuery } from './hooks'; + +jest.mock('@tanstack/react-query', () => ({ + useQuery: jest.fn(), + useInfiniteQuery: jest.fn(), +})); + +describe('useQuery', () => { + it('calls the underlying TanStack query function', () => { + const options = { queryKey: ['foo'] }; + expect(() => useQuery(options)).not.toThrow(); + expect(useQueryTanStack).toHaveBeenCalledWith(options); + }); +}); + +describe('useInfiniteQuery', () => { + it('calls the underlying TanStack query function', () => { + const options = { queryKey: ['foo'] }; + expect(() => useInfiniteQuery(options)).not.toThrow(); + expect(useInfiniteQueryTanStack).toHaveBeenCalledWith(options); + }); +}); diff --git a/packages/react-data-query/src/hooks.ts b/packages/react-data-query/src/hooks.ts new file mode 100644 index 00000000000..67ea942c764 --- /dev/null +++ b/packages/react-data-query/src/hooks.ts @@ -0,0 +1,68 @@ +import { QueryKey } from '@metamask/base-data-service'; +import { + useQuery as useQueryTanStack, + useInfiniteQuery as useInfiniteQueryTanStack, + OmitKeyof, + UseQueryOptions, + InitialDataFunction, + NonUndefinedGuard, + UseInfiniteQueryOptions, + UseQueryResult, + UseInfiniteQueryResult, +} from '@tanstack/react-query'; + +// We provide re-exports of the underlying TanStack Query hooks with narrower types, +// removing `staleTime` and `queryFn` which aren't useful when using data services. + +/** + * Consume a query from a data service. + * + * @param options - The query options. Keep in mind that `staleTime` and `queryFn` are not supported + * when querying data services. + * @returns The query results. + */ +export function useQuery< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: OmitKeyof< + UseQueryOptions, + 'initialData' | 'staleTime' | 'queryFn' + > & { + initialData?: + | undefined + | InitialDataFunction> + | NonUndefinedGuard; + }, +): UseQueryResult { + return useQueryTanStack(options); +} + +/** + * Consume a paginated query from a data service. + * + * @param options - The query options. Keep in mind that `staleTime` and `queryFn` are not supported + * when querying data services. + * @returns The paginated query results. + */ +export function useInfiniteQuery< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: OmitKeyof< + UseInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey + >, + 'staleTime' | 'queryFn' + >, +): UseInfiniteQueryResult { + return useInfiniteQueryTanStack(options); +} diff --git a/packages/react-data-query/src/index.test.ts b/packages/react-data-query/src/index.test.ts deleted file mode 100644 index bc062d3694a..00000000000 --- a/packages/react-data-query/src/index.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import greeter from '.'; - -describe('Test', () => { - it('greets', () => { - const name = 'Huey'; - const result = greeter(name); - expect(result).toBe('Hello, Huey!'); - }); -}); diff --git a/packages/react-data-query/src/index.ts b/packages/react-data-query/src/index.ts index 6972c117292..f121a7eed64 100644 --- a/packages/react-data-query/src/index.ts +++ b/packages/react-data-query/src/index.ts @@ -1,9 +1,2 @@ -/** - * 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}!`; -} +export { createUIQueryClient } from './createUIQueryClient'; +export { useQuery, useInfiniteQuery } from './hooks'; diff --git a/packages/react-data-query/tsconfig.build.json b/packages/react-data-query/tsconfig.build.json index 02a0eea03fe..3c1d8d64e87 100644 --- a/packages/react-data-query/tsconfig.build.json +++ b/packages/react-data-query/tsconfig.build.json @@ -5,6 +5,6 @@ "outDir": "./dist", "rootDir": "./src" }, - "references": [], + "references": [{ "path": "../base-data-service/tsconfig.build.json" }], "include": ["../../types", "./src"] } diff --git a/packages/react-data-query/tsconfig.json b/packages/react-data-query/tsconfig.json index 025ba2ef7f4..181f238ab73 100644 --- a/packages/react-data-query/tsconfig.json +++ b/packages/react-data-query/tsconfig.json @@ -3,6 +3,6 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [], + "references": [{ "path": "../base-data-service" }], "include": ["../../types", "./src"] } diff --git a/yarn.config.cjs b/yarn.config.cjs index db5c54c7fe3..6321d9137cd 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -24,9 +24,15 @@ const { inspect } = require('util'); * This should trend towards empty. */ const ALLOWED_INCONSISTENT_DEPENDENCIES = { - // '@metamask/json-rpc-engine': ['^9.0.3'], + '@tanstack/query-core': ['^4.43.0'], }; +/** + * These packages are allowed as peer dependencies without requiring installation as + * devDependencies. + */ +const ALLOWED_PEER_DEPENDENCIES = ['react', 'react-dom', 'react-native']; + /** * Aliases for the Yarn type definitions, to make the code more readable. * @@ -747,6 +753,10 @@ function expectPeerDependenciesAlsoListedAsDevDependencies( continue; } + if (ALLOWED_PEER_DEPENDENCIES.includes(dependencyIdent)) { + continue; + } + const dependencyWorkspace = Yarn.workspace({ ident: dependencyIdent }); if (!dependencyWorkspace) { diff --git a/yarn.lock b/yarn.lock index ab107535d6c..730062168a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2981,15 +2981,21 @@ __metadata: languageName: unknown linkType: soft -"@metamask/base-data-service@workspace:packages/base-data-service": +"@metamask/base-data-service@npm:^0.0.0, @metamask/base-data-service@workspace:packages/base-data-service": version: 0.0.0-use.local resolution: "@metamask/base-data-service@workspace:packages/base-data-service" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/controller-utils": "npm:^11.19.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/utils": "npm:^11.9.0" + "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" + fast-deep-equal: "npm:^3.1.3" jest: "npm:^29.7.0" + nock: "npm:^13.3.1" ts-jest: "npm:^29.2.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -4970,6 +4976,10 @@ __metadata: resolution: "@metamask/react-data-query@workspace:packages/react-data-query" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-data-service": "npm:^0.0.0" + "@metamask/utils": "npm:^11.9.0" + "@tanstack/query-core": "npm:^4.43.0" + "@tanstack/react-query": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" @@ -4978,6 +4988,10 @@ __metadata: typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-native: "*" languageName: unknown linkType: soft @@ -6205,6 +6219,13 @@ __metadata: languageName: node linkType: hard +"@tanstack/query-core@npm:4.43.0, @tanstack/query-core@npm:^4.43.0": + version: 4.43.0 + resolution: "@tanstack/query-core@npm:4.43.0" + checksum: 10/c2a5a151c7adaea8311e01a643255f31946ae3164a71567ba80048242821ae14043f13f5516b695baebe5ea7e4b2cf717fd60908a929d18a5c5125fee925ff67 + languageName: node + linkType: hard + "@tanstack/query-core@npm:^5.62.16": version: 5.90.20 resolution: "@tanstack/query-core@npm:5.90.20" @@ -6212,6 +6233,25 @@ __metadata: languageName: node linkType: hard +"@tanstack/react-query@npm:^4.43.0": + version: 4.43.0 + resolution: "@tanstack/react-query@npm:4.43.0" + dependencies: + "@tanstack/query-core": "npm:4.43.0" + use-sync-external-store: "npm:^1.6.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-native: "*" + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + checksum: 10/23f9d18d130fa2a1238d8fba8bc914c67e33753b7fc3a3c7856354a9873c4cbc5d18ce24dbf6364ecf86b8ea787575e1e60998ea75baa2b9e9647ad4b9127e10 + languageName: node + linkType: hard + "@tootallnate/once@npm:2": version: 2.0.0 resolution: "@tootallnate/once@npm:2.0.0" @@ -14416,6 +14456,15 @@ __metadata: languageName: node linkType: hard +"use-sync-external-store@npm:^1.6.0": + version: 1.6.0 + resolution: "use-sync-external-store@npm:1.6.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10/b40ad2847ba220695bff2d4ba4f4d60391c0fb4fb012faa7a4c18eb38b69181936f5edc55a522c4d20a788d1a879b73c3810952c9d0fd128d01cb3f22042c09e + languageName: node + linkType: hard + "utf8@npm:^3.0.0": version: 3.0.0 resolution: "utf8@npm:3.0.0"