From 9a267181f0a2aed0ffee19a2074039cd44e1dbe1 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 10 Feb 2026 15:27:47 +0100 Subject: [PATCH 01/20] Add beginning of BaseDataService --- packages/base-data-service/package.json | 5 + .../src/BaseDataService.test.ts | 59 +++++++ .../base-data-service/src/BaseDataService.ts | 164 ++++++++++++++++++ packages/base-data-service/src/index.test.ts | 9 - packages/base-data-service/src/index.ts | 10 +- yarn.lock | 12 +- 6 files changed, 240 insertions(+), 19 deletions(-) create mode 100644 packages/base-data-service/src/BaseDataService.test.ts create mode 100644 packages/base-data-service/src/BaseDataService.ts delete mode 100644 packages/base-data-service/src/index.test.ts diff --git a/packages/base-data-service/package.json b/packages/base-data-service/package.json index 7e49071c90e..4b6442bebcb 100644 --- a/packages/base-data-service/package.json +++ b/packages/base-data-service/package.json @@ -64,5 +64,10 @@ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" + }, + "dependencies": { + "@metamask/messenger": "workspace:^", + "@metamask/utils": "^11.9.0", + "@tanstack/query-core": "^4.43.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..1e0695e178c --- /dev/null +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -0,0 +1,59 @@ +import { Messenger } from '@metamask/messenger'; +import { BaseDataService } from './BaseDataService'; + +const serviceName = 'ExampleDataService'; + +type ExampleMessenger = Messenger; + +class ExampleDataService extends BaseDataService< + typeof serviceName, + ExampleMessenger +> { + #baseUrl = 'https://accounts.api.cx.metamask.io'; + + constructor(messenger: ExampleMessenger) { + super({ + name: serviceName, + messenger, + }); + + messenger.registerActionHandler( + `${this.name}:getActivity`, + // @ts-expect-error TODO. + this.getActivity.bind(this), + ); + } + + async getActivity(address: string) { + return this.fetchInfiniteQuery({ + queryKey: [`${this.name}:getActivity`, address], + queryFn: async ({ pageParam }) => { + const caipAddress = `eip155:0:${address.toLowerCase()}`; + const url = new URL( + `${this.#baseUrl}/v4/multiaccount/transactions?limit=10&accountAddresses=${caipAddress}`, + ); + + if (pageParam) { + url.searchParams.set('cursor', pageParam); + } + + const response = await fetch(url); + + return response.json(); + }, + getNextPageParam: ({ pageInfo }: { pageInfo: any }) => + pageInfo.hasNextPage ? pageInfo.endCursor : undefined, + }); + } +} + +describe('BaseDataService', () => { + it('works', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + expect( + await service.getActivity('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'), + ).toBe({}); + }); +}); diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts new file mode 100644 index 00000000000..2ad95946e63 --- /dev/null +++ b/packages/base-data-service/src/BaseDataService.ts @@ -0,0 +1,164 @@ +import { + DehydratedState, + FetchInfiniteQueryOptions, + FetchQueryOptions, + InfiniteData, + QueryClient, + QueryKey, + WithRequired, + dehydrate, + hashQueryKey, +} from '@tanstack/query-core'; +import { + Messenger, + ActionConstraint, + EventConstraint, +} from '@metamask/messenger'; +import { Json } from '@metamask/utils'; + +type SubscriptionCallback = (payload: Json) => void; + +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 + >, +> { + name: string; + + #messenger: ServiceMessenger; + + #client = new QueryClient(); + + #subscriptions: Map> = new Map(); + + constructor({ + name, + messenger, + }: { + name: ServiceName; + messenger: ServiceMessenger; + }) { + this.name = name; + this.#messenger = messenger; + + this.#registerMessageHandlers(); + this.#setupCacheListener(); + } + + protected async fetchQuery< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + >( + options: WithRequired< + FetchQueryOptions, + 'queryKey' + >, + ): Promise { + return this.#client.ensureQueryData(options); + } + + protected async fetchInfiniteQuery< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + >( + options: FetchInfiniteQueryOptions, + ): Promise> { + // @ts-expect-error TODO. + return this.#client.ensureQueryData(options); + } + + #registerMessageHandlers() { + this.#messenger.registerActionHandler( + // @ts-expect-error TODO. + `${this.name}:subscribe`, + (queryKey: QueryKey, callback: SubscriptionCallback) => { + return this.#handleSubscribe(queryKey, callback); + }, + ); + + this.#messenger.registerActionHandler( + // @ts-expect-error TODO. + `${this.name}:unsubscribe`, + (queryKey: QueryKey, callback: SubscriptionCallback) => { + return this.#handleUnsubscribe(queryKey, callback); + }, + ); + } + + #setupCacheListener() { + this.#client.getQueryCache().subscribe((event) => { + if (!event.query) { + return; + } + + const queryKeyHash = event.query.queryHash; + + if (this.#subscriptions.has(queryKeyHash)) { + this.#broadcastQueryState(event.query.queryKey); + } + }); + } + + #handleSubscribe( + queryKey: QueryKey, + subscription: SubscriptionCallback, + ): DehydratedState { + const hash = hashQueryKey(queryKey); + + if (!this.#subscriptions.has(hash)) { + this.#subscriptions.set(hash, new Set()); + } + + 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); + + if (!subscribers) { + return; + } + + 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) { + const hash = hashQueryKey(queryKey); + const state = this.#getDehydratedStateForQuery(queryKey); + + const subscribers = this.#subscriptions.get(hash)!; + subscribers.forEach((subscriber) => + subscriber({ + queryKeyHash: hash, + state, + } as unknown as Json), + ); + } +} 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..8b032fc1277 100644 --- a/packages/base-data-service/src/index.ts +++ b/packages/base-data-service/src/index.ts @@ -1,9 +1 @@ -/** - * 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'; diff --git a/yarn.lock b/yarn.lock index f0228dfa93b..e9d5f5e96bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2721,6 +2721,9 @@ __metadata: resolution: "@metamask/base-data-service@workspace:packages/base-data-service" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/messenger": "workspace:^" + "@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" @@ -4002,7 +4005,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/messenger@npm:^0.3.0, @metamask/messenger@workspace:packages/messenger": +"@metamask/messenger@npm:^0.3.0, @metamask/messenger@workspace:^, @metamask/messenger@workspace:packages/messenger": version: 0.0.0-use.local resolution: "@metamask/messenger@workspace:packages/messenger" dependencies: @@ -5695,6 +5698,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" From 1078d79b506584568a7b79c76d6258249e0d4057 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 11 Feb 2026 14:41:39 +0100 Subject: [PATCH 02/20] Support pagination --- .../base-data-service/src/BaseDataService.ts | 52 ++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 2ad95946e63..a0ac4466bea 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -3,18 +3,22 @@ import { FetchInfiniteQueryOptions, FetchQueryOptions, InfiniteData, + InvalidateOptions, + InvalidateQueryFilters, QueryClient, + QueryFunctionContext, QueryKey, WithRequired, dehydrate, hashQueryKey, + infiniteQueryBehavior, } from '@tanstack/query-core'; import { Messenger, ActionConstraint, EventConstraint, } from '@metamask/messenger'; -import { Json } from '@metamask/utils'; +import { assert, Json } from '@metamask/utils'; type SubscriptionCallback = (payload: Json) => void; @@ -60,10 +64,10 @@ export class BaseDataService< >( options: WithRequired< FetchQueryOptions, - 'queryKey' + 'queryKey' | 'queryFn' >, ): Promise { - return this.#client.ensureQueryData(options); + return this.#client.fetchQuery(options); } protected async fetchInfiniteQuery< @@ -72,10 +76,46 @@ export class BaseDataService< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >( - options: FetchInfiniteQueryOptions, + options: WithRequired< + FetchInfiniteQueryOptions, + 'queryKey' | 'queryFn' + >, + context: QueryFunctionContext, ): Promise> { - // @ts-expect-error TODO. - return this.#client.ensureQueryData(options); + assert(context, 'Context must be passed when using fetchInfiniteQuery.'); + + const queryData = await this.#client.ensureQueryData(options); + + if (context.pageParam) { + const query = this.#client + .getQueryCache() + .find({ queryKey: options.queryKey })!; + + return query.fetch({ + ...options, + behavior: { + onFetch: (fetchContext) => { + // Combine fetchContext with passed context, that may come from UI. + fetchContext.fetchFn = () => + fetchContext.options.queryFn({ + queryKey: fetchContext.queryKey, + signal: fetchContext.signal, + meta: context.meta, + pageParam: context.pageParam, + }); + }, + }, + }); + } + + return queryData; + } + + protected async invalidateQueries( + filters?: InvalidateQueryFilters, + options?: InvalidateOptions, + ): Promise { + return this.#client.invalidateQueries(filters, options); } #registerMessageHandlers() { From a3dfff28931499c8f8a17c4c8d84fab10ec4ba86 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 11 Feb 2026 15:14:42 +0100 Subject: [PATCH 03/20] Improve pagination --- .../base-data-service/src/BaseDataService.ts | 42 +++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index a0ac4466bea..daeb3ee6400 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -81,34 +81,32 @@ export class BaseDataService< 'queryKey' | 'queryFn' >, context: QueryFunctionContext, - ): Promise> { + ): Promise { assert(context, 'Context must be passed when using fetchInfiniteQuery.'); - const queryData = await this.#client.ensureQueryData(options); - - if (context.pageParam) { - const query = this.#client - .getQueryCache() - .find({ queryKey: options.queryKey })!; - - return query.fetch({ - ...options, - behavior: { - onFetch: (fetchContext) => { - // Combine fetchContext with passed context, that may come from UI. - fetchContext.fetchFn = () => - fetchContext.options.queryFn({ - queryKey: fetchContext.queryKey, - signal: fetchContext.signal, - meta: context.meta, - pageParam: context.pageParam, - }); + const query = this.#client + .getQueryCache() + .find({ queryKey: options.queryKey }); + + if (query && context.pageParam) { + const result = (await query.fetch(undefined, { + meta: { + // TODO: Determine if this breaks when fetching backwards. + fetchMore: { + direction: 'forward', + pageParam: context.pageParam, }, }, - }); + })) as InfiniteData; + + const pageIndex = result.pageParams.indexOf(context.pageParam); + + return result.pages[pageIndex]; } - return queryData; + const result = await this.#client.fetchInfiniteQuery(options); + + return result.pages[0]; } protected async invalidateQueries( From 63cf50dd2ab59dd5ba8a308033c0ae30a3d83131 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 11 Feb 2026 15:52:47 +0100 Subject: [PATCH 04/20] Add invalidateQueries action --- packages/base-data-service/src/BaseDataService.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index daeb3ee6400..043f34feca0 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -11,7 +11,6 @@ import { WithRequired, dehydrate, hashQueryKey, - infiniteQueryBehavior, } from '@tanstack/query-core'; import { Messenger, @@ -132,6 +131,12 @@ export class BaseDataService< return this.#handleUnsubscribe(queryKey, callback); }, ); + + this.#messenger.registerActionHandler( + // @ts-expect-error TODO. + `${this.name}:invalidateQueries`, + this.invalidateQueries.bind(this), + ); } #setupCacheListener() { From 1983e054be99a96925df7f967e9b3a8349069d61 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Fri, 13 Feb 2026 15:12:56 +0100 Subject: [PATCH 05/20] Account for fetch direction --- .../base-data-service/src/BaseDataService.ts | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 043f34feca0..a63d18ccee4 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -6,7 +6,6 @@ import { InvalidateOptions, InvalidateQueryFilters, QueryClient, - QueryFunctionContext, QueryKey, WithRequired, dehydrate, @@ -17,7 +16,7 @@ import { ActionConstraint, EventConstraint, } from '@metamask/messenger'; -import { assert, Json } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; type SubscriptionCallback = (payload: Json) => void; @@ -74,31 +73,36 @@ export class BaseDataService< TError = unknown, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, >( options: WithRequired< FetchInfiniteQueryOptions, 'queryKey' | 'queryFn' >, - context: QueryFunctionContext, + pageParam?: TPageParam, ): Promise { - assert(context, 'Context must be passed when using fetchInfiniteQuery.'); - const query = this.#client .getQueryCache() .find({ queryKey: options.queryKey }); - if (query && context.pageParam) { + if (query && pageParam) { + const pages = + (query.state.data as InfiniteData | undefined)?.pages ?? + []; + const previous = options.getPreviousPageParam?.(pages[0], pages); + + const direction = pageParam === previous ? 'backward' : 'forward'; + const result = (await query.fetch(undefined, { meta: { - // TODO: Determine if this breaks when fetching backwards. fetchMore: { - direction: 'forward', - pageParam: context.pageParam, + direction, + pageParam, }, }, })) as InfiniteData; - const pageIndex = result.pageParams.indexOf(context.pageParam); + const pageIndex = result.pageParams.indexOf(pageParam); return result.pages[pageIndex]; } From c590958321cfc2cd67b2fbcece98c3863f2cfbd5 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 25 Feb 2026 14:29:34 +0100 Subject: [PATCH 06/20] Follow conventions for referencing local packages --- packages/base-data-service/package.json | 2 +- packages/base-data-service/tsconfig.build.json | 4 +++- packages/base-data-service/tsconfig.json | 4 +++- yarn.lock | 4 ++-- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/base-data-service/package.json b/packages/base-data-service/package.json index 4b6442bebcb..d5c3892474b 100644 --- a/packages/base-data-service/package.json +++ b/packages/base-data-service/package.json @@ -66,7 +66,7 @@ "registry": "https://registry.npmjs.org/" }, "dependencies": { - "@metamask/messenger": "workspace:^", + "@metamask/messenger": "^0.3.0", "@metamask/utils": "^11.9.0", "@tanstack/query-core": "^4.43.0" } diff --git a/packages/base-data-service/tsconfig.build.json b/packages/base-data-service/tsconfig.build.json index 02a0eea03fe..fa04f31e471 100644 --- a/packages/base-data-service/tsconfig.build.json +++ b/packages/base-data-service/tsconfig.build.json @@ -5,6 +5,8 @@ "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..b5fdcac3bf3 100644 --- a/packages/base-data-service/tsconfig.json +++ b/packages/base-data-service/tsconfig.json @@ -3,6 +3,8 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [], + "references": [ + { "path": "../messenger/tsconfig.build.json" } + ], "include": ["../../types", "./src"] } diff --git a/yarn.lock b/yarn.lock index e9d5f5e96bb..c64b62ca4a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2721,7 +2721,7 @@ __metadata: resolution: "@metamask/base-data-service@workspace:packages/base-data-service" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/messenger": "workspace:^" + "@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" @@ -4005,7 +4005,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/messenger@npm:^0.3.0, @metamask/messenger@workspace:^, @metamask/messenger@workspace:packages/messenger": +"@metamask/messenger@npm:^0.3.0, @metamask/messenger@workspace:packages/messenger": version: 0.0.0-use.local resolution: "@metamask/messenger@workspace:packages/messenger" dependencies: From 07fee5a3bd1b53066af9286bcd341da421396956 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 25 Feb 2026 15:52:13 +0100 Subject: [PATCH 07/20] Improve typing --- packages/base-data-service/package.json | 10 +- .../base-data-service/src/BaseDataService.ts | 125 +++++++++++------- .../base-data-service/tsconfig.build.json | 4 +- packages/base-data-service/tsconfig.json | 4 +- 4 files changed, 85 insertions(+), 58 deletions(-) diff --git a/packages/base-data-service/package.json b/packages/base-data-service/package.json index d5c3892474b..60309f2f01d 100644 --- a/packages/base-data-service/package.json +++ b/packages/base-data-service/package.json @@ -47,6 +47,11 @@ "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" + }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", @@ -64,10 +69,5 @@ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" - }, - "dependencies": { - "@metamask/messenger": "^0.3.0", - "@metamask/utils": "^11.9.0", - "@tanstack/query-core": "^4.43.0" } } diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index a63d18ccee4..1624161946d 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -18,7 +18,31 @@ import { } from '@metamask/messenger'; import type { Json } from '@metamask/utils'; -type SubscriptionCallback = (payload: Json) => void; +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, @@ -32,9 +56,13 @@ export class BaseDataService< any >, > { - name: string; + public readonly name: ServiceName; - #messenger: ServiceMessenger; + #messenger: Messenger< + ServiceName, + DataServiceActions, + never + >; #client = new QueryClient(); @@ -48,14 +76,54 @@ export class BaseDataService< messenger: ServiceMessenger; }) { this.name = name; - this.#messenger = messenger; + + this.#messenger = messenger as unknown as Messenger< + ServiceName, + DataServiceActions, + never + >; this.#registerMessageHandlers(); this.#setupCacheListener(); } + #registerMessageHandlers() { + 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() { + this.#client.getQueryCache().subscribe((event) => { + if (!event.query) { + return; + } + + const queryKeyHash = event.query.queryHash; + + if (this.#subscriptions.has(queryKeyHash)) { + this.#broadcastQueryState(event.query.queryKey); + } + }); + } + protected async fetchQuery< - TQueryFnData = unknown, + TQueryFnData extends Json, TError = unknown, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, @@ -69,7 +137,7 @@ export class BaseDataService< } protected async fetchInfiniteQuery< - TQueryFnData = unknown, + TQueryFnData extends Json, TError = unknown, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, @@ -112,51 +180,14 @@ export class BaseDataService< return result.pages[0]; } - protected async invalidateQueries( + protected async invalidateQueries( filters?: InvalidateQueryFilters, options?: InvalidateOptions, ): Promise { return this.#client.invalidateQueries(filters, options); } - #registerMessageHandlers() { - this.#messenger.registerActionHandler( - // @ts-expect-error TODO. - `${this.name}:subscribe`, - (queryKey: QueryKey, callback: SubscriptionCallback) => { - return this.#handleSubscribe(queryKey, callback); - }, - ); - - this.#messenger.registerActionHandler( - // @ts-expect-error TODO. - `${this.name}:unsubscribe`, - (queryKey: QueryKey, callback: SubscriptionCallback) => { - return this.#handleUnsubscribe(queryKey, callback); - }, - ); - - this.#messenger.registerActionHandler( - // @ts-expect-error TODO. - `${this.name}:invalidateQueries`, - this.invalidateQueries.bind(this), - ); - } - - #setupCacheListener() { - this.#client.getQueryCache().subscribe((event) => { - if (!event.query) { - return; - } - - const queryKeyHash = event.query.queryHash; - - if (this.#subscriptions.has(queryKeyHash)) { - this.#broadcastQueryState(event.query.queryKey); - } - }); - } - + // TODO: Determine if this has a better fit with `messenger.publish`. #handleSubscribe( queryKey: QueryKey, subscription: SubscriptionCallback, @@ -203,9 +234,9 @@ export class BaseDataService< const subscribers = this.#subscriptions.get(hash)!; subscribers.forEach((subscriber) => subscriber({ - queryKeyHash: hash, + hash, state, - } as unknown as Json), + }), ); } } diff --git a/packages/base-data-service/tsconfig.build.json b/packages/base-data-service/tsconfig.build.json index fa04f31e471..57f3ffc0f9b 100644 --- a/packages/base-data-service/tsconfig.build.json +++ b/packages/base-data-service/tsconfig.build.json @@ -5,8 +5,6 @@ "outDir": "./dist", "rootDir": "./src" }, - "references": [ - { "path": "../messenger/tsconfig.build.json" } - ], + "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 b5fdcac3bf3..f8d51c32616 100644 --- a/packages/base-data-service/tsconfig.json +++ b/packages/base-data-service/tsconfig.json @@ -3,8 +3,6 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [ - { "path": "../messenger/tsconfig.build.json" } - ], + "references": [{ "path": "../messenger/tsconfig.build.json" }], "include": ["../../types", "./src"] } From dbaad590e52f130ada46020f23b039b49fe4f21b Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 25 Feb 2026 15:56:42 +0100 Subject: [PATCH 08/20] Bring test over from other branch --- .../src/BaseDataService.test.ts | 84 ++++++++++++++++--- 1 file changed, 74 insertions(+), 10 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 1e0695e178c..3b1b861aea7 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -1,5 +1,6 @@ import { Messenger } from '@metamask/messenger'; import { BaseDataService } from './BaseDataService'; +import { Json } from '@metamask/utils'; const serviceName = 'ExampleDataService'; @@ -9,7 +10,8 @@ class ExampleDataService extends BaseDataService< typeof serviceName, ExampleMessenger > { - #baseUrl = 'https://accounts.api.cx.metamask.io'; + #accountsBaseUrl = 'https://accounts.api.cx.metamask.io'; + #tokensBaseUrl = 'https://tokens.api.cx.metamask.io'; constructor(messenger: ExampleMessenger) { super({ @@ -17,6 +19,12 @@ class ExampleDataService extends BaseDataService< messenger, }); + messenger.registerActionHandler( + `${this.name}:getAssets`, + // @ts-expect-error TODO. + this.getAssets.bind(this), + ); + messenger.registerActionHandler( `${this.name}:getActivity`, // @ts-expect-error TODO. @@ -24,13 +32,28 @@ class ExampleDataService extends BaseDataService< ); } - async getActivity(address: string) { - return this.fetchInfiniteQuery({ + async getAssets(assets: string[]) { + 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(); + }, + }); + } + + async getActivity(address: string, pageParam?: string) { + return this.fetchInfiniteQuery<{ data: Json; pageInfo: { hasNextPage: boolean; endCursor: string } }>({ queryKey: [`${this.name}:getActivity`, address], queryFn: async ({ pageParam }) => { const caipAddress = `eip155:0:${address.toLowerCase()}`; const url = new URL( - `${this.#baseUrl}/v4/multiaccount/transactions?limit=10&accountAddresses=${caipAddress}`, + `${this.#accountsBaseUrl}/v4/multiaccount/transactions?limit=10&accountAddresses=${caipAddress}`, ); if (pageParam) { @@ -41,19 +64,60 @@ class ExampleDataService extends BaseDataService< return response.json(); }, - getNextPageParam: ({ pageInfo }: { pageInfo: any }) => + getNextPageParam: ({ pageInfo }) => pageInfo.hasNextPage ? pageInfo.endCursor : undefined, - }); + }, pageParam); } } describe('BaseDataService', () => { - it('works', async () => { + it('handles basic queries', async () => { const messenger = new Messenger({ namespace: serviceName }); const service = new ExampleDataService(messenger); expect( - await service.getActivity('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'), - ).toBe({}); + await service.getAssets([ + 'eip155:1/slip44:60', + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + ]), + ).toStrictEqual([ + { + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + decimals: 8, + name: 'Bitcoin', + symbol: 'BTC', + }, + { + assetId: 'eip155:1/slip44:60', + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + { + assetId: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + decimals: 18, + name: 'Dai Stablecoin', + symbol: 'DAI', + }, + ]); + }); + + it('handles paginated queries', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + const page1 = await service.getActivity( + '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + ); + + // expect(page1.data).toStrictEqual([]); + + const page2 = await service.getActivity( + '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + page1.pageInfo.endCursor, + ); + + expect(page2.data).not.toStrictEqual(page1.data); }); -}); +}); \ No newline at end of file From 76dc9e9904ab4cdb30535b5250cc6b7a4b3365d2 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 25 Feb 2026 16:58:24 +0100 Subject: [PATCH 09/20] Add example types --- .../src/BaseDataService.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 3b1b861aea7..0ddc1076662 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -4,7 +4,19 @@ import { Json } from '@metamask/utils'; const serviceName = 'ExampleDataService'; -type ExampleMessenger = Messenger; +type ExampleDataServiceGetAssetsAction = { + type: `${typeof serviceName}:getAssets`; + handler: ExampleDataService['getAssets']; +}; + +type ExampleDataServiceGetActivityAction = { + type: `${typeof serviceName}:getActivity`; + handler: ExampleDataService['getActivity'] +}; + +export type ExampleDataServiceActions = ExampleDataServiceGetAssetsAction | ExampleDataServiceGetActivityAction; + +type ExampleMessenger = Messenger; class ExampleDataService extends BaseDataService< typeof serviceName, @@ -21,13 +33,11 @@ class ExampleDataService extends BaseDataService< messenger.registerActionHandler( `${this.name}:getAssets`, - // @ts-expect-error TODO. this.getAssets.bind(this), ); messenger.registerActionHandler( `${this.name}:getActivity`, - // @ts-expect-error TODO. this.getActivity.bind(this), ); } From edb719d7b53ddc8788cf2754f15e136f4d2fb5b2 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 26 Feb 2026 11:11:40 +0100 Subject: [PATCH 10/20] Add createUIQueryClient + lint --- .../src/BaseDataService.test.ts | 66 +++++---- .../base-data-service/src/BaseDataService.ts | 51 ++++--- .../src/createUIQueryClient.ts | 132 ++++++++++++++++++ 3 files changed, 201 insertions(+), 48 deletions(-) create mode 100644 packages/base-data-service/src/createUIQueryClient.ts diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 0ddc1076662..07127c8ae88 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -1,7 +1,8 @@ import { Messenger } from '@metamask/messenger'; -import { BaseDataService } from './BaseDataService'; import { Json } from '@metamask/utils'; +import { BaseDataService } from './BaseDataService'; + const serviceName = 'ExampleDataService'; type ExampleDataServiceGetAssetsAction = { @@ -11,19 +12,26 @@ type ExampleDataServiceGetAssetsAction = { type ExampleDataServiceGetActivityAction = { type: `${typeof serviceName}:getActivity`; - handler: ExampleDataService['getActivity'] + handler: ExampleDataService['getActivity']; }; -export type ExampleDataServiceActions = ExampleDataServiceGetAssetsAction | ExampleDataServiceGetActivityAction; +type ExampleDataServiceActions = + | ExampleDataServiceGetAssetsAction + | ExampleDataServiceGetActivityAction; -type ExampleMessenger = Messenger; +type ExampleMessenger = Messenger< + typeof serviceName, + ExampleDataServiceActions, + never +>; class ExampleDataService extends BaseDataService< typeof serviceName, ExampleMessenger > { - #accountsBaseUrl = 'https://accounts.api.cx.metamask.io'; - #tokensBaseUrl = 'https://tokens.api.cx.metamask.io'; + readonly #accountsBaseUrl = 'https://accounts.api.cx.metamask.io'; + + readonly #tokensBaseUrl = 'https://tokens.api.cx.metamask.io'; constructor(messenger: ExampleMessenger) { super({ @@ -57,26 +65,32 @@ class ExampleDataService extends BaseDataService< }); } - async getActivity(address: string, pageParam?: string) { - return this.fetchInfiniteQuery<{ data: Json; pageInfo: { hasNextPage: boolean; endCursor: string } }>({ - queryKey: [`${this.name}:getActivity`, address], - queryFn: async ({ pageParam }) => { - const caipAddress = `eip155:0:${address.toLowerCase()}`; - const url = new URL( - `${this.#accountsBaseUrl}/v4/multiaccount/transactions?limit=10&accountAddresses=${caipAddress}`, - ); - - if (pageParam) { - url.searchParams.set('cursor', pageParam); - } - - const response = await fetch(url); - - return response.json(); + async getActivity(address: string, page?: string) { + return this.fetchInfiniteQuery<{ + data: Json; + pageInfo: { hasNextPage: boolean; endCursor: string }; + }>( + { + queryKey: [`${this.name}:getActivity`, address], + queryFn: async ({ pageParam }) => { + const caipAddress = `eip155:0:${address.toLowerCase()}`; + const url = new URL( + `${this.#accountsBaseUrl}/v4/multiaccount/transactions?limit=10&accountAddresses=${caipAddress}`, + ); + + if (pageParam) { + url.searchParams.set('cursor', pageParam); + } + + const response = await fetch(url); + + return response.json(); + }, + getNextPageParam: ({ pageInfo }) => + pageInfo.hasNextPage ? pageInfo.endCursor : undefined, }, - getNextPageParam: ({ pageInfo }) => - pageInfo.hasNextPage ? pageInfo.endCursor : undefined, - }, pageParam); + page, + ); } } @@ -130,4 +144,4 @@ describe('BaseDataService', () => { expect(page2.data).not.toStrictEqual(page1.data); }); -}); \ No newline at end of file +}); diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 1624161946d..9ee678c7860 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -1,3 +1,9 @@ +import { + Messenger, + ActionConstraint, + EventConstraint, +} from '@metamask/messenger'; +import type { Json } from '@metamask/utils'; import { DehydratedState, FetchInfiniteQueryOptions, @@ -11,19 +17,16 @@ import { dehydrate, hashQueryKey, } from '@tanstack/query-core'; -import { - Messenger, - ActionConstraint, - EventConstraint, -} from '@metamask/messenger'; -import type { Json } from '@metamask/utils'; -export type SubscriptionPayload = { hash: string, state: DehydratedState }; +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; + handler: ( + queryKey: QueryKey, + callback: SubscriptionCallback, + ) => DehydratedState; }; export type DataServiceUnsubscribeAction = { @@ -36,13 +39,13 @@ export type DataServiceInvalidateQueriesAction = { handler: ( filters?: InvalidateQueryFilters, options?: InvalidateOptions, - ) => Promise + ) => Promise; }; export type DataServiceActions = - DataServiceSubscribeAction | - DataServiceUnsubscribeAction | - DataServiceInvalidateQueriesAction + | DataServiceSubscribeAction + | DataServiceUnsubscribeAction + | DataServiceInvalidateQueriesAction; export class BaseDataService< ServiceName extends string, @@ -58,15 +61,15 @@ export class BaseDataService< > { public readonly name: ServiceName; - #messenger: Messenger< + readonly #messenger: Messenger< ServiceName, DataServiceActions, never >; - #client = new QueryClient(); + readonly #client = new QueryClient(); - #subscriptions: Map> = new Map(); + readonly #subscriptions: Map> = new Map(); constructor({ name, @@ -87,28 +90,30 @@ export class BaseDataService< this.#setupCacheListener(); } - #registerMessageHandlers() { + #registerMessageHandlers(): void { this.#messenger.registerActionHandler( `${this.name}:subscribe`, // @ts-expect-error TODO. - (queryKey: QueryKey, callback: SubscriptionCallback) => this.#handleSubscribe(queryKey, callback), + (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), + (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), + (filters?: InvalidateQueryFilters, options?: InvalidateOptions) => + this.invalidateQueries(filters, options), ); } - #setupCacheListener() { + #setupCacheListener(): void { this.#client.getQueryCache().subscribe((event) => { if (!event.query) { return; @@ -198,6 +203,7 @@ export class BaseDataService< 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); @@ -227,10 +233,11 @@ export class BaseDataService< }); } - #broadcastQueryState(queryKey: QueryKey) { + #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({ diff --git a/packages/base-data-service/src/createUIQueryClient.ts b/packages/base-data-service/src/createUIQueryClient.ts new file mode 100644 index 00000000000..64b7d7b7996 --- /dev/null +++ b/packages/base-data-service/src/createUIQueryClient.ts @@ -0,0 +1,132 @@ +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]; +} + +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 potentialAction = queryKey?.[0]; + + assert( + typeof potentialAction === 'string', + 'The first element of a query key must be a string.', + ); + assert( + dataServices.includes(potentialAction?.split(':')?.[0]), + 'Queries must use data service actions.', + ); + + return await messenger.call( + potentialAction, + options as unknown as Json, + ); + }, + // TODO: Decide on values for these. + staleTime: Infinity, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + }, + }, + }); + + client.getQueryCache().subscribe((event) => { + const { query } = event; + if (!query) { + return; + } + + 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); + + // Lazily subscribe to the cache updates broadcast by the data service + 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; +} From d7c46da2e0b90d1ce7629f16815d53fd53ffe571 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 26 Feb 2026 13:05:53 +0100 Subject: [PATCH 11/20] Improve tests --- packages/base-data-service/package.json | 1 + .../src/BaseDataService.test.ts | 112 +----- .../src/createUIQueryClient.test.ts | 194 +++++++++ .../src/createUIQueryClient.ts | 18 +- .../tests/ExampleDataService.ts | 97 +++++ packages/base-data-service/tests/mocks.ts | 369 ++++++++++++++++++ packages/base-data-service/tsconfig.json | 2 +- yarn.lock | 1 + 8 files changed, 685 insertions(+), 109 deletions(-) create mode 100644 packages/base-data-service/src/createUIQueryClient.test.ts create mode 100644 packages/base-data-service/tests/ExampleDataService.ts create mode 100644 packages/base-data-service/tests/mocks.ts diff --git a/packages/base-data-service/package.json b/packages/base-data-service/package.json index 60309f2f01d..711be7cefa9 100644 --- a/packages/base-data-service/package.json +++ b/packages/base-data-service/package.json @@ -58,6 +58,7 @@ "@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 index 07127c8ae88..b0c9da88b1e 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -1,100 +1,14 @@ import { Messenger } from '@metamask/messenger'; -import { Json } from '@metamask/utils'; -import { BaseDataService } from './BaseDataService'; - -const serviceName = 'ExampleDataService'; - -type ExampleDataServiceGetAssetsAction = { - type: `${typeof serviceName}:getAssets`; - handler: ExampleDataService['getAssets']; -}; - -type ExampleDataServiceGetActivityAction = { - type: `${typeof serviceName}:getActivity`; - handler: ExampleDataService['getActivity']; -}; - -type ExampleDataServiceActions = - | ExampleDataServiceGetAssetsAction - | ExampleDataServiceGetActivityAction; - -type ExampleMessenger = Messenger< - typeof serviceName, - ExampleDataServiceActions, - never ->; - -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[]) { - 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(); - }, - }); - } - - async getActivity(address: string, page?: string) { - return this.fetchInfiniteQuery<{ - data: Json; - pageInfo: { hasNextPage: boolean; endCursor: string }; - }>( - { - queryKey: [`${this.name}:getActivity`, address], - queryFn: async ({ pageParam }) => { - const caipAddress = `eip155:0:${address.toLowerCase()}`; - const url = new URL( - `${this.#accountsBaseUrl}/v4/multiaccount/transactions?limit=10&accountAddresses=${caipAddress}`, - ); - - if (pageParam) { - url.searchParams.set('cursor', pageParam); - } - - const response = await fetch(url); - - return response.json(); - }, - getNextPageParam: ({ pageInfo }) => - pageInfo.hasNextPage ? pageInfo.endCursor : undefined, - }, - page, - ); - } -} +import { ExampleDataService, serviceName } from '../tests/ExampleDataService'; +import { mockAssets, mockTransactions } from '../tests/mocks'; describe('BaseDataService', () => { + beforeEach(() => { + mockAssets(); + mockTransactions(); + }); + it('handles basic queries', async () => { const messenger = new Messenger({ namespace: serviceName }); const service = new ExampleDataService(messenger); @@ -106,6 +20,12 @@ describe('BaseDataService', () => { 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', ]), ).toStrictEqual([ + { + assetId: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + decimals: 18, + name: 'Dai Stablecoin', + symbol: 'DAI', + }, { assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', decimals: 8, @@ -118,12 +38,6 @@ describe('BaseDataService', () => { name: 'Ethereum', symbol: 'ETH', }, - { - assetId: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', - decimals: 18, - name: 'Dai Stablecoin', - symbol: 'DAI', - }, ]); }); 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..beef61b4a03 --- /dev/null +++ b/packages/base-data-service/src/createUIQueryClient.test.ts @@ -0,0 +1,194 @@ +import { Messenger } from '@metamask/messenger'; +import { Json } from '@metamask/utils'; +import { QueryClient, QueryKey, QueryObserver } from '@tanstack/query-core'; + +import { SubscriptionCallback, SubscriptionPayload } from './BaseDataService'; +import { createUIQueryClient } from './createUIQueryClient'; +import { + ExampleDataService, + ExampleDataServiceActions, + ExampleMessenger, +} from '../tests/ExampleDataService'; +import { mockAssets } 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: ExampleDataServiceActions['type'], + ...params: Json[] + ) => { + 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, ...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', + ], +]; + +describe('createUIQueryClient', () => { + beforeEach(() => { + mockAssets(); + }); + + 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(); + }); +}); diff --git a/packages/base-data-service/src/createUIQueryClient.ts b/packages/base-data-service/src/createUIQueryClient.ts index 64b7d7b7996..5ae34febe10 100644 --- a/packages/base-data-service/src/createUIQueryClient.ts +++ b/packages/base-data-service/src/createUIQueryClient.ts @@ -36,20 +36,21 @@ export function createUIQueryClient( queryFn: async (options): Promise => { const { queryKey } = options; - const potentialAction = queryKey?.[0]; + const action = queryKey?.[0]; assert( - typeof potentialAction === 'string', + typeof action === 'string', 'The first element of a query key must be a string.', ); assert( - dataServices.includes(potentialAction?.split(':')?.[0]), + dataServices.includes(action?.split(':')?.[0]), 'Queries must use data service actions.', ); return await messenger.call( - potentialAction, - options as unknown as Json, + action, + ...(options.queryKey.slice(1) as Json[]), + options.pageParam, ); }, // TODO: Decide on values for these. @@ -63,9 +64,6 @@ export function createUIQueryClient( client.getQueryCache().subscribe((event) => { const { query } = event; - if (!query) { - return; - } const hash = query.queryHash; const hasSubscription = subscriptions.has(hash); @@ -80,7 +78,9 @@ export function createUIQueryClient( ) { subscriptions.add(hash); - // Lazily subscribe to the cache updates broadcast by the data service + // 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)) { diff --git a/packages/base-data-service/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts new file mode 100644 index 00000000000..18dcabbe67a --- /dev/null +++ b/packages/base-data-service/tests/ExampleDataService.ts @@ -0,0 +1,97 @@ +import { Messenger } from '@metamask/messenger'; +import { 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 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[]) { + 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?: string) { + return this.fetchInfiniteQuery<{ + data: Json; + pageInfo: { hasNextPage: boolean; endCursor: string }; + }>( + { + queryKey: [`${this.name}:getActivity`, address], + queryFn: async ({ pageParam }) => { + const caipAddress = `eip155:0:${address.toLowerCase()}`; + const url = new URL( + `${this.#accountsBaseUrl}/v4/multiaccount/transactions?limit=10&accountAddresses=${caipAddress}`, + ); + + if (pageParam) { + url.searchParams.set('cursor', pageParam); + } + + const response = await fetch(url); + + return response.json(); + }, + getNextPageParam: ({ pageInfo }) => + pageInfo.hasNextPage ? pageInfo.endCursor : undefined, + }, + 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..e3521fa2c6f --- /dev/null +++ b/packages/base-data-service/tests/mocks.ts @@ -0,0 +1,369 @@ +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 function mockTransactions(mockReply?: MockReply): nock.Scope { + const reply = mockReply ?? { + status: 200, + body: { + data: [ + { + hash: '0x3fd0f0989c8307347492afd11e8f14929fe726e23939b2aec7c806658d7b96c8', + timestamp: '2026-02-26T10:20:49.000Z', + chainId: 8453, + accountId: 'eip155:8453:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + blockNumber: 42655951, + blockHash: + '0x0d950aa2dd400111cd70def5beeeb4e005a6c06b294a1b84e1ae2a2d082e2c4c', + gas: 63681, + gasUsed: 21062, + gasPrice: '18814867', + effectiveGasPrice: '18814867', + nonce: 9070, + cumulativeGasUsed: 32355593, + methodId: null, + value: '30000000000000', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + from: '0xfa783aa578a0f2d21756c5c6c5403494302a1eb1', + isError: false, + valueTransfers: [ + { + from: '0xfa783aa578a0f2d21756c5c6c5403494302a1eb1', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + amount: '30000000000000', + decimal: 18, + transferType: 'normal', + }, + ], + }, + { + hash: '0xa3e4916122815850f8aa6c0bdb8f9b075be3d2caa103003f955dfdf2816acf47', + timestamp: '2026-02-26T08:27:23.000Z', + chainId: 1, + accountId: 'eip155:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + blockNumber: 24539890, + blockHash: + '0xb69dd1d970207a8da49cb52fc1a0351cc39b61142646d8381c04656936fb07d2', + gas: 25473, + gasUsed: 21062, + gasPrice: '62203661', + effectiveGasPrice: '62203661', + nonce: 13, + cumulativeGasUsed: 23622367, + methodId: null, + value: '1000000000000000', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', + isError: false, + valueTransfers: [ + { + from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + amount: '1000000000000000', + decimal: 18, + transferType: 'normal', + }, + ], + }, + { + hash: '0x95060cbd9d9d049c73da8e81b8f1349e561a1edd209d12693f9e771cba4bed04', + timestamp: '2026-02-26T08:26:59.000Z', + chainId: 1, + accountId: 'eip155:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + blockNumber: 24539888, + blockHash: + '0x8ca8ee27bbe0fe86d522a7639fed9ed39ed32aec0e234c4d3d84f91170119e09', + gas: 25473, + gasUsed: 21062, + gasPrice: '59049965', + effectiveGasPrice: '59049965', + nonce: 11, + cumulativeGasUsed: 32992820, + methodId: null, + value: '1000000000000000', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', + isError: false, + valueTransfers: [ + { + from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + amount: '1000000000000000', + decimal: 18, + transferType: 'normal', + }, + ], + }, + { + hash: '0xd62d9036c6774e60955860ebdd8263bb2e04ea1d9f8a091203b8e450edd972a9', + timestamp: '2026-02-26T08:26:35.000Z', + chainId: 1, + accountId: 'eip155:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + blockNumber: 24539886, + blockHash: + '0xde57db8d6318c1720abfeacbf07752f40ca70249578c9e28ca128436c25aded8', + gas: 25473, + gasUsed: 21062, + gasPrice: '59204928', + effectiveGasPrice: '59204928', + nonce: 10, + cumulativeGasUsed: 33005174, + methodId: null, + value: '100000000000000', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', + isError: false, + valueTransfers: [ + { + from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + amount: '100000000000000', + decimal: 18, + transferType: 'normal', + }, + ], + }, + { + hash: '0xa4f6ba45916ce0398da55fb2be4a603c19d0a7bc692edac71970a76a854a769a', + timestamp: '2026-02-26T08:17:11.000Z', + chainId: 1, + accountId: 'eip155:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + blockNumber: 24539839, + blockHash: + '0x4a92b6ae4d949a5f732ef0ff481dc61e00d8c129ec2eec9d26614e57b8f2c9d7', + gas: 25473, + gasUsed: 21062, + gasPrice: '41188673', + effectiveGasPrice: '41188673', + nonce: 8, + cumulativeGasUsed: 51579752, + methodId: null, + value: '1000000000000000', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', + isError: false, + valueTransfers: [ + { + from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + amount: '1000000000000000', + decimal: 18, + transferType: 'normal', + }, + ], + }, + { + hash: '0x8ef436c3847ca66207fa7f1d903e0366f907701f63219042fca1540bf9af8fbb', + timestamp: '2026-02-26T08:16:11.000Z', + chainId: 1, + accountId: 'eip155:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + blockNumber: 24539834, + blockHash: + '0x18275f949d3eb177977250c34b87cbd5ed2ebc28d067f7cdb618a5620ed79928', + gas: 25473, + gasUsed: 21062, + gasPrice: '42372579', + effectiveGasPrice: '42372579', + nonce: 6, + cumulativeGasUsed: 53840387, + methodId: null, + value: '1000000000000000', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', + isError: false, + valueTransfers: [ + { + from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + amount: '1000000000000000', + decimal: 18, + transferType: 'normal', + }, + ], + }, + { + hash: '0x4e11fd71425b8aef394a427a60908394391ed01391223eaab5bcb47527b9ed95', + timestamp: '2026-02-26T06:19:41.000Z', + chainId: 8453, + accountId: 'eip155:8453:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + blockNumber: 42648717, + blockHash: + '0x49bf09a1b790b59c07210518d86f1572d174bf01c573dae5defd314471513faa', + gas: 100000, + gasUsed: 21062, + gasPrice: '6599218', + effectiveGasPrice: '6599218', + nonce: 488, + cumulativeGasUsed: 16806862, + methodId: null, + value: '99880000', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + from: '0xf0e9286cfcb75c94ac19e99bcd93d814da55e304', + isError: false, + valueTransfers: [ + { + from: '0xf0e9286cfcb75c94ac19e99bcd93d814da55e304', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + amount: '99880000', + decimal: 18, + transferType: 'normal', + }, + ], + }, + { + hash: '0xa0993bcb4b1fe0877c1eb2b3414291fb4a94560fa191ddab5d4946f9ca6a173a', + timestamp: '2026-02-26T04:51:47.000Z', + chainId: 1, + accountId: 'eip155:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + blockNumber: 24538816, + blockHash: + '0x7a628393adbe09117e48d298b676a73108d40ab96e54345c87b027508a956851', + gas: 31841, + gasUsed: 21062, + gasPrice: '158983234', + effectiveGasPrice: '158983234', + nonce: 10, + cumulativeGasUsed: 18661650, + methodId: null, + value: '100000000000', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + from: '0xffe90d7897d56ef6c2a5953da34558015cccc85a', + isError: false, + valueTransfers: [ + { + from: '0xffe90d7897d56ef6c2a5953da34558015cccc85a', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + amount: '100000000000', + decimal: 18, + transferType: 'normal', + }, + ], + }, + { + hash: '0xea79462c31d6bf0a96409f5b49fa6a02464b45a48f5b3192329c2ea1887de57a', + timestamp: '2026-02-26T04:19:13.000Z', + chainId: 8453, + accountId: 'eip155:8453:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + blockNumber: 42645103, + blockHash: + '0x62ddcb78dc7abd9e6bca07c9a105afffb3d708b553ea74bcaac7c9b02b745fa9', + gas: 359969, + gasUsed: 237236, + gasPrice: '6004066', + effectiveGasPrice: '6004066', + nonce: 752, + cumulativeGasUsed: 31492867, + methodId: '0x01020400', + value: '0', + to: '0x0000000000006ac72ed1d192fa28f0058d3f8806', + from: '0xc723f2c210c4d29cfe35209340a6fb766d956982', + isError: false, + valueTransfers: [ + { + from: '0xe6ede73fa975b5a2f8daf2a51945addee6413df5', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + amount: '150000', + decimal: 6, + contractAddress: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + symbol: 'USDC', + name: 'USD Coin', + transferType: 'erc20', + }, + ], + }, + { + hash: '0xd98b2afab4bb65ef6d8b5f0c726192f180a906f27c960c9dad11c60041474738', + timestamp: '2026-02-26T02:38:01.000Z', + chainId: 8453, + accountId: 'eip155:8453:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + blockNumber: 42642067, + blockHash: + '0xf612c40cd76c5e8b4140b5235130c8ceb23f10464b20e7c5c314037aa9e82f82', + gas: 341543, + gasUsed: 225000, + gasPrice: '6493425', + effectiveGasPrice: '6493425', + nonce: 11197, + cumulativeGasUsed: 25787493, + methodId: '0x01020400', + value: '0', + to: '0x0000000000006ac72ed1d192fa28f0058d3f8806', + from: '0x52fba7915b2b37f85100b543af54fba499228846', + isError: false, + valueTransfers: [ + { + from: '0x74753ad4f1e5b1f0c25725f50796bb530636a912', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + amount: '150000', + decimal: 6, + contractAddress: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + symbol: 'USDC', + name: 'USD Coin', + transferType: 'erc20', + }, + ], + }, + ], + unprocessedNetworks: [ + 'eip155:137:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + ], + pageInfo: { + count: 10, + hasNextPage: true, + hasPreviousPage: false, + startCursor: null, + endCursor: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweGQ4ZGE2YmYyNjk2NGFmOWQ3ZWVkOWUwM2U1MzQxNWQzN2FhOTYwNDUiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDItMjZUMDI6Mzg6MDEuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4ZDhkYTZiZjI2OTY0YWY5ZDdlZWQ5ZTAzZTUzNDE1ZDM3YWE5NjA0NSI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMi0yNlQwMjozODowMS4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTMyOToweGQ4ZGE2YmYyNjk2NGFmOWQ3ZWVkOWUwM2U1MzQxNWQzN2FhOTYwNDUiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDItMjZUMDI6Mzg6MDEuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjE0MzoweGQ4ZGE2YmYyNjk2NGFmOWQ3ZWVkOWUwM2U1MzQxNWQzN2FhOTYwNDUiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDItMjZUMDI6Mzg6MDEuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjQyMTYxOjB4ZDhkYTZiZjI2OTY0YWY5ZDdlZWQ5ZTAzZTUzNDE1ZDM3YWE5NjA0NSI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMi0yNlQwMjozODowMS4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NTM0MzUyOjB4ZDhkYTZiZjI2OTY0YWY5ZDdlZWQ5ZTAzZTUzNDE1ZDM3YWE5NjA0NSI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMi0yNlQwMjozODowMS4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NTY6MHhkOGRhNmJmMjY5NjRhZjlkN2VlZDllMDNlNTM0MTVkMzdhYTk2MDQ1Ijp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAyLTI2VDAyOjM4OjAxLjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1OTE0NDoweGQ4ZGE2YmYyNjk2NGFmOWQ3ZWVkOWUwM2U1MzQxNWQzN2FhOTYwNDUiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDItMjZUMDI6Mzg6MDEuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1Ojg0NTM6MHhkOGRhNmJmMjY5NjRhZjlkN2VlZDllMDNlNTM0MTVkMzdhYTk2MDQ1Ijp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAyLTI2VDAyOjM4OjAxLjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo5OTk6MHhkOGRhNmJmMjY5NjRhZjlkN2VlZDllMDNlNTM0MTVkMzdhYTk2MDQ1Ijp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAyLTI2VDAyOjM4OjAxLjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImlhdCI6MTc3MjEwMjAyNX0.NYLYAQ-7pTPd01t5Nz1VxP5tMBZvHOPf2PXZw7VInpM', + }, + }, + }; + return nock('https://accounts.api.cx.metamask.io:443', { + encodedQueryParams: true, + }) + .get('/v4/multiaccount/transactions') + .query({ + limit: '10', + accountAddresses: + 'eip155%3A0%3A0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + }) + .reply(reply.status, reply.body); +} diff --git a/packages/base-data-service/tsconfig.json b/packages/base-data-service/tsconfig.json index f8d51c32616..6e77825aa53 100644 --- a/packages/base-data-service/tsconfig.json +++ b/packages/base-data-service/tsconfig.json @@ -4,5 +4,5 @@ "baseUrl": "./" }, "references": [{ "path": "../messenger/tsconfig.build.json" }], - "include": ["../../types", "./src"] + "include": ["../../types", "./src", "./tests"] } diff --git a/yarn.lock b/yarn.lock index c64b62ca4a0..6ca034d7e1f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2728,6 +2728,7 @@ __metadata: "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" 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" From 0efc387ca5bed074a2b792fb22f6fa778b47205d Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 26 Feb 2026 13:08:08 +0100 Subject: [PATCH 12/20] Add export --- packages/base-data-service/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/base-data-service/src/index.ts b/packages/base-data-service/src/index.ts index 8b032fc1277..cf5db9eb1db 100644 --- a/packages/base-data-service/src/index.ts +++ b/packages/base-data-service/src/index.ts @@ -1 +1,2 @@ export * from './BaseDataService'; +export * from './createUIQueryClient'; From 66bdfa5eda81319663c5500d1144471616172e88 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 26 Feb 2026 13:45:06 +0100 Subject: [PATCH 13/20] Fix pagination test --- .../src/BaseDataService.test.ts | 19 +- .../base-data-service/src/BaseDataService.ts | 16 +- .../src/createUIQueryClient.test.ts | 13 +- .../tests/ExampleDataService.ts | 11 +- packages/base-data-service/tests/mocks.ts | 416 +++++++----------- 5 files changed, 200 insertions(+), 275 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index b0c9da88b1e..032a4a7afbb 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -1,12 +1,19 @@ import { Messenger } from '@metamask/messenger'; import { ExampleDataService, serviceName } from '../tests/ExampleDataService'; -import { mockAssets, mockTransactions } from '../tests/mocks'; +import { + mockAssets, + mockTransactionsPage1, + mockTransactionsPage2, +} from '../tests/mocks'; + +const TEST_ADDRESS = '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520'; describe('BaseDataService', () => { beforeEach(() => { mockAssets(); - mockTransactions(); + mockTransactionsPage1(); + mockTransactionsPage2(); }); it('handles basic queries', async () => { @@ -41,18 +48,16 @@ describe('BaseDataService', () => { ]); }); - it('handles paginated queries', async () => { + it.only('handles paginated queries', async () => { const messenger = new Messenger({ namespace: serviceName }); const service = new ExampleDataService(messenger); - const page1 = await service.getActivity( - '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', - ); + const page1 = await service.getActivity(TEST_ADDRESS); // expect(page1.data).toStrictEqual([]); const page2 = await service.getActivity( - '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + TEST_ADDRESS, page1.pageInfo.endCursor, ); diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 9ee678c7860..2eb4a202bfa 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -115,13 +115,7 @@ export class BaseDataService< #setupCacheListener(): void { this.#client.getQueryCache().subscribe((event) => { - if (!event.query) { - return; - } - - const queryKeyHash = event.query.queryHash; - - if (this.#subscriptions.has(queryKeyHash)) { + if (this.#subscriptions.has(event.query.queryHash)) { this.#broadcastQueryState(event.query.queryKey); } }); @@ -216,12 +210,8 @@ export class BaseDataService< const hash = hashQueryKey(queryKey); const subscribers = this.#subscriptions.get(hash); - if (!subscribers) { - return; - } - - subscribers.delete(subscription); - if (subscribers.size === 0) { + subscribers?.delete(subscription); + if (subscribers?.size === 0) { this.#subscriptions.delete(hash); } } diff --git a/packages/base-data-service/src/createUIQueryClient.test.ts b/packages/base-data-service/src/createUIQueryClient.test.ts index beef61b4a03..cf151ae287a 100644 --- a/packages/base-data-service/src/createUIQueryClient.test.ts +++ b/packages/base-data-service/src/createUIQueryClient.test.ts @@ -40,7 +40,10 @@ function createClient(serviceMessenger: ExampleMessenger): QueryClient { } return serviceMessenger.call(method, ...params); }, - subscribe: async (_method: string, callback: SubscriptionCallback): Promise => { + subscribe: async ( + _method: string, + callback: SubscriptionCallback, + ): Promise => { subscriptions.add(callback); }, }; @@ -177,15 +180,15 @@ describe('createUIQueryClient', () => { // Replace the mock response and invalidate mockAssets({ - status: 200, - body: [], - }) + status: 200, + body: [], + }); await clientA.invalidateQueries(); const queryData = clientA.getQueryData(getAssetsQueryKey); - expect(queryData).toStrictEqual([]) + expect(queryData).toStrictEqual([]); expect(queryData).toStrictEqual(clientB.getQueryData(getAssetsQueryKey)); observerA.destroy(); diff --git a/packages/base-data-service/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts index 18dcabbe67a..0af6ea3728c 100644 --- a/packages/base-data-service/tests/ExampleDataService.ts +++ b/packages/base-data-service/tests/ExampleDataService.ts @@ -70,14 +70,19 @@ export class ExampleDataService extends BaseDataService< async getActivity(address: string, page?: string) { return this.fetchInfiniteQuery<{ data: Json; - pageInfo: { hasNextPage: boolean; endCursor: string }; + pageInfo: { + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string; + endCursor: string; + }; }>( { queryKey: [`${this.name}:getActivity`, address], queryFn: async ({ pageParam }) => { const caipAddress = `eip155:0:${address.toLowerCase()}`; const url = new URL( - `${this.#accountsBaseUrl}/v4/multiaccount/transactions?limit=10&accountAddresses=${caipAddress}`, + `${this.#accountsBaseUrl}/v4/multiaccount/transactions?limit=3&accountAddresses=${caipAddress}`, ); if (pageParam) { @@ -88,6 +93,8 @@ export class ExampleDataService extends BaseDataService< return response.json(); }, + getPreviousPageParam: ({ pageInfo }) => + pageInfo.hasPreviousPage ? pageInfo.startCursor : undefined, getNextPageParam: ({ pageInfo }) => pageInfo.hasNextPage ? pageInfo.endCursor : undefined, }, diff --git a/packages/base-data-service/tests/mocks.ts b/packages/base-data-service/tests/mocks.ts index e3521fa2c6f..a99d8d0e877 100644 --- a/packages/base-data-service/tests/mocks.ts +++ b/packages/base-data-service/tests/mocks.ts @@ -41,318 +41,236 @@ export function mockAssets(mockReply?: MockReply): nock.Scope { .reply(reply.status, reply.body); } -export function mockTransactions(mockReply?: MockReply): nock.Scope { +export function mockTransactionsPage1(mockReply?: MockReply): nock.Scope { const reply = mockReply ?? { status: 200, body: { data: [ { - hash: '0x3fd0f0989c8307347492afd11e8f14929fe726e23939b2aec7c806658d7b96c8', - timestamp: '2026-02-26T10:20:49.000Z', + hash: '0xb398bcc8a9287ca18b5a7c4d6f52eaf4ae599d5ac85b860143f5293ed57724fb', + timestamp: '2026-02-07T22:44:17.000Z', chainId: 8453, - accountId: 'eip155:8453:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - blockNumber: 42655951, + accountId: 'eip155:8453:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 41857455, blockHash: - '0x0d950aa2dd400111cd70def5beeeb4e005a6c06b294a1b84e1ae2a2d082e2c4c', - gas: 63681, - gasUsed: 21062, - gasPrice: '18814867', - effectiveGasPrice: '18814867', - nonce: 9070, - cumulativeGasUsed: 32355593, - methodId: null, - value: '30000000000000', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - from: '0xfa783aa578a0f2d21756c5c6c5403494302a1eb1', - isError: false, - valueTransfers: [ - { - from: '0xfa783aa578a0f2d21756c5c6c5403494302a1eb1', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - amount: '30000000000000', - decimal: 18, - transferType: 'normal', - }, - ], - }, - { - hash: '0xa3e4916122815850f8aa6c0bdb8f9b075be3d2caa103003f955dfdf2816acf47', - timestamp: '2026-02-26T08:27:23.000Z', - chainId: 1, - accountId: 'eip155:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - blockNumber: 24539890, - blockHash: - '0xb69dd1d970207a8da49cb52fc1a0351cc39b61142646d8381c04656936fb07d2', - gas: 25473, - gasUsed: 21062, - gasPrice: '62203661', - effectiveGasPrice: '62203661', - nonce: 13, - cumulativeGasUsed: 23622367, - methodId: null, - value: '1000000000000000', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', - isError: false, - valueTransfers: [ - { - from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - amount: '1000000000000000', - decimal: 18, - transferType: 'normal', - }, - ], - }, - { - hash: '0x95060cbd9d9d049c73da8e81b8f1349e561a1edd209d12693f9e771cba4bed04', - timestamp: '2026-02-26T08:26:59.000Z', - chainId: 1, - accountId: 'eip155:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - blockNumber: 24539888, - blockHash: - '0x8ca8ee27bbe0fe86d522a7639fed9ed39ed32aec0e234c4d3d84f91170119e09', - gas: 25473, - gasUsed: 21062, - gasPrice: '59049965', - effectiveGasPrice: '59049965', - nonce: 11, - cumulativeGasUsed: 32992820, - methodId: null, - value: '1000000000000000', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', - isError: false, - valueTransfers: [ - { - from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - amount: '1000000000000000', - decimal: 18, - transferType: 'normal', - }, - ], - }, - { - hash: '0xd62d9036c6774e60955860ebdd8263bb2e04ea1d9f8a091203b8e450edd972a9', - timestamp: '2026-02-26T08:26:35.000Z', - chainId: 1, - accountId: 'eip155:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - blockNumber: 24539886, - blockHash: - '0xde57db8d6318c1720abfeacbf07752f40ca70249578c9e28ca128436c25aded8', - gas: 25473, - gasUsed: 21062, - gasPrice: '59204928', - effectiveGasPrice: '59204928', - nonce: 10, - cumulativeGasUsed: 33005174, - methodId: null, - value: '100000000000000', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', - isError: false, - valueTransfers: [ - { - from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - amount: '100000000000000', - decimal: 18, - transferType: 'normal', - }, - ], - }, - { - hash: '0xa4f6ba45916ce0398da55fb2be4a603c19d0a7bc692edac71970a76a854a769a', - timestamp: '2026-02-26T08:17:11.000Z', - chainId: 1, - accountId: 'eip155:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - blockNumber: 24539839, - blockHash: - '0x4a92b6ae4d949a5f732ef0ff481dc61e00d8c129ec2eec9d26614e57b8f2c9d7', - gas: 25473, - gasUsed: 21062, - gasPrice: '41188673', - effectiveGasPrice: '41188673', - nonce: 8, - cumulativeGasUsed: 51579752, - methodId: null, - value: '1000000000000000', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', + '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: '0x5931f36512899f6519aecd95f7189b817ab63ad9', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - amount: '1000000000000000', + from: '0x671fdde61d38f00dffb4f8ce8701d0aabb4b405d', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: '100000000000000000000', decimal: 18, - transferType: 'normal', + contractAddress: '0x491b67a94ec0a59b81b784f4719d0387c4510c36', + symbol: 'PF', + name: 'Purple Frog', + transferType: 'erc20', }, ], }, { - hash: '0x8ef436c3847ca66207fa7f1d903e0366f907701f63219042fca1540bf9af8fbb', - timestamp: '2026-02-26T08:16:11.000Z', + hash: '0x8e773bc374095ef6410b40b3c95e898077a30c70a9b74297738c60deb888dc34', + timestamp: '2026-02-02T02:25:59.000Z', chainId: 1, - accountId: 'eip155:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - blockNumber: 24539834, + accountId: 'eip155:1:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 24366180, blockHash: - '0x18275f949d3eb177977250c34b87cbd5ed2ebc28d067f7cdb618a5620ed79928', - gas: 25473, - gasUsed: 21062, - gasPrice: '42372579', - effectiveGasPrice: '42372579', - nonce: 6, - cumulativeGasUsed: 53840387, - methodId: null, - value: '1000000000000000', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', + '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: '0x5931f36512899f6519aecd95f7189b817ab63ad9', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - amount: '1000000000000000', + from: '0x340eb3a94d7e6802742d0a82c1afe852629f7b08', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: '10000000000000000', decimal: 18, - transferType: 'normal', + contractAddress: '0x94f31ac896c9823d81cf9c2c93feceed4923218f', + symbol: 'YFTE', + name: 'YfTether.io', + transferType: 'erc20', }, ], }, { - hash: '0x4e11fd71425b8aef394a427a60908394391ed01391223eaab5bcb47527b9ed95', - timestamp: '2026-02-26T06:19:41.000Z', - chainId: 8453, - accountId: 'eip155:8453:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - blockNumber: 42648717, + hash: '0x3147f8bf154e854b27b24caf51ecb8e87ba625bb9c6b0bab60ac8f44057defc4', + timestamp: '2026-01-16T20:16:16.000Z', + chainId: 137, + accountId: 'eip155:137:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 81737302, blockHash: - '0x49bf09a1b790b59c07210518d86f1572d174bf01c573dae5defd314471513faa', - gas: 100000, - gasUsed: 21062, - gasPrice: '6599218', - effectiveGasPrice: '6599218', - nonce: 488, - cumulativeGasUsed: 16806862, - methodId: null, - value: '99880000', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - from: '0xf0e9286cfcb75c94ac19e99bcd93d814da55e304', + '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: '0xf0e9286cfcb75c94ac19e99bcd93d814da55e304', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - amount: '99880000', - decimal: 18, - transferType: 'normal', + from: '0x0000000000000000000000000000000000000000', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + tokenId: '1106', + contractAddress: '0xe581b0a826de8c199be934604c1962ee306ba292', + transferType: 'erc721', }, ], }, + ], + unprocessedNetworks: [], + pageInfo: { + count: 3, + hasNextPage: true, + hasPreviousPage: false, + startCursor: null, + endCursor: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTA4NzM4fQ.rlFQKUlm5rJjHynbXffMKzWw36qFva91GBcjOwjwPOw', + }, + }, + }; + 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: '0xa0993bcb4b1fe0877c1eb2b3414291fb4a94560fa191ddab5d4946f9ca6a173a', - timestamp: '2026-02-26T04:51:47.000Z', - chainId: 1, - accountId: 'eip155:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - blockNumber: 24538816, + hash: '0xcecd28aa5bd781ffd2a6d960578ffc6c89ac390e8d02baebc977a827956394e9', + timestamp: '2025-12-29T11:51:08.000Z', + chainId: 56, + accountId: 'eip155:56:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 73342543, blockHash: - '0x7a628393adbe09117e48d298b676a73108d40ab96e54345c87b027508a956851', - gas: 31841, - gasUsed: 21062, - gasPrice: '158983234', - effectiveGasPrice: '158983234', - nonce: 10, - cumulativeGasUsed: 18661650, - methodId: null, - value: '100000000000', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - from: '0xffe90d7897d56ef6c2a5953da34558015cccc85a', + '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: '0xffe90d7897d56ef6c2a5953da34558015cccc85a', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - amount: '100000000000', + from: '0x053577f23edd3d6bf15fc53db9ca8042d4796fa7', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: '29006498000000000', decimal: 18, - transferType: 'normal', + contractAddress: '0x18d0e455b3491e09210292d3953157a4bf104444', + symbol: '比特币', + name: '比特币', + transferType: 'erc20', }, ], }, { - hash: '0xea79462c31d6bf0a96409f5b49fa6a02464b45a48f5b3192329c2ea1887de57a', - timestamp: '2026-02-26T04:19:13.000Z', - chainId: 8453, - accountId: 'eip155:8453:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - blockNumber: 42645103, + hash: '0xdb40973b60f774a14616e6e2be7af6e426b559d29e25e9b2938b3a733f361b78', + timestamp: '2025-12-22T09:18:48.000Z', + chainId: 56, + accountId: 'eip155:56:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 72524170, blockHash: - '0x62ddcb78dc7abd9e6bca07c9a105afffb3d708b553ea74bcaac7c9b02b745fa9', - gas: 359969, - gasUsed: 237236, - gasPrice: '6004066', - effectiveGasPrice: '6004066', - nonce: 752, - cumulativeGasUsed: 31492867, - methodId: '0x01020400', + '0xd43d7bb4c06ccfc0ecd172ed08fccacb774ed29e1c58b727687c5b075bc3343d', + gas: 85408, + gasUsed: 56133, + gasPrice: '52330000', + effectiveGasPrice: '52330000', + nonce: 104, + cumulativeGasUsed: 24011496, + methodId: '0xa9059cbb', value: '0', - to: '0x0000000000006ac72ed1d192fa28f0058d3f8806', - from: '0xc723f2c210c4d29cfe35209340a6fb766d956982', + to: '0xcba411922349ecd7eec13aac1825b1ddca223fc8', + from: '0x0325f3aa3ef51e24b3f31a0c390e0bc984b5490f', isError: false, valueTransfers: [ { - from: '0xe6ede73fa975b5a2f8daf2a51945addee6413df5', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - amount: '150000', - decimal: 6, - contractAddress: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', - symbol: 'USDC', - name: 'USD Coin', + from: '0x0325f3aa3ef51e24b3f31a0c390e0bc984b5490f', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: '100000000000000000000', + decimal: 18, + contractAddress: '0xcba411922349ecd7eec13aac1825b1ddca223fc8', + symbol: 'MOB', + name: 'MOB', transferType: 'erc20', }, ], }, { - hash: '0xd98b2afab4bb65ef6d8b5f0c726192f180a906f27c960c9dad11c60041474738', - timestamp: '2026-02-26T02:38:01.000Z', - chainId: 8453, - accountId: 'eip155:8453:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - blockNumber: 42642067, + hash: '0x07bb21d1937b66aab9dfe1632e4eee9b96e82f54f41f17b3cc4378ec0188af61', + timestamp: '2025-12-14T12:55:16.000Z', + chainId: 56, + accountId: 'eip155:56:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 71620155, blockHash: - '0xf612c40cd76c5e8b4140b5235130c8ceb23f10464b20e7c5c314037aa9e82f82', - gas: 341543, - gasUsed: 225000, - gasPrice: '6493425', - effectiveGasPrice: '6493425', - nonce: 11197, - cumulativeGasUsed: 25787493, - methodId: '0x01020400', + '0xe0e71f46bba84eb4060565b376bc3ede99a45e84fad2e6588bbd003e5e623313', + gas: 30424536, + gasUsed: 3138845, + gasPrice: '50500000', + effectiveGasPrice: '50500000', + nonce: 968, + cumulativeGasUsed: 18618033, + methodId: '0x729ad39e', value: '0', - to: '0x0000000000006ac72ed1d192fa28f0058d3f8806', - from: '0x52fba7915b2b37f85100b543af54fba499228846', + to: '0xdd7eb7809d283ae3ffa880183f20e7016ebe8374', + from: '0x6c604c63fb280ca69559f42f6c5a4a4bfcf661d5', isError: false, valueTransfers: [ { - from: '0x74753ad4f1e5b1f0c25725f50796bb530636a912', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - amount: '150000', - decimal: 6, - contractAddress: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', - symbol: 'USDC', - name: 'USD Coin', - transferType: 'erc20', + from: '0x6c604c63fb280ca69559f42f6c5a4a4bfcf661d5', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: 1, + tokenId: '0', + contractAddress: '0xdd7eb7809d283ae3ffa880183f20e7016ebe8374', + transferType: 'erc1155', }, ], }, ], - unprocessedNetworks: [ - 'eip155:137:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - ], + unprocessedNetworks: [], pageInfo: { - count: 10, + count: 3, hasNextPage: true, hasPreviousPage: false, startCursor: null, endCursor: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweGQ4ZGE2YmYyNjk2NGFmOWQ3ZWVkOWUwM2U1MzQxNWQzN2FhOTYwNDUiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDItMjZUMDI6Mzg6MDEuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4ZDhkYTZiZjI2OTY0YWY5ZDdlZWQ5ZTAzZTUzNDE1ZDM3YWE5NjA0NSI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMi0yNlQwMjozODowMS4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTMyOToweGQ4ZGE2YmYyNjk2NGFmOWQ3ZWVkOWUwM2U1MzQxNWQzN2FhOTYwNDUiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDItMjZUMDI6Mzg6MDEuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjE0MzoweGQ4ZGE2YmYyNjk2NGFmOWQ3ZWVkOWUwM2U1MzQxNWQzN2FhOTYwNDUiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDItMjZUMDI6Mzg6MDEuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjQyMTYxOjB4ZDhkYTZiZjI2OTY0YWY5ZDdlZWQ5ZTAzZTUzNDE1ZDM3YWE5NjA0NSI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMi0yNlQwMjozODowMS4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NTM0MzUyOjB4ZDhkYTZiZjI2OTY0YWY5ZDdlZWQ5ZTAzZTUzNDE1ZDM3YWE5NjA0NSI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMi0yNlQwMjozODowMS4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NTY6MHhkOGRhNmJmMjY5NjRhZjlkN2VlZDllMDNlNTM0MTVkMzdhYTk2MDQ1Ijp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAyLTI2VDAyOjM4OjAxLjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1OTE0NDoweGQ4ZGE2YmYyNjk2NGFmOWQ3ZWVkOWUwM2U1MzQxNWQzN2FhOTYwNDUiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDItMjZUMDI6Mzg6MDEuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1Ojg0NTM6MHhkOGRhNmJmMjY5NjRhZjlkN2VlZDllMDNlNTM0MTVkMzdhYTk2MDQ1Ijp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAyLTI2VDAyOjM4OjAxLjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo5OTk6MHhkOGRhNmJmMjY5NjRhZjlkN2VlZDllMDNlNTM0MTVkMzdhYTk2MDQ1Ijp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAyLTI2VDAyOjM4OjAxLjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImlhdCI6MTc3MjEwMjAyNX0.NYLYAQ-7pTPd01t5Nz1VxP5tMBZvHOPf2PXZw7VInpM', + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjU1OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjU1OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTA5NDE2fQ.FD0bOPSGwFLPJytoo9KCRxTcUuyDXDKfzAeIGRfJPQI', }, }, }; @@ -361,9 +279,11 @@ export function mockTransactions(mockReply?: MockReply): nock.Scope { }) .get('/v4/multiaccount/transactions') .query({ - limit: '10', + limit: '3', accountAddresses: - 'eip155%3A0%3A0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + 'eip155%3A0%3A0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + cursor: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTA4NzM4fQ.rlFQKUlm5rJjHynbXffMKzWw36qFva91GBcjOwjwPOw', }) .reply(reply.status, reply.body); } From d3d0fb2a52e377222b67d0cecb48cc7f311203a1 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 26 Feb 2026 13:47:50 +0100 Subject: [PATCH 14/20] Fix missing assertion --- packages/base-data-service/src/BaseDataService.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 032a4a7afbb..486923e8ae3 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -48,19 +48,25 @@ describe('BaseDataService', () => { ]); }); - it.only('handles paginated queries', async () => { + 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).toStrictEqual([]); + expect(page1.data).toHaveLength(3); const page2 = await service.getActivity( TEST_ADDRESS, page1.pageInfo.endCursor, ); + expect(page2.data).toHaveLength(3); + + expect(page2.data).not.toStrictEqual(page1.data); + }); + + expect(page2.data).not.toStrictEqual(page1.data); }); }); From d2cded97e893f1b0d094790712c689b6c45a8571 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 26 Feb 2026 13:52:16 +0100 Subject: [PATCH 15/20] Revert accidental change --- packages/base-data-service/src/BaseDataService.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 486923e8ae3..b2a676e8cb4 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -63,10 +63,6 @@ describe('BaseDataService', () => { expect(page2.data).toHaveLength(3); - expect(page2.data).not.toStrictEqual(page1.data); - }); - - expect(page2.data).not.toStrictEqual(page1.data); }); }); From 79d37fcf5d322423330b00819e1018c3c97f36b1 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 26 Feb 2026 14:21:05 +0100 Subject: [PATCH 16/20] Add test for paginated observers --- .../src/createUIQueryClient.test.ts | 84 ++++++++++++++++++- .../tests/ExampleDataService.ts | 38 ++++++--- 2 files changed, 108 insertions(+), 14 deletions(-) diff --git a/packages/base-data-service/src/createUIQueryClient.test.ts b/packages/base-data-service/src/createUIQueryClient.test.ts index cf151ae287a..4ec78abf4af 100644 --- a/packages/base-data-service/src/createUIQueryClient.test.ts +++ b/packages/base-data-service/src/createUIQueryClient.test.ts @@ -1,6 +1,12 @@ import { Messenger } from '@metamask/messenger'; import { Json } from '@metamask/utils'; -import { QueryClient, QueryKey, QueryObserver } from '@tanstack/query-core'; +import { + InfiniteData, + InfiniteQueryObserver, + QueryClient, + QueryKey, + QueryObserver, +} from '@tanstack/query-core'; import { SubscriptionCallback, SubscriptionPayload } from './BaseDataService'; import { createUIQueryClient } from './createUIQueryClient'; @@ -8,8 +14,13 @@ import { ExampleDataService, ExampleDataServiceActions, ExampleMessenger, + GetActivityResponse, } from '../tests/ExampleDataService'; -import { mockAssets } from '../tests/mocks'; +import { + mockAssets, + mockTransactionsPage1, + mockTransactionsPage2, +} from '../tests/mocks'; const DATA_SERVICES = ['ExampleDataService']; @@ -77,9 +88,16 @@ const getAssetsQueryKey = [ ], ]; +const getActivityQueryKey = [ + 'ExampleDataService:getActivity', + '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520', +]; + describe('createUIQueryClient', () => { beforeEach(() => { mockAssets(); + mockTransactionsPage1(); + mockTransactionsPage2(); }); it('proxies requests to the underlying service', async () => { @@ -194,4 +212,66 @@ describe('createUIQueryClient', () => { observerA.destroy(); observerB.destroy(); }); + + it('fetches using paginated observers', async () => { + const { clientA, clientB } = createClients(); + + const getPreviousPageParam = ({ + pageInfo, + }: GetActivityResponse): string | undefined => + pageInfo.hasPreviousPage ? pageInfo.startCursor : undefined; + const getNextPageParam = ({ + pageInfo, + }: GetActivityResponse): string | undefined => + pageInfo.hasNextPage ? 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/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts index 0af6ea3728c..90312c46f30 100644 --- a/packages/base-data-service/tests/ExampleDataService.ts +++ b/packages/base-data-service/tests/ExampleDataService.ts @@ -1,5 +1,5 @@ import { Messenger } from '@metamask/messenger'; -import { Duration, inMilliseconds, Json } from '@metamask/utils'; +import { CaipAssetId, Duration, inMilliseconds, Json } from '@metamask/utils'; import { BaseDataService, DataServiceActions } from '../src/BaseDataService'; @@ -26,6 +26,24 @@ export type ExampleMessenger = Messenger< 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 class ExampleDataService extends BaseDataService< typeof serviceName, ExampleMessenger @@ -51,7 +69,7 @@ export class ExampleDataService extends BaseDataService< ); } - async getAssets(assets: string[]) { + async getAssets(assets: string[]): Promise { return this.fetchQuery({ queryKey: [`${this.name}:getAssets`, assets], queryFn: async () => { @@ -67,16 +85,11 @@ export class ExampleDataService extends BaseDataService< }); } - async getActivity(address: string, page?: string) { - return this.fetchInfiniteQuery<{ - data: Json; - pageInfo: { - hasNextPage: boolean; - hasPreviousPage: boolean; - startCursor: string; - endCursor: string; - }; - }>( + async getActivity( + address: string, + page?: string, + ): Promise { + return this.fetchInfiniteQuery( { queryKey: [`${this.name}:getActivity`, address], queryFn: async ({ pageParam }) => { @@ -97,6 +110,7 @@ export class ExampleDataService extends BaseDataService< pageInfo.hasPreviousPage ? pageInfo.startCursor : undefined, getNextPageParam: ({ pageInfo }) => pageInfo.hasNextPage ? pageInfo.endCursor : undefined, + staleTime: inMilliseconds(5, Duration.Minute), }, page, ); From c19b3d79b8362a671dd834cfdb258710b8e6e23e Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 26 Feb 2026 15:23:23 +0100 Subject: [PATCH 17/20] Fix lint --- .../src/createUIQueryClient.test.ts | 14 +++++++++++--- .../base-data-service/src/createUIQueryClient.ts | 7 ++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/base-data-service/src/createUIQueryClient.test.ts b/packages/base-data-service/src/createUIQueryClient.test.ts index 4ec78abf4af..c6a5ae74bb1 100644 --- a/packages/base-data-service/src/createUIQueryClient.test.ts +++ b/packages/base-data-service/src/createUIQueryClient.test.ts @@ -1,6 +1,7 @@ import { Messenger } from '@metamask/messenger'; import { Json } from '@metamask/utils'; import { + DehydratedState, InfiniteData, InfiniteQueryObserver, QueryClient, @@ -15,6 +16,7 @@ import { ExampleDataServiceActions, ExampleMessenger, GetActivityResponse, + GetAssetsResponse, } from '../tests/ExampleDataService'; import { mockAssets, @@ -33,9 +35,11 @@ function createClient(serviceMessenger: ExampleMessenger): QueryClient { const messengerAdapter = { call: async ( - method: ExampleDataServiceActions['type'], + method: string, ...params: Json[] - ) => { + ): Promise< + void | DehydratedState | GetActivityResponse | GetAssetsResponse + > => { if (method === 'ExampleDataService:subscribe') { return serviceMessenger.call( method, @@ -49,7 +53,11 @@ function createClient(serviceMessenger: ExampleMessenger): QueryClient { subscription, ); } - return serviceMessenger.call(method, ...params); + return serviceMessenger.call( + method as ExampleDataServiceActions['type'], + // @ts-expect-error TODO. + ...params, + ); }, subscribe: async ( _method: string, diff --git a/packages/base-data-service/src/createUIQueryClient.ts b/packages/base-data-service/src/createUIQueryClient.ts index 5ae34febe10..16f6e191cc8 100644 --- a/packages/base-data-service/src/createUIQueryClient.ts +++ b/packages/base-data-service/src/createUIQueryClient.ts @@ -12,8 +12,9 @@ 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; + call: (method: string, ...params: Json[]) => Promise; subscribe: (method: string, callback: (data: Json) => void) => void; }; @@ -47,11 +48,11 @@ export function createUIQueryClient( 'Queries must use data service actions.', ); - return await messenger.call( + return (await messenger.call( action, ...(options.queryKey.slice(1) as Json[]), options.pageParam, - ); + )) as Json; }, // TODO: Decide on values for these. staleTime: Infinity, From ff83d5e4a0aa1f2816b9264975ead1326f51dc1e Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Fri, 27 Feb 2026 11:34:05 +0100 Subject: [PATCH 18/20] Fix issues with pagination when query was not already called once --- .../src/BaseDataService.test.ts | 24 ++++ .../base-data-service/src/BaseDataService.ts | 48 ++++--- packages/base-data-service/tests/mocks.ts | 135 +++++++++++++++++- 3 files changed, 181 insertions(+), 26 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index b2a676e8cb4..6ac1178e79a 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -5,6 +5,8 @@ import { mockAssets, mockTransactionsPage1, mockTransactionsPage2, + mockTransactionsPage3, + TRANSACTIONS_PAGE_2_CURSOR, } from '../tests/mocks'; const TEST_ADDRESS = '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520'; @@ -14,6 +16,7 @@ describe('BaseDataService', () => { mockAssets(); mockTransactionsPage1(); mockTransactionsPage2(); + mockTransactionsPage3(); }); it('handles basic queries', async () => { @@ -65,4 +68,25 @@ describe('BaseDataService', () => { 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, + TRANSACTIONS_PAGE_2_CURSOR, + ); + + expect(page2.data).toHaveLength(3); + + const page3 = await service.getActivity( + TEST_ADDRESS, + page2.pageInfo.endCursor, + ); + + expect(page3.data).toHaveLength(3); + + expect(page3.data).not.toStrictEqual(page2.data); + }); }); diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 2eb4a202bfa..fac7b4224a8 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -152,31 +152,37 @@ export class BaseDataService< .getQueryCache() .find({ queryKey: options.queryKey }); - if (query && pageParam) { - const pages = - (query.state.data as InfiniteData | undefined)?.pages ?? - []; - const previous = options.getPreviousPageParam?.(pages[0], pages); - - const direction = pageParam === previous ? 'backward' : 'forward'; - - const result = (await query.fetch(undefined, { - meta: { - fetchMore: { - direction, - pageParam, - }, - }, - })) as InfiniteData; + if (!query || !pageParam) { + const result = await this.#client.fetchInfiniteQuery({ + ...options, + queryFn: (context) => + options.queryFn({ + ...context, + pageParam: context.pageParam ?? pageParam, + }), + }); + + return result.pages[0]; + } - const pageIndex = result.pageParams.indexOf(pageParam); + const pages = + (query.state.data as InfiniteData | undefined)?.pages ?? []; + const previous = options.getPreviousPageParam?.(pages[0], pages); - return result.pages[pageIndex]; - } + const direction = pageParam === previous ? 'backward' : 'forward'; + + const result = (await query.fetch(undefined, { + meta: { + fetchMore: { + direction, + pageParam, + }, + }, + })) as InfiniteData; - const result = await this.#client.fetchInfiniteQuery(options); + const pageIndex = result.pageParams.indexOf(pageParam); - return result.pages[0]; + return result.pages[pageIndex]; } protected async invalidateQueries( diff --git a/packages/base-data-service/tests/mocks.ts b/packages/base-data-service/tests/mocks.ts index a99d8d0e877..7094de4b75f 100644 --- a/packages/base-data-service/tests/mocks.ts +++ b/packages/base-data-service/tests/mocks.ts @@ -41,6 +41,12 @@ export function mockAssets(mockReply?: MockReply): nock.Scope { .reply(reply.status, reply.body); } +export const TRANSACTIONS_PAGE_2_CURSOR = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTg0NjQ5fQ.btHnBzYlpbZtAA0kgdyZ5rZ-BC91PZyZQPUuXj1jj6M'; + +export const TRANSACTIONS_PAGE_3_CURSOR = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjU1OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjU1OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTg0NzE4fQ.3bzO_0SLGmIbhN8HoN_JTqaiOOcVqF25U8ftRuth2ow'; + export function mockTransactionsPage1(mockReply?: MockReply): nock.Scope { const reply = mockReply ?? { status: 200, @@ -146,8 +152,7 @@ export function mockTransactionsPage1(mockReply?: MockReply): nock.Scope { hasNextPage: true, hasPreviousPage: false, startCursor: null, - endCursor: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTA4NzM4fQ.rlFQKUlm5rJjHynbXffMKzWw36qFva91GBcjOwjwPOw', + endCursor: TRANSACTIONS_PAGE_2_CURSOR, }, }, }; @@ -269,8 +274,129 @@ export function mockTransactionsPage2(mockReply?: MockReply): nock.Scope { 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({ + limit: '3', + accountAddresses: + 'eip155%3A0%3A0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + cursor: 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: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6MDY6MDIuMDAwWiIsImhhc1ByZXZpb3VzUGFnZSI6dHJ1ZX0sImVpcDE1NToxMDoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6MDY6MDIuMDAwWiIsImhhc1ByZXZpb3VzUGFnZSI6dHJ1ZX0sImVpcDE1NToxMzc6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjA2OjAyLjAwMFoiLCJoYXNQcmV2aW91c1BhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjA2OjAyLjAwMFoiLCJoYXNQcmV2aW91c1BhZ2UiOnRydWV9LCJlaXAxNTU6NTM0MzUyOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjowNjowMi4wMDBaIiwiaGFzUHJldmlvdXNQYWdlIjp0cnVlfSwiZWlwMTU1OjU2OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjowNjowMi4wMDBaIiwiaGFzUHJldmlvdXNQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjowNjowMi4wMDBaIiwiaGFzUHJldmlvdXNQYWdlIjp0cnVlfSwiZWlwMTU1Ojg0NTM6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjA2OjAyLjAwMFoiLCJoYXNQcmV2aW91c1BhZ2UiOnRydWV9LCJpYXQiOjE3NzIxODQ4MjJ9.mQOxvn8fFy8yLtntxJspuvL0i4A7QoyjGoJOn-XcnJI', endCursor: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjU1OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjU1OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTA5NDE2fQ.FD0bOPSGwFLPJytoo9KCRxTcUuyDXDKfzAeIGRfJPQI', + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTNUMDQ6NTk6MjMuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xM1QwNDo1OToyMy4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xM1QwNDo1OToyMy4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTEzVDA0OjU5OjIzLjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTEzVDA0OjU5OjIzLjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTNUMDQ6NTk6MjMuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xM1QwNDo1OToyMy4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTNUMDQ6NTk6MjMuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTg0ODIyfQ.-JOxS3Ly3j0XLp9P-PfRHJuzVsHQh6uRzvYJvcW_PGs', }, }, }; @@ -282,8 +408,7 @@ export function mockTransactionsPage2(mockReply?: MockReply): nock.Scope { limit: '3', accountAddresses: 'eip155%3A0%3A0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', - cursor: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTA4NzM4fQ.rlFQKUlm5rJjHynbXffMKzWw36qFva91GBcjOwjwPOw', + cursor: TRANSACTIONS_PAGE_3_CURSOR, }) .reply(reply.status, reply.body); } From 2647140755ed0aca991dd365bde8c1769399786e Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Fri, 27 Feb 2026 12:26:57 +0100 Subject: [PATCH 19/20] Improve example --- .../src/BaseDataService.test.ts | 21 ++++++++----------- .../src/createUIQueryClient.test.ts | 9 ++++---- .../tests/ExampleDataService.ts | 20 +++++++++++++----- packages/base-data-service/tests/mocks.ts | 4 ++-- 4 files changed, 31 insertions(+), 23 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 6ac1178e79a..36bd0074ddb 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -59,10 +59,9 @@ describe('BaseDataService', () => { expect(page1.data).toHaveLength(3); - const page2 = await service.getActivity( - TEST_ADDRESS, - page1.pageInfo.endCursor, - ); + const page2 = await service.getActivity(TEST_ADDRESS, { + after: page1.pageInfo.endCursor, + }); expect(page2.data).toHaveLength(3); @@ -73,17 +72,15 @@ describe('BaseDataService', () => { const messenger = new Messenger({ namespace: serviceName }); const service = new ExampleDataService(messenger); - const page2 = await service.getActivity( - TEST_ADDRESS, - TRANSACTIONS_PAGE_2_CURSOR, - ); + const page2 = await service.getActivity(TEST_ADDRESS, { + after: TRANSACTIONS_PAGE_2_CURSOR, + }); expect(page2.data).toHaveLength(3); - const page3 = await service.getActivity( - TEST_ADDRESS, - page2.pageInfo.endCursor, - ); + const page3 = await service.getActivity(TEST_ADDRESS, { + after: page2.pageInfo.endCursor, + }); expect(page3.data).toHaveLength(3); diff --git a/packages/base-data-service/src/createUIQueryClient.test.ts b/packages/base-data-service/src/createUIQueryClient.test.ts index c6a5ae74bb1..07bb3af8173 100644 --- a/packages/base-data-service/src/createUIQueryClient.test.ts +++ b/packages/base-data-service/src/createUIQueryClient.test.ts @@ -17,6 +17,7 @@ import { ExampleMessenger, GetActivityResponse, GetAssetsResponse, + PageParam, } from '../tests/ExampleDataService'; import { mockAssets, @@ -226,12 +227,12 @@ describe('createUIQueryClient', () => { const getPreviousPageParam = ({ pageInfo, - }: GetActivityResponse): string | undefined => - pageInfo.hasPreviousPage ? pageInfo.startCursor : undefined; + }: GetActivityResponse): PageParam | undefined => + pageInfo.hasPreviousPage ? { before: pageInfo.startCursor } : undefined; const getNextPageParam = ({ pageInfo, - }: GetActivityResponse): string | undefined => - pageInfo.hasNextPage ? pageInfo.endCursor : undefined; + }: GetActivityResponse): PageParam | undefined => + pageInfo.hasNextPage ? { after: pageInfo.endCursor } : undefined; const observerA = new InfiniteQueryObserver(clientA, { queryKey: getActivityQueryKey, diff --git a/packages/base-data-service/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts index 90312c46f30..6ed9cfc3e5f 100644 --- a/packages/base-data-service/tests/ExampleDataService.ts +++ b/packages/base-data-service/tests/ExampleDataService.ts @@ -44,6 +44,12 @@ export type GetActivityResponse = { }; }; +export type PageParam = + | { + before: string; + } + | { after: string }; + export class ExampleDataService extends BaseDataService< typeof serviceName, ExampleMessenger @@ -87,7 +93,7 @@ export class ExampleDataService extends BaseDataService< async getActivity( address: string, - page?: string, + page?: PageParam, ): Promise { return this.fetchInfiniteQuery( { @@ -98,8 +104,10 @@ export class ExampleDataService extends BaseDataService< `${this.#accountsBaseUrl}/v4/multiaccount/transactions?limit=3&accountAddresses=${caipAddress}`, ); - if (pageParam) { - url.searchParams.set('cursor', pageParam); + if (pageParam?.after) { + url.searchParams.set('after', pageParam.after); + } else if (pageParam?.before) { + url.searchParams.set('before', pageParam.before); } const response = await fetch(url); @@ -107,9 +115,11 @@ export class ExampleDataService extends BaseDataService< return response.json(); }, getPreviousPageParam: ({ pageInfo }) => - pageInfo.hasPreviousPage ? pageInfo.startCursor : undefined, + pageInfo.hasPreviousPage + ? { before: pageInfo.startCursor } + : undefined, getNextPageParam: ({ pageInfo }) => - pageInfo.hasNextPage ? pageInfo.endCursor : undefined, + 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 index 7094de4b75f..7cda01a1315 100644 --- a/packages/base-data-service/tests/mocks.ts +++ b/packages/base-data-service/tests/mocks.ts @@ -286,7 +286,7 @@ export function mockTransactionsPage2(mockReply?: MockReply): nock.Scope { limit: '3', accountAddresses: 'eip155%3A0%3A0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', - cursor: TRANSACTIONS_PAGE_2_CURSOR, + after: TRANSACTIONS_PAGE_2_CURSOR, }) .reply(reply.status, reply.body); } @@ -408,7 +408,7 @@ export function mockTransactionsPage3(mockReply?: MockReply): nock.Scope { limit: '3', accountAddresses: 'eip155%3A0%3A0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', - cursor: TRANSACTIONS_PAGE_3_CURSOR, + after: TRANSACTIONS_PAGE_3_CURSOR, }) .reply(reply.status, reply.body); } From 6f38346bfd133131c49b5fbb7cc1c7c5e055832e Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Fri, 27 Feb 2026 12:50:32 +0100 Subject: [PATCH 20/20] Add working test for backwards pagination --- packages/base-data-service/package.json | 3 ++- .../src/BaseDataService.test.ts | 19 ++++++++++++++++++ .../base-data-service/src/BaseDataService.ts | 5 +++-- packages/base-data-service/tests/mocks.ts | 20 +++++++++++-------- yarn.lock | 1 + 5 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/base-data-service/package.json b/packages/base-data-service/package.json index 711be7cefa9..f6449084f25 100644 --- a/packages/base-data-service/package.json +++ b/packages/base-data-service/package.json @@ -50,7 +50,8 @@ "dependencies": { "@metamask/messenger": "^0.3.0", "@metamask/utils": "^11.9.0", - "@tanstack/query-core": "^4.43.0" + "@tanstack/query-core": "^4.43.0", + "fast-deep-equal": "^3.1.3" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 36bd0074ddb..78fa43abba5 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -7,6 +7,7 @@ import { mockTransactionsPage2, mockTransactionsPage3, TRANSACTIONS_PAGE_2_CURSOR, + TRANSACTIONS_PAGE_3_CURSOR, } from '../tests/mocks'; const TEST_ADDRESS = '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520'; @@ -86,4 +87,22 @@ describe('BaseDataService', () => { 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 index fac7b4224a8..25aeeb815f7 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -17,6 +17,7 @@ import { 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; @@ -166,10 +167,10 @@ export class BaseDataService< } const pages = - (query.state.data as InfiniteData | undefined)?.pages ?? []; + (query.state.data as InfiniteData).pages; const previous = options.getPreviousPageParam?.(pages[0], pages); - const direction = pageParam === previous ? 'backward' : 'forward'; + const direction = deepEqual(pageParam, previous) ? 'backward' : 'forward'; const result = (await query.fetch(undefined, { meta: { diff --git a/packages/base-data-service/tests/mocks.ts b/packages/base-data-service/tests/mocks.ts index 7cda01a1315..82341e06721 100644 --- a/packages/base-data-service/tests/mocks.ts +++ b/packages/base-data-service/tests/mocks.ts @@ -44,6 +44,9 @@ export function mockAssets(mockReply?: MockReply): nock.Scope { 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'; @@ -282,12 +285,14 @@ export function mockTransactionsPage2(mockReply?: MockReply): nock.Scope { encodedQueryParams: true, }) .get('/v4/multiaccount/transactions') - .query({ - limit: '3', - accountAddresses: - 'eip155%3A0%3A0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', - after: TRANSACTIONS_PAGE_2_CURSOR, - }) + .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); } @@ -393,8 +398,7 @@ export function mockTransactionsPage3(mockReply?: MockReply): nock.Scope { count: 3, hasNextPage: true, hasPreviousPage: true, - startCursor: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6MDY6MDIuMDAwWiIsImhhc1ByZXZpb3VzUGFnZSI6dHJ1ZX0sImVpcDE1NToxMDoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6MDY6MDIuMDAwWiIsImhhc1ByZXZpb3VzUGFnZSI6dHJ1ZX0sImVpcDE1NToxMzc6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjA2OjAyLjAwMFoiLCJoYXNQcmV2aW91c1BhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjA2OjAyLjAwMFoiLCJoYXNQcmV2aW91c1BhZ2UiOnRydWV9LCJlaXAxNTU6NTM0MzUyOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjowNjowMi4wMDBaIiwiaGFzUHJldmlvdXNQYWdlIjp0cnVlfSwiZWlwMTU1OjU2OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjowNjowMi4wMDBaIiwiaGFzUHJldmlvdXNQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjowNjowMi4wMDBaIiwiaGFzUHJldmlvdXNQYWdlIjp0cnVlfSwiZWlwMTU1Ojg0NTM6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjA2OjAyLjAwMFoiLCJoYXNQcmV2aW91c1BhZ2UiOnRydWV9LCJpYXQiOjE3NzIxODQ4MjJ9.mQOxvn8fFy8yLtntxJspuvL0i4A7QoyjGoJOn-XcnJI', + startCursor: TRANSACTIONS_PAGE_3_START_CURSOR, endCursor: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTNUMDQ6NTk6MjMuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xM1QwNDo1OToyMy4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xM1QwNDo1OToyMy4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTEzVDA0OjU5OjIzLjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTEzVDA0OjU5OjIzLjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTNUMDQ6NTk6MjMuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xM1QwNDo1OToyMy4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTNUMDQ6NTk6MjMuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTg0ODIyfQ.-JOxS3Ly3j0XLp9P-PfRHJuzVsHQh6uRzvYJvcW_PGs', }, diff --git a/yarn.lock b/yarn.lock index 6ca034d7e1f..e42218bd2e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2727,6 +2727,7 @@ __metadata: "@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"