diff --git a/packages/base-data-service/package.json b/packages/base-data-service/package.json index 7e49071c90e..f6449084f25 100644 --- a/packages/base-data-service/package.json +++ b/packages/base-data-service/package.json @@ -47,12 +47,19 @@ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, + "dependencies": { + "@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..78fa43abba5 --- /dev/null +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -0,0 +1,108 @@ +import { Messenger } from '@metamask/messenger'; + +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'; + +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([ + 'eip155:1/slip44:60', + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + ]), + ).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); + }); +}); diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts new file mode 100644 index 00000000000..9b0d25cf277 --- /dev/null +++ b/packages/base-data-service/src/BaseDataService.ts @@ -0,0 +1,245 @@ +import { + Messenger, + ActionConstraint, + EventConstraint, +} from '@metamask/messenger'; +import type { Json } from '@metamask/utils'; +import { + DehydratedState, + FetchInfiniteQueryOptions, + FetchQueryOptions, + InfiniteData, + InvalidateOptions, + InvalidateQueryFilters, + QueryClient, + QueryKey, + WithRequired, + dehydrate, + hashQueryKey, +} from '@tanstack/query-core'; +import deepEqual from 'fast-deep-equal'; + +export type SubscriptionPayload = { hash: string; state: DehydratedState }; +export type SubscriptionCallback = (payload: SubscriptionPayload) => void; + +export type DataServiceSubscribeAction = { + type: `${ServiceName}:subscribe`; + handler: ( + queryKey: QueryKey, + callback: SubscriptionCallback, + ) => DehydratedState; +}; + +export type DataServiceUnsubscribeAction = { + type: `${ServiceName}:unsubscribe`; + handler: (queryKey: QueryKey, callback: SubscriptionCallback) => void; +}; + +export type DataServiceInvalidateQueriesAction = { + type: `${ServiceName}:invalidateQueries`; + handler: ( + filters?: InvalidateQueryFilters, + options?: InvalidateOptions, + ) => Promise; +}; + +export type DataServiceActions = + | DataServiceSubscribeAction + | DataServiceUnsubscribeAction + | DataServiceInvalidateQueriesAction; + +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, + never + >; + + readonly #client = new QueryClient(); + + readonly #subscriptions: Map> = new Map(); + + constructor({ + name, + messenger, + }: { + name: ServiceName; + messenger: ServiceMessenger; + }) { + this.name = name; + + this.#messenger = messenger as unknown as Messenger< + ServiceName, + DataServiceActions, + never + >; + + this.#registerMessageHandlers(); + this.#setupCacheListener(); + } + + #registerMessageHandlers(): void { + this.#messenger.registerActionHandler( + `${this.name}:subscribe`, + // @ts-expect-error TODO. + (queryKey: QueryKey, callback: SubscriptionCallback) => + this.#handleSubscribe(queryKey, callback), + ); + + this.#messenger.registerActionHandler( + `${this.name}:unsubscribe`, + // @ts-expect-error TODO. + (queryKey: QueryKey, callback: SubscriptionCallback) => + this.#handleUnsubscribe(queryKey, callback), + ); + + this.#messenger.registerActionHandler( + `${this.name}:invalidateQueries`, + // @ts-expect-error TODO. + (filters?: InvalidateQueryFilters, options?: InvalidateOptions) => + this.invalidateQueries(filters, options), + ); + } + + #setupCacheListener(): void { + this.#client.getQueryCache().subscribe((event) => { + if (this.#subscriptions.has(event.query.queryHash)) { + this.#broadcastQueryState(event.query.queryKey); + } + }); + } + + protected async fetchQuery< + TQueryFnData extends Json, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + >( + options: WithRequired< + FetchQueryOptions, + 'queryKey' | 'queryFn' + >, + ): Promise { + return this.#client.fetchQuery(options); + } + + protected async fetchInfiniteQuery< + TQueryFnData extends Json, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam extends Json = Json, + >( + options: WithRequired< + FetchInfiniteQueryOptions, + 'queryKey' | 'queryFn' + >, + pageParam?: TPageParam, + ): Promise { + const query = this.#client + .getQueryCache() + .find({ queryKey: options.queryKey }); + + if (!query || !pageParam) { + const result = await this.#client.fetchInfiniteQuery({ + ...options, + queryFn: (context) => + options.queryFn({ + ...context, + pageParam: context.pageParam ?? pageParam, + }), + }); + + return result.pages[0]; + } + + const { pages } = query.state.data as InfiniteData; + const previous = options.getPreviousPageParam?.(pages[0], pages); + + const direction = deepEqual(pageParam, previous) ? 'backward' : 'forward'; + + const result = (await query.fetch(undefined, { + meta: { + fetchMore: { + direction, + pageParam, + }, + }, + })) as InfiniteData; + + const pageIndex = result.pageParams.indexOf(pageParam); + + return result.pages[pageIndex]; + } + + protected async invalidateQueries( + filters?: InvalidateQueryFilters, + options?: InvalidateOptions, + ): Promise { + return this.#client.invalidateQueries(filters, options); + } + + // TODO: Determine if this has a better fit with `messenger.publish`. + #handleSubscribe( + queryKey: QueryKey, + subscription: SubscriptionCallback, + ): DehydratedState { + const hash = hashQueryKey(queryKey); + + if (!this.#subscriptions.has(hash)) { + this.#subscriptions.set(hash, new Set()); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.#subscriptions.get(hash)!.add(subscription); + + return this.#getDehydratedStateForQuery(queryKey); + } + + #handleUnsubscribe( + queryKey: QueryKey, + subscription: SubscriptionCallback, + ): void { + const hash = hashQueryKey(queryKey); + const subscribers = this.#subscriptions.get(hash); + + subscribers?.delete(subscription); + if (subscribers?.size === 0) { + this.#subscriptions.delete(hash); + } + } + + #getDehydratedStateForQuery(queryKey: QueryKey): DehydratedState { + const hash = hashQueryKey(queryKey); + return dehydrate(this.#client, { + shouldDehydrateQuery: (query) => query.queryHash === hash, + }); + } + + #broadcastQueryState(queryKey: QueryKey): void { + const hash = hashQueryKey(queryKey); + const state = this.#getDehydratedStateForQuery(queryKey); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const subscribers = this.#subscriptions.get(hash)!; + subscribers.forEach((subscriber) => + subscriber({ + hash, + state, + }), + ); + } +} diff --git a/packages/base-data-service/src/createUIQueryClient.test.ts b/packages/base-data-service/src/createUIQueryClient.test.ts new file mode 100644 index 00000000000..07bb3af8173 --- /dev/null +++ b/packages/base-data-service/src/createUIQueryClient.test.ts @@ -0,0 +1,286 @@ +import { Messenger } from '@metamask/messenger'; +import { Json } from '@metamask/utils'; +import { + DehydratedState, + InfiniteData, + InfiniteQueryObserver, + QueryClient, + QueryKey, + QueryObserver, +} from '@tanstack/query-core'; + +import { SubscriptionCallback, SubscriptionPayload } from './BaseDataService'; +import { createUIQueryClient } from './createUIQueryClient'; +import { + ExampleDataService, + ExampleDataServiceActions, + ExampleMessenger, + GetActivityResponse, + GetAssetsResponse, + PageParam, +} from '../tests/ExampleDataService'; +import { + mockAssets, + mockTransactionsPage1, + mockTransactionsPage2, +} from '../tests/mocks'; + +const DATA_SERVICES = ['ExampleDataService']; + +function createClient(serviceMessenger: ExampleMessenger): QueryClient { + const subscriptions = new Set(); + + const subscription = (payload: SubscriptionPayload): void => { + subscriptions.forEach((callback) => callback(payload)); + }; + + const messengerAdapter = { + call: async ( + method: string, + ...params: Json[] + ): Promise< + void | DehydratedState | GetActivityResponse | GetAssetsResponse + > => { + if (method === 'ExampleDataService:subscribe') { + return serviceMessenger.call( + method, + params[0] as QueryKey, + subscription, + ); + } else if (method === 'ExampleDataService:unsubscribe') { + return serviceMessenger.call( + method, + params[0] as QueryKey, + subscription, + ); + } + return serviceMessenger.call( + method as ExampleDataServiceActions['type'], + // @ts-expect-error TODO. + ...params, + ); + }, + subscribe: async ( + _method: string, + callback: SubscriptionCallback, + ): Promise => { + subscriptions.add(callback); + }, + }; + + return createUIQueryClient(DATA_SERVICES, messengerAdapter); +} + +function createClients(): { + service: ExampleDataService; + clientA: QueryClient; + clientB: QueryClient; +} { + const serviceMessenger = new Messenger< + 'ExampleDataService', + ExampleDataServiceActions + >({ namespace: 'ExampleDataService' }); + const service = new ExampleDataService(serviceMessenger); + + const clientA = createClient(serviceMessenger); + const clientB = createClient(serviceMessenger); + + return { service, clientA, clientB }; +} + +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('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(); + }); +}); diff --git a/packages/base-data-service/src/createUIQueryClient.ts b/packages/base-data-service/src/createUIQueryClient.ts new file mode 100644 index 00000000000..16f6e191cc8 --- /dev/null +++ b/packages/base-data-service/src/createUIQueryClient.ts @@ -0,0 +1,133 @@ +import { assert, Json } from '@metamask/utils'; +import { + hydrate, + QueryClient, + InvalidateQueryFilters, + InvalidateOptions, +} from '@tanstack/query-core'; + +type QueryKey = readonly [string, ...Json[]]; + +function getServiceFromQueryKey(queryKey: QueryKey): string { + return queryKey[0].split(':')[0]; +} + +// When UI messengers are available this should simply be a proper messenger that allows access to DataServiceActions +type MessengerAdapter = { + call: (method: string, ...params: Json[]) => Promise; + subscribe: (method: string, callback: (data: Json) => void) => 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 Set(); + + 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; + }, + // TODO: Decide on values for these. + staleTime: Infinity, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + }, + }, + }); + + client.getQueryCache().subscribe((event) => { + const { query } = event; + + const hash = query.queryHash; + const hasSubscription = subscriptions.has(hash); + const observerCount = query.getObserversCount(); + + const service = getServiceFromQueryKey(query.queryKey); + + if ( + !hasSubscription && + event.type === 'observerAdded' && + observerCount === 1 + ) { + subscriptions.add(hash); + + // This is a bit of a mess because we can't pass functions across the process boundary, so we call subscribe + // but also register listeners for :cacheUpdate which will be sent to subscribed processes + // TODO: Unsubscribe + messenger.subscribe(`${service}:cacheUpdate`, (data) => { + const castData = data as { hash: string; state: Json }; + if (subscriptions.has(castData.hash)) { + hydrate(client, castData.state); + } + }); + + messenger + .call(`${service}:subscribe`, query.queryKey) + .then((state) => hydrate(client, state)) + .catch(console.error); + } else if ( + event.type === 'observerRemoved' && + observerCount === 0 && + hasSubscription + ) { + subscriptions.delete(hash); + messenger + .call(`${service}:unsubscribe`, query.queryKey) + .catch(console.error); + } + }); + + // Override invalidateQueries to ensure the data service is invalidated as well. + const originalInvalidate = client.invalidateQueries.bind(client); + + // @ts-expect-error TODO. + client.invalidateQueries = async ( + filters?: InvalidateQueryFilters, + options?: InvalidateOptions, + ): Promise => { + const queries = client.getQueryCache().findAll(filters); + await Promise.all( + queries.map((query) => { + const service = getServiceFromQueryKey(query.queryKey as QueryKey); + + return messenger.call( + `${service}:invalidateQueries`, + filters as Json, + options as Json, + ); + }), + ); + + return originalInvalidate(filters, options); + }; + + return client; +} 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..cf5db9eb1db 100644 --- a/packages/base-data-service/src/index.ts +++ b/packages/base-data-service/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 * from './BaseDataService'; +export * from './createUIQueryClient'; diff --git a/packages/base-data-service/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts new file mode 100644 index 00000000000..6ed9cfc3e5f --- /dev/null +++ b/packages/base-data-service/tests/ExampleDataService.ts @@ -0,0 +1,128 @@ +import { Messenger } from '@metamask/messenger'; +import { CaipAssetId, Duration, inMilliseconds, Json } from '@metamask/utils'; + +import { BaseDataService, DataServiceActions } 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 ExampleMessenger = Messenger< + typeof serviceName, + ExampleDataServiceActions, + never +>; + +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, + }); + + 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), + }); + } + + 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, + ); + } +} 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..57f3ffc0f9b 100644 --- a/packages/base-data-service/tsconfig.build.json +++ b/packages/base-data-service/tsconfig.build.json @@ -5,6 +5,6 @@ "outDir": "./dist", "rootDir": "./src" }, - "references": [], + "references": [{ "path": "../messenger/tsconfig.build.json" }], "include": ["../../types", "./src"] } diff --git a/packages/base-data-service/tsconfig.json b/packages/base-data-service/tsconfig.json index 025ba2ef7f4..6e77825aa53 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/tsconfig.build.json" }], + "include": ["../../types", "./src", "./tests"] } diff --git a/yarn.lock b/yarn.lock index f0228dfa93b..e42218bd2e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2721,10 +2721,15 @@ __metadata: resolution: "@metamask/base-data-service@workspace:packages/base-data-service" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" + "@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" @@ -5695,6 +5700,13 @@ __metadata: languageName: node linkType: hard +"@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"