diff --git a/.changeset/app-667-token-voting-members-from-aragon-domain.md b/.changeset/app-667-token-voting-members-from-aragon-domain.md new file mode 100644 index 0000000000..13f1bc06d9 --- /dev/null +++ b/.changeset/app-667-token-voting-members-from-aragon-domain.md @@ -0,0 +1,5 @@ +--- +"@aragon/app": minor +--- + +Serve mainnet ERC-20 token-voting member lists from the aragon-domain (Envio) BFF, with source routing and a legacy-backend fallback diff --git a/apps/app/package.json b/apps/app/package.json index 21e6a6d2d9..aaf6fe1c71 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -30,7 +30,7 @@ "e2e:codegen": "playwright codegen --config e2e/playwright.config.ts" }, "dependencies": { - "@aragon/aragon-domain": "^0.3.1", + "@aragon/aragon-domain": "0.0.0-20260806160246", "@aragon/assistant-chat": "workspace:*", "@aragon/assistant-contracts": "workspace:*", "@aragon/gov-ui-kit": "catalog:", diff --git a/apps/app/src/app/api/domain/token-voting/members/route.test.ts b/apps/app/src/app/api/domain/token-voting/members/route.test.ts new file mode 100644 index 0000000000..e268641004 --- /dev/null +++ b/apps/app/src/app/api/domain/token-voting/members/route.test.ts @@ -0,0 +1,83 @@ +/** @jest-environment node */ + +import type { NextRequest } from 'next/server'; +import { tokenVotingMembershipServiceServer } from '@/modules/governance/api/tokenVotingMembershipService/tokenVotingMembershipService.server'; +import { GET } from './route'; + +const pluginAddress = '0x1111111111111111111111111111111111111111'; +const tokenContractAddress = '0x2222222222222222222222222222222222222222'; + +const generateRequest = (query: string): NextRequest => + ({ + nextUrl: new URL( + `http://localhost/api/domain/token-voting/members?${query}`, + ), + }) as NextRequest; + +describe('GET /api/domain/token-voting/members', () => { + const getMembershipSpy = jest.spyOn( + tokenVotingMembershipServiceServer, + 'getTokenVotingMembership', + ); + + afterEach(() => { + getMembershipSpy.mockReset(); + }); + + it.each([ + [ + 'a missing plugin address', + `tokenContractAddress=${tokenContractAddress}`, + ], + [ + 'an invalid plugin address', + `pluginAddress=not-an-address&tokenContractAddress=${tokenContractAddress}`, + ], + [ + 'an invalid token contract address', + `pluginAddress=${pluginAddress}&tokenContractAddress=0x1234`, + ], + [ + 'a non-integer page', + `pluginAddress=${pluginAddress}&tokenContractAddress=${tokenContractAddress}&page=first`, + ], + [ + 'a non-positive page size', + `pluginAddress=${pluginAddress}&tokenContractAddress=${tokenContractAddress}&pageSize=0`, + ], + [ + 'a page size above the domain maximum', + `pluginAddress=${pluginAddress}&tokenContractAddress=${tokenContractAddress}&pageSize=251`, + ], + ])('returns 400 for %s', async (_label, query) => { + const response = await GET(generateRequest(query)); + + expect(response.status).toBe(400); + expect(getMembershipSpy).not.toHaveBeenCalled(); + }); + + it('passes validated request parameters to the domain service', async () => { + const result = { + data: [], + metadata: { page: 2, pageSize: 25, totalPages: 0, totalRecords: 0 }, + }; + getMembershipSpy.mockResolvedValue(result); + + const response = await GET( + generateRequest( + `pluginAddress=${pluginAddress}&tokenContractAddress=${tokenContractAddress}&page=2&pageSize=25`, + ), + ); + + expect(response.status).toBe(200); + expect(getMembershipSpy).toHaveBeenCalledWith({ + queryParams: { + pluginAddress, + tokenContractAddress, + page: 2, + pageSize: 25, + }, + }); + await expect(response.json()).resolves.toEqual(result); + }); +}); diff --git a/apps/app/src/app/api/domain/token-voting/members/route.ts b/apps/app/src/app/api/domain/token-voting/members/route.ts new file mode 100644 index 0000000000..f3ef8a2dbd --- /dev/null +++ b/apps/app/src/app/api/domain/token-voting/members/route.ts @@ -0,0 +1,82 @@ +import { type NextRequest, NextResponse } from 'next/server'; +// biome-ignore lint/style/noRestrictedImports: server-only BFF validation; strict EIP-55 validation is explicitly disabled. +import { isAddress } from 'viem'; +import { tokenVotingMembershipServiceServer } from '@/modules/governance/api/tokenVotingMembershipService/tokenVotingMembershipService.server'; +import { monitoringUtils } from '@/shared/utils/monitoringUtils'; + +const maximumPageSize = 250; + +interface ITokenVotingMembershipQueryParams { + pluginAddress: string; + tokenContractAddress: string; + page?: number; + pageSize?: number; +} + +const validateQueryParams = ( + params: URLSearchParams, +): ITokenVotingMembershipQueryParams | undefined => { + const pluginAddress = params.get('pluginAddress'); + const tokenContractAddress = params.get('tokenContractAddress'); + const page = params.get('page'); + const pageSize = params.get('pageSize'); + const parsedPage = page != null ? Number(page) : undefined; + const parsedPageSize = pageSize != null ? Number(pageSize) : undefined; + + if ( + pluginAddress == null || + tokenContractAddress == null || + !isAddress(pluginAddress, { strict: false }) || + !isAddress(tokenContractAddress, { strict: false }) || + (parsedPage != null && + (!Number.isInteger(parsedPage) || parsedPage <= 0)) || + (parsedPageSize != null && + (!Number.isInteger(parsedPageSize) || + parsedPageSize <= 0 || + parsedPageSize > maximumPageSize)) + ) { + return; + } + + return { + pluginAddress, + tokenContractAddress, + page: parsedPage, + pageSize: parsedPageSize, + }; +}; + +export const GET = async (req: NextRequest) => { + const queryParams = validateQueryParams(req.nextUrl.searchParams); + + if (queryParams == null) { + return NextResponse.json( + { + error: 'pluginAddress and tokenContractAddress must be valid addresses; page must be a positive integer; pageSize must be a positive integer no greater than 250', + }, + { status: 400 }, + ); + } + + try { + const result = + await tokenVotingMembershipServiceServer.getTokenVotingMembership({ + queryParams, + }); + + return NextResponse.json(result); + } catch (error) { + monitoringUtils.logError(error, { + context: { + errorType: 'get_token_voting_membership_error', + pluginAddress: queryParams.pluginAddress, + tokenContractAddress: queryParams.tokenContractAddress, + }, + }); + + return NextResponse.json( + { error: 'getTokenVotingMembership request failed' }, + { status: 500 }, + ); + } +}; diff --git a/apps/app/src/app/dao/[network]/[addressOrEns]/(daoPage)/members/page.tsx b/apps/app/src/app/dao/[network]/[addressOrEns]/(daoPage)/members/page.tsx index 7268e842a2..f9de47a1d3 100644 --- a/apps/app/src/app/dao/[network]/[addressOrEns]/(daoPage)/members/page.tsx +++ b/apps/app/src/app/dao/[network]/[addressOrEns]/(daoPage)/members/page.tsx @@ -1,3 +1,5 @@ -import { DaoMembersPage } from '@/modules/governance/pages/daoMembersPage'; +// Imported from the page file (not the module barrel): the RSC pulls in +// server-only prefetch code that must stay out of the barrel's client graph. +import { DaoMembersPage } from '@/modules/governance/pages/daoMembersPage/daoMembersPage'; export default DaoMembersPage; diff --git a/apps/app/src/modules/application/api/memberProfileService/memberProfileService.server.ts b/apps/app/src/modules/application/api/memberProfileService/memberProfileService.server.ts index 854e599c7a..5d0a6ed147 100644 --- a/apps/app/src/modules/application/api/memberProfileService/memberProfileService.server.ts +++ b/apps/app/src/modules/application/api/memberProfileService/memberProfileService.server.ts @@ -4,10 +4,9 @@ import type { IGetEnsTextRecordsParams } from './memberProfileService.api'; class MemberProfileServiceServer { getEnsTextRecords = async ({ urlParams }: IGetEnsTextRecordsParams) => { - const result = - await aragonDomainServiceBackend.getMemberProfileTextRecords( - urlParams, - ); + const result = await aragonDomainServiceBackend + .getDomain() + .getMemberProfileTextRecords(urlParams); if (!result.success) { throw new Error( diff --git a/apps/app/src/modules/governance/api/governanceService/governanceService.api.ts b/apps/app/src/modules/governance/api/governanceService/governanceService.api.ts index b5835f9689..39fb431d31 100644 --- a/apps/app/src/modules/governance/api/governanceService/governanceService.api.ts +++ b/apps/app/src/modules/governance/api/governanceService/governanceService.api.ts @@ -2,7 +2,7 @@ import type { IOrderedRequest, IPaginatedRequest, } from '@/shared/api/aragonBackendService'; -import type { Network } from '@/shared/api/daoService'; +import type { Network, PluginInterfaceType } from '@/shared/api/daoService'; import type { IRequestQueryParams, IRequestUrlParams, @@ -59,6 +59,32 @@ export interface IGetMemberListQueryParams extends IPaginatedRequest { export interface IGetMemberListParams extends IRequestQueryParams {} +export interface IGetTokenVotingMembershipQueryParams + extends IGetMemberListQueryParams { + /** + * Network of the plugin, used to route the query to the aragon-domain BFF + * when the network is indexed by Envio. + */ + network?: Network; + /** + * Interface type of the plugin. + */ + pluginInterfaceType?: PluginInterfaceType; + /** + * Address of the governance token. + */ + tokenAddress?: string; + /** + * Address of the underlying token when the governance token is a wrapped + * or voting-escrow adapter. `null` / `undefined` means the governance + * token is a plain ERC-20. + */ + tokenUnderlying?: string | null; +} + +export interface IGetTokenVotingMembershipParams + extends IRequestQueryParams {} + export interface IGetMemberUrlParams { /** * Address of a DAO member. diff --git a/apps/app/src/modules/governance/api/governanceService/governanceService.test.ts b/apps/app/src/modules/governance/api/governanceService/governanceService.test.ts index 9629e95e75..626d0a2a19 100644 --- a/apps/app/src/modules/governance/api/governanceService/governanceService.test.ts +++ b/apps/app/src/modules/governance/api/governanceService/governanceService.test.ts @@ -8,6 +8,7 @@ import { generateSppProposal } from '@/plugins/sppPlugin/testUtils/generators/sp import { generateSppPluginSettings } from '@/plugins/sppPlugin/testUtils/generators/sppSettings'; import { generateSppStage } from '@/plugins/sppPlugin/testUtils/generators/sppStage'; import type { ISppProposal } from '@/plugins/sppPlugin/types'; +import { generateTokenMember } from '@/plugins/tokenPlugin/testUtils'; import { Network, PluginInterfaceType } from '@/shared/api/daoService'; import { generatePaginatedResponse } from '@/shared/testUtils'; import { @@ -15,11 +16,16 @@ import { generateProposal, generateVote, } from '../../testUtils'; +import { tokenVotingMembershipServiceClient } from '../tokenVotingMembershipService'; import { governanceService } from './governanceService'; import * as fetchTokensTotalSupplyHelpers from './utils/fetchTokensTotalSupply'; describe('governance service', () => { const requestSpy = jest.spyOn(governanceService, 'request'); + const domainMembersSpy = jest.spyOn( + tokenVotingMembershipServiceClient, + 'getTokenVotingMembership', + ); const fetchTokensTotalSupplySpy = jest.spyOn( fetchTokensTotalSupplyHelpers, 'fetchTokensTotalSupply', @@ -27,6 +33,7 @@ describe('governance service', () => { afterEach(() => { requestSpy.mockReset(); + domainMembersSpy.mockReset(); fetchTokensTotalSupplySpy.mockReset(); }); @@ -53,6 +60,141 @@ describe('governance service', () => { expect(result).toEqual(members); }); + it('getTokenVotingMembership delegates mainnet token-voting to the aragon-domain service', async () => { + const responseBody = { + data: [ + { + address: '0xabc', + ens: 'alice.eth', + votingPower: '5000', + firstActivityTimestamp: '2024-01-15T12:00:00.000Z', + lastActivityTimestamp: '2024-06-20T08:30:00.000Z', + delegationCount: 3, + }, + ], + metadata: { + page: 1, + pageSize: 10, + totalPages: 1, + totalRecords: 1, + }, + }; + domainMembersSpy.mockResolvedValue(responseBody); + + const result = await governanceService.getTokenVotingMembership({ + queryParams: { + daoId: 'dao-id-test', + pluginAddress: '0xPlugin', + tokenAddress: '0xToken', + network: Network.ETHEREUM_MAINNET, + pluginInterfaceType: PluginInterfaceType.TOKEN_VOTING, + page: 2, + pageSize: 25, + }, + }); + + expect(requestSpy).not.toHaveBeenCalled(); + expect(domainMembersSpy).toHaveBeenCalledWith({ + queryParams: { + pluginAddress: '0xplugin', + tokenContractAddress: '0xtoken', + page: 2, + pageSize: 25, + }, + }); + expect(result).toEqual(responseBody); + }); + + it.each([ + ['non-mainnet network', { network: Network.POLYGON_MAINNET }], + [ + 'non-token-voting interface type', + { pluginInterfaceType: PluginInterfaceType.MULTISIG }, + ], + ['missing tokenAddress', { tokenAddress: undefined }], + [ + 'wrapped / VE-adapter governance token', + { tokenUnderlying: '0xunderlying' }, + ], + ])('getTokenVotingMembership routes to the legacy backend for %s', async (_label, routingOverrides) => { + requestSpy.mockResolvedValue(generatePaginatedResponse({})); + await governanceService.getTokenVotingMembership({ + queryParams: { + daoId: 'dao-id-test', + pluginAddress: '0x123', + tokenAddress: '0xtoken', + network: Network.ETHEREUM_MAINNET, + pluginInterfaceType: PluginInterfaceType.TOKEN_VOTING, + ...routingOverrides, + }, + }); + + expect(domainMembersSpy).not.toHaveBeenCalled(); + expect(requestSpy).toHaveBeenCalledWith( + governanceService['urls'].members, + expect.objectContaining({ + queryParams: expect.objectContaining({ + pluginAddress: '0x123', + }), + }), + ); + }); + + it('getTokenVotingMembership maps the backend members to DTOs when routing to the legacy backend', async () => { + const member = generateTokenMember({ + address: '0xabc', + ens: 'alice.eth', + votingPower: '5000', + type: 'token-voting', + firstActive: 100, + lastActive: 200, + metrics: { + firstActivity: 100, + lastActivity: 200, + delegationCount: 3, + }, + }); + requestSpy.mockResolvedValue( + generatePaginatedResponse({ data: [member] }), + ); + + const result = await governanceService.getTokenVotingMembership({ + queryParams: { + daoId: 'dao-id-test', + pluginAddress: '0xPlugin', + tokenAddress: '0xToken', + // Non-mainnet → backend branch. + network: Network.POLYGON_MAINNET, + pluginInterfaceType: PluginInterfaceType.TOKEN_VOTING, + }, + }); + + expect(domainMembersSpy).not.toHaveBeenCalled(); + // Routing-only fields are stripped before hitting the backend. + expect(requestSpy).toHaveBeenCalledWith( + governanceService['urls'].members, + { + queryParams: { + daoId: 'dao-id-test', + pluginAddress: '0xPlugin', + }, + }, + ); + expect(result.data).toEqual([ + { + address: '0xabc', + ens: 'alice.eth', + votingPower: '5000', + // Legacy block numbers are not resolved to timestamps. + firstActivityTimestamp: null, + lastActivityTimestamp: null, + delegationCount: 3, + }, + ]); + expect(result.data[0]).not.toHaveProperty('type'); + expect(result.data[0]).not.toHaveProperty('firstActive'); + }); + it('getMember fetches the member of the specified DAO by address', async () => { const member = generateMember({ address: '0x123' }); const params = { diff --git a/apps/app/src/modules/governance/api/governanceService/governanceService.ts b/apps/app/src/modules/governance/api/governanceService/governanceService.ts index e8a09cf741..edb5627a69 100644 --- a/apps/app/src/modules/governance/api/governanceService/governanceService.ts +++ b/apps/app/src/modules/governance/api/governanceService/governanceService.ts @@ -1,6 +1,8 @@ +import type { PageDTO, TokenVotingMemberDTO } from '@aragon/aragon-domain'; import { invariant } from '@aragon/gov-ui-kit'; import { lockToVoteProposalUtils } from '@/plugins/lockToVotePlugin/utils/lockToVoteProposalUtils'; import { sppProposalUtils } from '@/plugins/sppPlugin/utils/sppProposalUtils'; +import type { ITokenMember } from '@/plugins/tokenPlugin/types'; import { AragonBackendService, type IPaginatedResponse, @@ -9,6 +11,7 @@ import type { ICanCreateProposalResult, IMemberExistsResult, } from '../../types'; +import { tokenVotingMembershipServiceClient } from '../tokenVotingMembershipService'; import type { IMember, IProposal, @@ -24,10 +27,12 @@ import type { IGetProposalActionsParams, IGetProposalBySlugParams, IGetProposalListParams, + IGetTokenVotingMembershipParams, IGetVoteListParams, } from './governanceService.api'; import { collectTokenAddresses } from './utils/collectTokenAddresses'; import { fetchTokensTotalSupply } from './utils/fetchTokensTotalSupply'; +import { fetchTokenVotingMembership } from './utils/fetchTokenVotingMembership'; class GovernanceService extends AragonBackendService { private urls = { @@ -52,6 +57,21 @@ class GovernanceService extends AragonBackendService { return result; }; + /** + * Gets the membership for token-voting plugins. + * + * This routes between the aragon-domain BFF and the legacy backend + * regardless of source. The generic `getMemberList` still serves multisig/admin. + */ + getTokenVotingMembership = ( + params: IGetTokenVotingMembershipParams, + ): Promise> => + fetchTokenVotingMembership( + params, + tokenVotingMembershipServiceClient.getTokenVotingMembership, + (legacyParams) => this.getMemberList(legacyParams), + ); + getMember = async ( params: IGetMemberParams, ): Promise => { diff --git a/apps/app/src/modules/governance/api/governanceService/governanceServiceKeys.ts b/apps/app/src/modules/governance/api/governanceService/governanceServiceKeys.ts index 3b0b28d02a..2c867cc3f3 100644 --- a/apps/app/src/modules/governance/api/governanceService/governanceServiceKeys.ts +++ b/apps/app/src/modules/governance/api/governanceService/governanceServiceKeys.ts @@ -6,6 +6,7 @@ import type { IGetProposalActionsParams, IGetProposalBySlugParams, IGetProposalListParams, + IGetTokenVotingMembershipParams, IGetVoteListParams, } from './governanceService.api'; @@ -15,6 +16,7 @@ export enum GovernanceServiceKey { PROPOSAL_ACTIONS = 'PROPOSAL_ACTIONS', CAN_CREATE_PROPOSAL = 'CAN_CREATE_PROPOSAL', MEMBER_LIST = 'MEMBER_LIST', + TOKEN_VOTING_MEMBERSHIP = 'TOKEN_VOTING_MEMBERSHIP', MEMBER = 'MEMBER', MEMBER_EXISTS = 'MEMBER_EXISTS', VOTE_LIST = 'VOTE_LIST', @@ -41,6 +43,10 @@ export const governanceServiceKeys = { GovernanceServiceKey.MEMBER_LIST, params, ], + tokenVotingMembership: (params: IGetTokenVotingMembershipParams) => [ + GovernanceServiceKey.TOKEN_VOTING_MEMBERSHIP, + params, + ], member: (params: IGetMemberParams) => [GovernanceServiceKey.MEMBER, params], memberExists: (params: IGetMemberExistsParams) => [ GovernanceServiceKey.MEMBER_EXISTS, diff --git a/apps/app/src/modules/governance/api/governanceService/index.ts b/apps/app/src/modules/governance/api/governanceService/index.ts index 10c52b0ce6..d92bba43a0 100644 --- a/apps/app/src/modules/governance/api/governanceService/index.ts +++ b/apps/app/src/modules/governance/api/governanceService/index.ts @@ -6,3 +6,9 @@ export { governanceServiceKeys, } from './governanceServiceKeys'; export * from './queries'; +export { + buildTokenVotingMembershipParams, + type ITokenVotingMembershipPluginSettings, + isTokenMemberListPlugin, +} from './utils/buildTokenVotingMembershipParams'; +export { mapBackendMemberToTokenVotingDTO } from './utils/mapBackendMemberToTokenVotingDTO'; diff --git a/apps/app/src/modules/governance/api/governanceService/queries/index.ts b/apps/app/src/modules/governance/api/governanceService/queries/index.ts index 117b5ccda5..3ed2b986f8 100644 --- a/apps/app/src/modules/governance/api/governanceService/queries/index.ts +++ b/apps/app/src/modules/governance/api/governanceService/queries/index.ts @@ -4,4 +4,5 @@ export * from './useMemberList'; export * from './useProposalActions'; export * from './useProposalBySlug'; export * from './useProposalList'; +export * from './useTokenVotingMembership'; export * from './useVoteList'; diff --git a/apps/app/src/modules/governance/api/governanceService/queries/useTokenVotingMembership/index.ts b/apps/app/src/modules/governance/api/governanceService/queries/useTokenVotingMembership/index.ts new file mode 100644 index 0000000000..3562b0527b --- /dev/null +++ b/apps/app/src/modules/governance/api/governanceService/queries/useTokenVotingMembership/index.ts @@ -0,0 +1 @@ +export { tokenVotingMembershipOptions } from './useTokenVotingMembership'; diff --git a/apps/app/src/modules/governance/api/governanceService/queries/useTokenVotingMembership/useTokenVotingMembership.server.ts b/apps/app/src/modules/governance/api/governanceService/queries/useTokenVotingMembership/useTokenVotingMembership.server.ts new file mode 100644 index 0000000000..0973022b32 --- /dev/null +++ b/apps/app/src/modules/governance/api/governanceService/queries/useTokenVotingMembership/useTokenVotingMembership.server.ts @@ -0,0 +1,44 @@ +import 'server-only'; +import type { PageDTO, TokenVotingMemberDTO } from '@aragon/aragon-domain'; +import type { ITokenMember } from '@/plugins/tokenPlugin/types'; +import type { + InfiniteQueryOptions, + SharedInfiniteQueryOptions, +} from '@/shared/types'; +import { tokenVotingMembershipServiceServer } from '../../../tokenVotingMembershipService/tokenVotingMembershipService.server'; +import { governanceService } from '../../governanceService'; +import type { IGetTokenVotingMembershipParams } from '../../governanceService.api'; +import { fetchTokenVotingMembership } from '../../utils/fetchTokenVotingMembership'; +import { tokenVotingMembershipOptions } from './useTokenVotingMembership'; + +/** + * Server variant of `tokenVotingMembershipOptions` for RSC prefetching. It + * shares the query key with the client options, so the dehydrated cache + * resolves the client query without a second network call, but routes the + * aragon-domain branch through the in-process controller instead of the BFF + * route (a server-side relative fetch would fail). + * + * @example + * await queryClient.prefetchInfiniteQuery( + * tokenVotingMembershipOptionsServer(params), + * ); + */ +export const tokenVotingMembershipOptionsServer = ( + params: IGetTokenVotingMembershipParams, + options?: InfiniteQueryOptions< + PageDTO, + IGetTokenVotingMembershipParams + >, +): SharedInfiniteQueryOptions< + PageDTO, + IGetTokenVotingMembershipParams +> => ({ + ...tokenVotingMembershipOptions(params, options), + queryFn: ({ pageParam }) => + fetchTokenVotingMembership( + pageParam, + tokenVotingMembershipServiceServer.getTokenVotingMembership, + (legacyParams) => + governanceService.getMemberList(legacyParams), + ), +}); diff --git a/apps/app/src/modules/governance/api/governanceService/queries/useTokenVotingMembership/useTokenVotingMembership.ts b/apps/app/src/modules/governance/api/governanceService/queries/useTokenVotingMembership/useTokenVotingMembership.ts new file mode 100644 index 0000000000..2449b3653e --- /dev/null +++ b/apps/app/src/modules/governance/api/governanceService/queries/useTokenVotingMembership/useTokenVotingMembership.ts @@ -0,0 +1,35 @@ +import type { PageDTO, TokenVotingMemberDTO } from '@aragon/aragon-domain'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import type { + InfiniteQueryOptions, + SharedInfiniteQueryOptions, +} from '@/shared/types'; +import { governanceService } from '../../governanceService'; +import type { IGetTokenVotingMembershipParams } from '../../governanceService.api'; +import { governanceServiceKeys } from '../../governanceServiceKeys'; + +export const tokenVotingMembershipOptions = ( + params: IGetTokenVotingMembershipParams, + options?: InfiniteQueryOptions< + PageDTO, + IGetTokenVotingMembershipParams + >, +): SharedInfiniteQueryOptions< + PageDTO, + IGetTokenVotingMembershipParams +> => ({ + queryKey: governanceServiceKeys.tokenVotingMembership(params), + initialPageParam: params, + queryFn: ({ pageParam }) => + governanceService.getTokenVotingMembership(pageParam), + getNextPageParam: governanceService.getNextPageParams, + ...options, +}); + +export const useTokenVotingMembership = ( + params: IGetTokenVotingMembershipParams, + options?: InfiniteQueryOptions< + PageDTO, + IGetTokenVotingMembershipParams + >, +) => useInfiniteQuery(tokenVotingMembershipOptions(params, options)); diff --git a/apps/app/src/modules/governance/api/governanceService/utils/buildTokenVotingMembershipParams/buildTokenVotingMembershipParams.test.ts b/apps/app/src/modules/governance/api/governanceService/utils/buildTokenVotingMembershipParams/buildTokenVotingMembershipParams.test.ts new file mode 100644 index 0000000000..706056ba1e --- /dev/null +++ b/apps/app/src/modules/governance/api/governanceService/utils/buildTokenVotingMembershipParams/buildTokenVotingMembershipParams.test.ts @@ -0,0 +1,101 @@ +import { Network, PluginInterfaceType } from '@/shared/api/daoService'; +import { + generateDao, + generateDaoPlugin, + generatePluginSettings, +} from '@/shared/testUtils'; +import { + buildTokenVotingMembershipParams, + type ITokenVotingMembershipPluginSettings, + isTokenMemberListPlugin, +} from './buildTokenVotingMembershipParams'; + +describe('buildTokenVotingMembershipParams', () => { + const initialParams = { + queryParams: { + daoId: 'dao-id', + pluginAddress: '0xPlugin', + pageSize: 18, + }, + }; + + const generateMembershipPlugin = ( + interfaceType: PluginInterfaceType, + token: ITokenVotingMembershipPluginSettings['token'], + ) => + generateDaoPlugin({ + interfaceType, + settings: { ...generatePluginSettings(), token }, + }); + + it('builds the routing params from the plugin settings and DAO network', () => { + const plugin = generateMembershipPlugin( + PluginInterfaceType.TOKEN_VOTING, + { address: '0xToken', underlying: '0xUnderlying' }, + ); + const dao = generateDao({ network: Network.ETHEREUM_MAINNET }); + + const result = buildTokenVotingMembershipParams( + initialParams, + plugin, + dao, + ); + + expect(result.queryParams).toEqual({ + daoId: 'dao-id', + pluginAddress: '0xPlugin', + pageSize: 18, + network: Network.ETHEREUM_MAINNET, + pluginInterfaceType: PluginInterfaceType.TOKEN_VOTING, + tokenAddress: '0xToken', + tokenUnderlying: '0xUnderlying', + }); + }); + + it('normalizes a missing underlying to null (lock-to-vote tokens do not carry the field)', () => { + const plugin = generateMembershipPlugin( + PluginInterfaceType.LOCK_TO_VOTE, + { address: '0xToken' }, + ); + const dao = generateDao({ network: Network.POLYGON_MAINNET }); + + const { queryParams } = buildTokenVotingMembershipParams( + initialParams, + plugin, + dao, + ); + + expect(queryParams.tokenAddress).toBe('0xToken'); + expect(queryParams.tokenUnderlying).toBeNull(); + expect(queryParams.pluginInterfaceType).toBe( + PluginInterfaceType.LOCK_TO_VOTE, + ); + }); + + it('preserves the original daoId for non-linked-account plugins', () => { + const plugin = generateMembershipPlugin( + PluginInterfaceType.TOKEN_VOTING, + { address: '0xToken' }, + ); + + const { queryParams } = buildTokenVotingMembershipParams( + initialParams, + plugin, + generateDao(), + ); + + expect(queryParams.daoId).toBe('dao-id'); + }); +}); + +describe('isTokenMemberListPlugin', () => { + it.each([ + [PluginInterfaceType.TOKEN_VOTING, true], + [PluginInterfaceType.LOCK_TO_VOTE, true], + [PluginInterfaceType.MULTISIG, false], + [PluginInterfaceType.ADMIN, false], + ])('returns %s → %s', (interfaceType, expected) => { + const plugin = generateDaoPlugin({ interfaceType }); + expect(isTokenMemberListPlugin(plugin)).toBe(expected); + }); +}); diff --git a/apps/app/src/modules/governance/api/governanceService/utils/buildTokenVotingMembershipParams/buildTokenVotingMembershipParams.ts b/apps/app/src/modules/governance/api/governanceService/utils/buildTokenVotingMembershipParams/buildTokenVotingMembershipParams.ts new file mode 100644 index 0000000000..1facdc2a83 --- /dev/null +++ b/apps/app/src/modules/governance/api/governanceService/utils/buildTokenVotingMembershipParams/buildTokenVotingMembershipParams.ts @@ -0,0 +1,75 @@ +import { + type IDao, + type IDaoPlugin, + type IPluginSettings, + PluginInterfaceType, +} from '@/shared/api/daoService'; +import { daoUtils } from '@/shared/utils/daoUtils'; +import type { + IGetMemberListParams, + IGetTokenVotingMembershipParams, +} from '../../governanceService.api'; + +export interface ITokenVotingMembershipPluginSettings extends IPluginSettings { + /** + * Governance token of the plugin. `underlying` is only set on the token + * plugin's wrapped / VE-adapter governance tokens. + */ + token: { + address: string; + underlying?: string | null; + }; +} + +const tokenMemberListPlugins: PluginInterfaceType[] = [ + PluginInterfaceType.TOKEN_VOTING, + PluginInterfaceType.LOCK_TO_VOTE, +]; + +/** + * Plugins whose member list renders through `TokenMemberListBase` and thus + * consumes the token-voting membership query instead of the generic member + * list. + * + * The guard narrows a generic plugin to the token-carrying settings this + * module needs. + */ +export const isTokenMemberListPlugin = ( + plugin: IDaoPlugin, +): plugin is IDaoPlugin => + tokenMemberListPlugins.includes(plugin.interfaceType); + +/** + * Builds the token-voting membership query params from a plugin and its DAO. + * Used by both the client member list and the members-page RSC prefetch — + * the two MUST build byte-identical params, otherwise the query keys diverge + * and the dehydrated server cache never resolves the client query. + * + * For linked account plugins the API call must target the linked account's + * own daoId so the backend queries the correct DAO. + */ +export const buildTokenVotingMembershipParams = ( + initialParams: IGetMemberListParams, + plugin: IDaoPlugin, + dao?: IDao, +): IGetTokenVotingMembershipParams => { + const resolvedDaoId = daoUtils.resolvePluginDaoId( + initialParams.queryParams.daoId, + plugin, + dao, + ); + + const { token } = plugin.settings; + + return { + ...initialParams, + queryParams: { + ...initialParams.queryParams, + daoId: resolvedDaoId, + network: dao?.network, + pluginInterfaceType: plugin.interfaceType, + tokenAddress: token.address, + tokenUnderlying: token.underlying ?? null, + }, + }; +}; diff --git a/apps/app/src/modules/governance/api/governanceService/utils/buildTokenVotingMembershipParams/index.ts b/apps/app/src/modules/governance/api/governanceService/utils/buildTokenVotingMembershipParams/index.ts new file mode 100644 index 0000000000..9fa9b3ba43 --- /dev/null +++ b/apps/app/src/modules/governance/api/governanceService/utils/buildTokenVotingMembershipParams/index.ts @@ -0,0 +1,5 @@ +export { + buildTokenVotingMembershipParams, + type ITokenVotingMembershipPluginSettings, + isTokenMemberListPlugin, +} from './buildTokenVotingMembershipParams'; diff --git a/apps/app/src/modules/governance/api/governanceService/utils/fetchTokenVotingMembership/fetchTokenVotingMembership.test.ts b/apps/app/src/modules/governance/api/governanceService/utils/fetchTokenVotingMembership/fetchTokenVotingMembership.test.ts new file mode 100644 index 0000000000..f62f747b6a --- /dev/null +++ b/apps/app/src/modules/governance/api/governanceService/utils/fetchTokenVotingMembership/fetchTokenVotingMembership.test.ts @@ -0,0 +1,79 @@ +import { Network, PluginInterfaceType } from '@/shared/api/daoService'; +import { generatePaginatedResponse } from '@/shared/testUtils'; +import type { IGetTokenVotingMembershipQueryParams } from '../../governanceService.api'; +import { fetchTokenVotingMembership } from './fetchTokenVotingMembership'; + +describe('fetchTokenVotingMembership', () => { + const baseQueryParams: IGetTokenVotingMembershipQueryParams = { + daoId: 'dao-id', + pluginAddress: '0xPlugin', + tokenAddress: '0xToken', + network: Network.ETHEREUM_MAINNET, + pluginInterfaceType: PluginInterfaceType.TOKEN_VOTING, + }; + + const createFetchers = () => ({ + fetchDomainMembers: jest + .fn() + .mockResolvedValue(generatePaginatedResponse({})), + fetchLegacyMemberList: jest + .fn() + .mockResolvedValue(generatePaginatedResponse({})), + }); + + it('routes mainnet token-voting plain ERC-20 to the aragon-domain with the request DTO', async () => { + const { fetchDomainMembers, fetchLegacyMemberList } = createFetchers(); + + await fetchTokenVotingMembership( + { queryParams: { ...baseQueryParams, page: 2, pageSize: 25 } }, + fetchDomainMembers, + fetchLegacyMemberList, + ); + + expect(fetchLegacyMemberList).not.toHaveBeenCalled(); + expect(fetchDomainMembers).toHaveBeenCalledWith({ + queryParams: { + pluginAddress: '0xplugin', + tokenContractAddress: '0xtoken', + page: 2, + pageSize: 25, + }, + }); + }); + + it.each([ + [ + 'non-mainnet network', + { ...baseQueryParams, network: Network.POLYGON_MAINNET }, + ], + [ + 'non-token-voting interface type', + { + ...baseQueryParams, + pluginInterfaceType: PluginInterfaceType.MULTISIG, + }, + ], + [ + 'missing tokenAddress', + { ...baseQueryParams, tokenAddress: undefined }, + ], + ['missing network', { ...baseQueryParams, network: undefined }], + [ + 'wrapped / VE-adapter governance token', + { ...baseQueryParams, tokenUnderlying: '0xunderlying' }, + ], + ])('routes to the legacy backend with stripped routing fields for %s', async (_label, queryParams) => { + const { fetchDomainMembers, fetchLegacyMemberList } = createFetchers(); + + await fetchTokenVotingMembership( + { queryParams }, + fetchDomainMembers, + fetchLegacyMemberList, + ); + + expect(fetchDomainMembers).not.toHaveBeenCalled(); + expect(fetchLegacyMemberList).toHaveBeenCalledWith({ + queryParams: { daoId: 'dao-id', pluginAddress: '0xPlugin' }, + }); + }); +}); diff --git a/apps/app/src/modules/governance/api/governanceService/utils/fetchTokenVotingMembership/fetchTokenVotingMembership.ts b/apps/app/src/modules/governance/api/governanceService/utils/fetchTokenVotingMembership/fetchTokenVotingMembership.ts new file mode 100644 index 0000000000..9e38ff55fb --- /dev/null +++ b/apps/app/src/modules/governance/api/governanceService/utils/fetchTokenVotingMembership/fetchTokenVotingMembership.ts @@ -0,0 +1,101 @@ +import type { PageDTO, TokenVotingMemberDTO } from '@aragon/aragon-domain'; +import type { ITokenMember } from '@/plugins/tokenPlugin/types'; +import type { IPaginatedResponse } from '@/shared/api/aragonBackendService'; +import { Network, PluginInterfaceType } from '@/shared/api/daoService'; +import type { IGetTokenVotingMembersParams } from '../../../tokenVotingMembershipService'; +import type { + IGetMemberListParams, + IGetTokenVotingMembershipParams, + IGetTokenVotingMembershipQueryParams, +} from '../../governanceService.api'; +import { mapBackendMemberToTokenVotingDTO } from '../mapBackendMemberToTokenVotingDTO'; + +export type FetchDomainTokenVotingMembers = ( + params: IGetTokenVotingMembersParams, +) => Promise>; + +export type FetchLegacyMemberList = ( + params: IGetMemberListParams, +) => Promise>; + +/** + * Networks whose token-voting member queries are served by the + * aragon-domain BFF. Expand as more networks are indexed by aragon-indexer. + */ +const DOMAIN_NETWORKS: ReadonlySet = new Set([ + Network.ETHEREUM_MAINNET, +]); + +/** + * Decides which backing source serves a token-voting member query. + * + * The aragon-domain only covers plain ERC-20 token-voting governance + * tokens on the networks in `DOMAIN_NETWORKS`. Wrapped, VE-adapter tokens, + * and every other plugin type or network continue to use the legacy backend + * until the aragon-domain supports them. + */ +const resolveMemberSource = ( + queryParams: IGetTokenVotingMembershipQueryParams, +): 'domain' | 'backend' => { + const pluginAddress = queryParams.pluginAddress?.toLowerCase(); + const tokenAddress = queryParams.tokenAddress?.toLowerCase(); + const network = queryParams.network; + const interfaceType = queryParams.pluginInterfaceType; + const tokenUnderlying = queryParams.tokenUnderlying; + + const useDomain = + pluginAddress != null && + tokenAddress != null && + network != null && + DOMAIN_NETWORKS.has(network) && + interfaceType === PluginInterfaceType.TOKEN_VOTING && + tokenUnderlying == null; + + return useDomain ? 'domain' : 'backend'; +}; + +/** + * Routes token-voting membership queries. + * + * The legacy branch strips the routing-only fields before forwarding so no + * unknown query params reach the backend, then normalizes the response + * through the anti-corruption mapper. Both branches return the library-owned + * `TokenVotingMemberDTO` page. + */ +export const fetchTokenVotingMembership = async ( + params: IGetTokenVotingMembershipParams, + fetchDomainMembers: FetchDomainTokenVotingMembers, + fetchLegacyMemberList: FetchLegacyMemberList, +): Promise> => { + const { queryParams } = params; + const { tokenAddress, page, pageSize } = queryParams; + + if (tokenAddress != null && resolveMemberSource(queryParams) === 'domain') { + return fetchDomainMembers({ + queryParams: { + pluginAddress: queryParams.pluginAddress.toLowerCase(), + tokenContractAddress: tokenAddress.toLowerCase(), + page, + pageSize, + }, + }); + } + + const { + network: _network, + pluginInterfaceType: _pluginInterfaceType, + tokenAddress: _tokenAddress, + tokenUnderlying: _tokenUnderlying, + ...legacyQueryParams + } = queryParams; + + const result = await fetchLegacyMemberList({ + ...params, + queryParams: legacyQueryParams, + }); + + return { + ...result, + data: result.data.map(mapBackendMemberToTokenVotingDTO), + }; +}; diff --git a/apps/app/src/modules/governance/api/governanceService/utils/fetchTokenVotingMembership/index.ts b/apps/app/src/modules/governance/api/governanceService/utils/fetchTokenVotingMembership/index.ts new file mode 100644 index 0000000000..b1f8d43c01 --- /dev/null +++ b/apps/app/src/modules/governance/api/governanceService/utils/fetchTokenVotingMembership/index.ts @@ -0,0 +1,5 @@ +export { + type FetchDomainTokenVotingMembers, + type FetchLegacyMemberList, + fetchTokenVotingMembership, +} from './fetchTokenVotingMembership'; diff --git a/apps/app/src/modules/governance/api/governanceService/utils/mapBackendMemberToTokenVotingDTO/index.ts b/apps/app/src/modules/governance/api/governanceService/utils/mapBackendMemberToTokenVotingDTO/index.ts new file mode 100644 index 0000000000..2f45b0cf2c --- /dev/null +++ b/apps/app/src/modules/governance/api/governanceService/utils/mapBackendMemberToTokenVotingDTO/index.ts @@ -0,0 +1 @@ +export { mapBackendMemberToTokenVotingDTO } from './mapBackendMemberToTokenVotingDTO'; diff --git a/apps/app/src/modules/governance/api/governanceService/utils/mapBackendMemberToTokenVotingDTO/mapBackendMemberToTokenVotingDTO.test.ts b/apps/app/src/modules/governance/api/governanceService/utils/mapBackendMemberToTokenVotingDTO/mapBackendMemberToTokenVotingDTO.test.ts new file mode 100644 index 0000000000..1932b95fd8 --- /dev/null +++ b/apps/app/src/modules/governance/api/governanceService/utils/mapBackendMemberToTokenVotingDTO/mapBackendMemberToTokenVotingDTO.test.ts @@ -0,0 +1,42 @@ +import { generateTokenMember } from '@/plugins/tokenPlugin/testUtils'; +import { mapBackendMemberToTokenVotingDTO } from './mapBackendMemberToTokenVotingDTO'; + +describe('mapBackendMemberToTokenVotingDTO', () => { + it('passes address, ens, votingPower and delegationCount through and emits null activity timestamps', () => { + const member = generateTokenMember({ + address: '0xabc', + ens: 'alice.eth', + votingPower: '5000', + metrics: { + firstActivity: 100, + lastActivity: 200, + delegationCount: 3, + }, + }); + + expect(mapBackendMemberToTokenVotingDTO(member)).toEqual({ + address: '0xabc', + ens: 'alice.eth', + votingPower: '5000', + // The legacy backend only reports activity as block numbers, + // which the mapper does not resolve to timestamps. + firstActivityTimestamp: null, + lastActivityTimestamp: null, + delegationCount: 3, + }); + }); + + it('drops the type / firstActive / lastActive fields', () => { + const member = generateTokenMember({ + type: 'token-voting', + firstActive: 100, + lastActive: 200, + }); + + const dto = mapBackendMemberToTokenVotingDTO(member); + + expect(dto).not.toHaveProperty('type'); + expect(dto).not.toHaveProperty('firstActive'); + expect(dto).not.toHaveProperty('lastActive'); + }); +}); diff --git a/apps/app/src/modules/governance/api/governanceService/utils/mapBackendMemberToTokenVotingDTO/mapBackendMemberToTokenVotingDTO.ts b/apps/app/src/modules/governance/api/governanceService/utils/mapBackendMemberToTokenVotingDTO/mapBackendMemberToTokenVotingDTO.ts new file mode 100644 index 0000000000..cc34bdc4a5 --- /dev/null +++ b/apps/app/src/modules/governance/api/governanceService/utils/mapBackendMemberToTokenVotingDTO/mapBackendMemberToTokenVotingDTO.ts @@ -0,0 +1,24 @@ +import type { TokenVotingMemberDTO } from '@aragon/aragon-domain'; +import type { ITokenMember } from '@/plugins/tokenPlugin/types'; + +/** + * Anti-corruption boundary: Maps a legacy backend token member into the + * aragon-domain `TokenVotingMemberDTO` that the token list renders from. + * + * The legacy backend reports activity as block numbers, which cannot be + * resolved to the DTO's activity timestamps without per-member RPC + * lookups. The mapper emits `null` until the UI actually needs pre-migration + * activity data. Nothing renders the list timestamps today. + * + * This survives until the legacy backend list path is retired. + */ +export const mapBackendMemberToTokenVotingDTO = ( + member: ITokenMember, +): TokenVotingMemberDTO => ({ + address: member.address, + ens: member.ens, + votingPower: member.votingPower, + firstActivityTimestamp: null, + lastActivityTimestamp: null, + delegationCount: member.metrics.delegationCount, +}); diff --git a/apps/app/src/modules/governance/api/tokenVotingMembershipService/index.ts b/apps/app/src/modules/governance/api/tokenVotingMembershipService/index.ts new file mode 100644 index 0000000000..9a945c4c1c --- /dev/null +++ b/apps/app/src/modules/governance/api/tokenVotingMembershipService/index.ts @@ -0,0 +1,2 @@ +export type { IGetTokenVotingMembersParams } from './tokenVotingMembershipService.api'; +export { tokenVotingMembershipServiceClient } from './tokenVotingMembershipService.client'; diff --git a/apps/app/src/modules/governance/api/tokenVotingMembershipService/tokenVotingMembershipService.api.ts b/apps/app/src/modules/governance/api/tokenVotingMembershipService/tokenVotingMembershipService.api.ts new file mode 100644 index 0000000000..70444fea08 --- /dev/null +++ b/apps/app/src/modules/governance/api/tokenVotingMembershipService/tokenVotingMembershipService.api.ts @@ -0,0 +1,5 @@ +import type { GetTokenVotingMembershipRequestDTO } from '@aragon/aragon-domain'; +import type { IRequestQueryParams } from '@/shared/api/httpService'; + +export interface IGetTokenVotingMembersParams + extends IRequestQueryParams {} diff --git a/apps/app/src/modules/governance/api/tokenVotingMembershipService/tokenVotingMembershipService.client.ts b/apps/app/src/modules/governance/api/tokenVotingMembershipService/tokenVotingMembershipService.client.ts new file mode 100644 index 0000000000..5eaa7bb3dd --- /dev/null +++ b/apps/app/src/modules/governance/api/tokenVotingMembershipService/tokenVotingMembershipService.client.ts @@ -0,0 +1,23 @@ +import type { PageDTO, TokenVotingMemberDTO } from '@aragon/aragon-domain'; +import { AragonDomainServiceClient } from '@/shared/api/aragonDomainService'; +import type { IGetTokenVotingMembersParams } from './tokenVotingMembershipService.api'; + +class TokenVotingMembershipServiceClient extends AragonDomainServiceClient { + private urls = { + members: '/token-voting/members', + }; + + getTokenVotingMembership = async ( + params: IGetTokenVotingMembersParams, + ): Promise> => { + const result = await this.request>( + this.urls.members, + params, + ); + + return result; + }; +} + +export const tokenVotingMembershipServiceClient = + new TokenVotingMembershipServiceClient(); diff --git a/apps/app/src/modules/governance/api/tokenVotingMembershipService/tokenVotingMembershipService.server.ts b/apps/app/src/modules/governance/api/tokenVotingMembershipService/tokenVotingMembershipService.server.ts new file mode 100644 index 0000000000..8851a0f948 --- /dev/null +++ b/apps/app/src/modules/governance/api/tokenVotingMembershipService/tokenVotingMembershipService.server.ts @@ -0,0 +1,28 @@ +import 'server-only'; +import type { PageDTO, TokenVotingMemberDTO } from '@aragon/aragon-domain'; +import { aragonDomainServiceBackend } from '@/shared/api/aragonDomainService/aragonDomainService.backend'; +import type { IGetTokenVotingMembersParams } from './tokenVotingMembershipService.api'; + +class TokenVotingMembershipServiceServer { + getTokenVotingMembership = async ({ + queryParams, + }: IGetTokenVotingMembersParams): Promise< + PageDTO + > => { + const result = await aragonDomainServiceBackend + .getDomain() + .getTokenVotingMembership(queryParams); + + if (!result.success) { + throw new Error( + 'TokenVotingMembershipServiceServer: getTokenVotingMembership failed', + { cause: result.error }, + ); + } + + return result.result; + }; +} + +export const tokenVotingMembershipServiceServer = + new TokenVotingMembershipServiceServer(); diff --git a/apps/app/src/modules/governance/hooks/useTokenVotingMembershipData/index.ts b/apps/app/src/modules/governance/hooks/useTokenVotingMembershipData/index.ts new file mode 100644 index 0000000000..6662e64506 --- /dev/null +++ b/apps/app/src/modules/governance/hooks/useTokenVotingMembershipData/index.ts @@ -0,0 +1 @@ +export { useTokenVotingMembershipData } from './useTokenVotingMembershipData'; diff --git a/apps/app/src/modules/governance/hooks/useTokenVotingMembershipData/useTokenVotingMembershipData.ts b/apps/app/src/modules/governance/hooks/useTokenVotingMembershipData/useTokenVotingMembershipData.ts new file mode 100644 index 0000000000..c56b4b7dd2 --- /dev/null +++ b/apps/app/src/modules/governance/hooks/useTokenVotingMembershipData/useTokenVotingMembershipData.ts @@ -0,0 +1,65 @@ +import type { PageDTO, TokenVotingMemberDTO } from '@aragon/aragon-domain'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { + type IGetTokenVotingMembershipParams, + tokenVotingMembershipOptions, +} from '@/modules/governance/api/governanceService'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import type { InfiniteQueryOptions } from '@/shared/types'; +import { dataListUtils } from '@/shared/utils/dataListUtils'; + +/** + * Gets the membership for token-voting plugins. + * + * This routes between the aragon-domain BFF and the legacy backend + * regardless of source. The generic `getMemberList` still serves multisig/admin. + */ +export const useTokenVotingMembershipData = ( + params: IGetTokenVotingMembershipParams, + options?: InfiniteQueryOptions< + PageDTO, + IGetTokenVotingMembershipParams + >, +) => { + const { t } = useTranslations(); + + const { + data: membershipData, + status, + fetchStatus, + isFetchingNextPage, + fetchNextPage, + } = useInfiniteQuery(tokenVotingMembershipOptions(params, options)); + + const memberList = membershipData?.pages.flatMap((page) => page.data); + const state = dataListUtils.queryToDataListState({ + status, + fetchStatus, + isFetchingNextPage, + }); + + const pageSize = + params.queryParams.pageSize ?? + membershipData?.pages[0].metadata.pageSize; + const itemsCount = membershipData?.pages[0].metadata.totalRecords; + + const errorState = { + heading: t('app.governance.daoMemberList.error.title'), + description: t('app.governance.daoMemberList.error.description'), + }; + + const emptyState = { + heading: t('app.governance.daoMemberList.empty.title'), + description: t('app.governance.daoMemberList.empty.description'), + }; + + return { + onLoadMore: fetchNextPage, + memberList, + state, + pageSize, + itemsCount, + emptyState, + errorState, + }; +}; diff --git a/apps/app/src/modules/governance/pages/daoMemberDetailsPage/daoMemberDetailsPageClient.test.tsx b/apps/app/src/modules/governance/pages/daoMemberDetailsPage/daoMemberDetailsPageClient.test.tsx index 77677c9d71..79e4b8dbfb 100644 --- a/apps/app/src/modules/governance/pages/daoMemberDetailsPage/daoMemberDetailsPageClient.test.tsx +++ b/apps/app/src/modules/governance/pages/daoMemberDetailsPage/daoMemberDetailsPageClient.test.tsx @@ -25,7 +25,7 @@ import { import { networkUtils } from '@/shared/utils/networkUtils'; import { timeUtils } from '@/test/utils'; import * as governanceService from '../../api/governanceService'; -import { generateMember, generateMemberMetrics } from '../../testUtils'; +import { generateMember } from '../../testUtils'; import { DaoMemberDetailsPageClient, type IDaoMemberDetailsPageClientProps, @@ -396,7 +396,6 @@ describe(' component', () => { }); it('renders fallback of `-` when lastActive is null', () => { - const metrics = generateMemberMetrics({ firstActivity: 1_723_472_877 }); useBlockSpy .mockReturnValueOnce({ data: { timestamp: 3_204_230_420 }, @@ -406,7 +405,10 @@ describe(' component', () => { } as unknown as wagmi.UseBlockReturnType); useMemberSpy.mockReturnValue( generateReactQueryResultSuccess({ - data: generateMember({ metrics, lastActive: null }), + data: generateMember({ + firstActive: 1_723_472_877, + lastActive: null, + }), }), ); @@ -435,7 +437,7 @@ describe(' component', () => { ).toBeInTheDocument(); }); - it('renders fallback of `-` when firstActivity is null', () => { + it('renders fallback of `-` when firstActive is null', () => { const lastActive = 1_723_472_877; useBlockSpy .mockReturnValueOnce({ @@ -455,20 +457,20 @@ describe(' component', () => { }); it('renders the correct first activity date', () => { - const metrics = generateMemberMetrics({ firstActivity: 1_723_472_877 }); + const firstActive = 1_723_472_877; useBlockSpy.mockReturnValue({ - data: { timestamp: metrics.firstActivity }, + data: { timestamp: firstActive }, } as unknown as wagmi.UseBlockReturnType); useMemberSpy.mockReturnValue( generateReactQueryResultSuccess({ - data: generateMember({ metrics }), + data: generateMember({ firstActive }), }), ); render(createTestComponent()); const firstActivityDate = formatterUtils.formatDate( - metrics.firstActivity! * 1000, + firstActive * 1000, { format: DateFormat.YEAR_MONTH_DAY, }, diff --git a/apps/app/src/modules/governance/pages/daoMemberDetailsPage/daoMemberDetailsPageClient.tsx b/apps/app/src/modules/governance/pages/daoMemberDetailsPage/daoMemberDetailsPageClient.tsx index bc2554515c..94620a42c5 100644 --- a/apps/app/src/modules/governance/pages/daoMemberDetailsPage/daoMemberDetailsPageClient.tsx +++ b/apps/app/src/modules/governance/pages/daoMemberDetailsPage/daoMemberDetailsPageClient.tsx @@ -92,6 +92,8 @@ export const DaoMemberDetailsPageClient: React.FC< }; const { data: member } = useMember(memberParams); + // The single-member endpoint is still served by the legacy backend, which + // reports activity as block numbers. This resolves them to dates on-chain. const { firstActive, lastActive } = member ?? {}; const { chainId, buildEntityUrl } = useDaoChain({ daoId }); diff --git a/apps/app/src/modules/governance/pages/daoMembersPage/daoMembersPage.tsx b/apps/app/src/modules/governance/pages/daoMembersPage/daoMembersPage.tsx index 41b945828c..517d16a98e 100644 --- a/apps/app/src/modules/governance/pages/daoMembersPage/daoMembersPage.tsx +++ b/apps/app/src/modules/governance/pages/daoMembersPage/daoMembersPage.tsx @@ -7,7 +7,12 @@ import { type IDaoPageParams, PluginType } from '@/shared/types'; import { daoUtils } from '@/shared/utils/daoUtils'; import { daoVisibilityUtils } from '@/shared/utils/daoVisibilityUtils'; import { networkUtils } from '@/shared/utils/networkUtils'; -import { memberListOptions } from '../../api/governanceService'; +import { + buildTokenVotingMembershipParams, + isTokenMemberListPlugin, + memberListOptions, +} from '../../api/governanceService'; +import { tokenVotingMembershipOptionsServer } from '../../api/governanceService/queries/useTokenVotingMembership/useTokenVotingMembership.server'; import { DaoMembersPageClient } from './daoMembersPageClient'; export interface IDaoMembersPageProps { @@ -57,16 +62,32 @@ export const DaoMembersPage: React.FC = async (props) => { return ; } - const bodyPluginAddress = plugins[0].address; + const bodyPlugin = plugins[0]; const memberListQueryParams = { daoId, - pluginAddress: bodyPluginAddress, + pluginAddress: bodyPlugin.address, pageSize: daoMembersCount, }; const memberListParams = { queryParams: memberListQueryParams }; - await queryClient.prefetchInfiniteQuery( - memberListOptions({ queryParams: memberListQueryParams }), - ); + + // Token-voting and lock-to-vote lists consume the token-voting membership + // query. Every other plugin uses the generic member list. The prefetched + // key must match what the list component builds on the client. + if (isTokenMemberListPlugin(bodyPlugin)) { + await queryClient.prefetchInfiniteQuery( + tokenVotingMembershipOptionsServer( + buildTokenVotingMembershipParams( + memberListParams, + bodyPlugin, + dao, + ), + ), + ); + } else { + await queryClient.prefetchInfiniteQuery( + memberListOptions({ queryParams: memberListQueryParams }), + ); + } return ( diff --git a/apps/app/src/modules/governance/pages/daoMembersPage/index.ts b/apps/app/src/modules/governance/pages/daoMembersPage/index.ts index 55a56dea26..61bc5ca193 100644 --- a/apps/app/src/modules/governance/pages/daoMembersPage/index.ts +++ b/apps/app/src/modules/governance/pages/daoMembersPage/index.ts @@ -1,2 +1,3 @@ -export { DaoMembersPage, type IDaoMembersPageProps } from './daoMembersPage'; +// The DaoMembersPage RSC is NOT exported here on purpose since imports +// `server-only` modules. This barrel is imported by client components. export { daoMembersPageFilterParam } from './daoMembersPageClient'; diff --git a/apps/app/src/plugins/lockToVotePlugin/components/lockToVoteMemberList/lockToVoteMemberList.test.tsx b/apps/app/src/plugins/lockToVotePlugin/components/lockToVoteMemberList/lockToVoteMemberList.test.tsx index 7b65a290e6..30ce862c25 100644 --- a/apps/app/src/plugins/lockToVotePlugin/components/lockToVoteMemberList/lockToVoteMemberList.test.tsx +++ b/apps/app/src/plugins/lockToVotePlugin/components/lockToVoteMemberList/lockToVoteMemberList.test.tsx @@ -1,7 +1,7 @@ import { GukModulesProvider } from '@aragon/gov-ui-kit'; import { render, screen } from '@testing-library/react'; import * as wagmi from 'wagmi'; -import * as useMemberListData from '@/modules/governance/hooks/useMemberListData'; +import * as useTokenVotingMembershipDataModule from '@/modules/governance/hooks/useTokenVotingMembershipData'; import type { ITokenMember } from '@/plugins/tokenPlugin/types'; import * as daoService from '@/shared/api/daoService'; import { @@ -33,9 +33,9 @@ jest.mock('./lockToVoteMemberListLockCardEmptyState', () => ({ })); describe(' component', () => { - const useMemberListDataSpy = jest.spyOn( - useMemberListData, - 'useMemberListData', + const useTokenVotingMembershipDataSpy = jest.spyOn( + useTokenVotingMembershipDataModule, + 'useTokenVotingMembershipData', ); const useDaoSpy = jest.spyOn(daoService, 'useDao'); const useConnectionSpy = jest.spyOn(wagmi, 'useConnection'); @@ -45,7 +45,7 @@ describe(' component', () => { ); beforeEach(() => { - useMemberListDataSpy.mockReturnValue({ + useTokenVotingMembershipDataSpy.mockReturnValue({ memberList: undefined, onLoadMore: jest.fn(), state: 'idle', @@ -67,7 +67,7 @@ describe(' component', () => { }); afterEach(() => { - useMemberListDataSpy.mockReset(); + useTokenVotingMembershipDataSpy.mockReset(); useDaoSpy.mockReset(); useConnectionSpy.mockReset(); useLockOnboardingCheckSpy.mockReset(); diff --git a/apps/app/src/plugins/tokenPlugin/components/tokenMemberList/components/tokenMemberListItem.test.tsx b/apps/app/src/plugins/tokenPlugin/components/tokenMemberList/components/tokenMemberListItem.test.tsx index c12ce73c2b..4c7e3882a8 100644 --- a/apps/app/src/plugins/tokenPlugin/components/tokenMemberList/components/tokenMemberListItem.test.tsx +++ b/apps/app/src/plugins/tokenPlugin/components/tokenMemberList/components/tokenMemberListItem.test.tsx @@ -2,9 +2,9 @@ import { GukModulesProvider } from '@aragon/gov-ui-kit'; import { render, screen } from '@testing-library/react'; import * as ensModule from '@/modules/ens'; import { - generateTokenMember, generateTokenPluginSettings, generateTokenPluginSettingsToken, + generateTokenVotingMember, } from '@/plugins/tokenPlugin/testUtils'; import * as daoService from '@/shared/api/daoService'; import { Network } from '@/shared/api/daoService'; @@ -47,7 +47,7 @@ describe(' component', () => { props?: Partial, ) => { const completeProps: ITokenMemberListItemProps = { - member: generateTokenMember(), + member: generateTokenVotingMember(), daoId: 'test-dao-id', plugin: generateDaoPlugin({ settings: generateTokenPluginSettings(), @@ -64,7 +64,7 @@ describe(' component', () => { it('renders the token member', () => { const ensName = 'tttt.eth'; - const member = generateTokenMember({ + const member = generateTokenVotingMember({ ens: 'tttt.eth', address: '0x123', }); @@ -77,7 +77,7 @@ describe(' component', () => { }); it('shows the shortened aragon name when the member has an aragon subdomain', () => { - const member = generateTokenMember({ address: '0x123' }); + const member = generateTokenVotingMember({ address: '0x123' }); useEnsNameSpy.mockImplementation( (_address, options) => ({ @@ -95,7 +95,9 @@ describe(' component', () => { it('retrieves the plugin settings to parse the member voting power using the decimals of the governance token', () => { const token = generateTokenPluginSettingsToken({ decimals: 6 }); const pluginSettings = generateTokenPluginSettings({ token }); - const member = generateTokenMember({ votingPower: '47928374987234' }); + const member = generateTokenVotingMember({ + votingPower: '47928374987234', + }); const plugin = generateDaoPlugin({ settings: pluginSettings }); const daoAddress = '0x123'; const daoNetwork = Network.ETHEREUM_SEPOLIA; diff --git a/apps/app/src/plugins/tokenPlugin/components/tokenMemberList/components/tokenMemberListItem.tsx b/apps/app/src/plugins/tokenPlugin/components/tokenMemberList/components/tokenMemberListItem.tsx index e3d10311e0..02b50e2b55 100644 --- a/apps/app/src/plugins/tokenPlugin/components/tokenMemberList/components/tokenMemberListItem.tsx +++ b/apps/app/src/plugins/tokenPlugin/components/tokenMemberList/components/tokenMemberListItem.tsx @@ -1,7 +1,7 @@ +import type { TokenVotingMemberDTO } from '@aragon/aragon-domain'; import { MemberDataListItem } from '@aragon/gov-ui-kit'; import { formatUnits } from 'viem'; import { useEnsAvatar, useEnsName } from '@/modules/ens'; -import type { ITokenMember } from '@/plugins/tokenPlugin/types'; import { type IDaoPlugin, useDao } from '@/shared/api/daoService'; import { bigIntUtils } from '@/shared/utils/bigIntUtils'; import { daoUtils } from '@/shared/utils/daoUtils'; @@ -11,7 +11,7 @@ export interface ITokenMemberListItemProps { /** * Member to display the information for. */ - member: ITokenMember; + member: TokenVotingMemberDTO; /** * ID of the DAO the user is member of. */ @@ -48,7 +48,7 @@ export const TokenMemberListItem: React.FC = ( address={member.address} avatarSrc={ensAvatar ?? undefined} className="min-w-0" - delegationCount={member.metrics.delegationCount} + delegationCount={member.delegationCount} ensName={displayName ?? undefined} href={daoUtils.getDaoUrl(dao, `members/${member.address}`)} isDelegate={isDelegate} diff --git a/apps/app/src/plugins/tokenPlugin/components/tokenMemberList/tokenMemberListBase.test.tsx b/apps/app/src/plugins/tokenPlugin/components/tokenMemberList/tokenMemberListBase.test.tsx index 31cb7900ea..7d7904f63b 100644 --- a/apps/app/src/plugins/tokenPlugin/components/tokenMemberList/tokenMemberListBase.test.tsx +++ b/apps/app/src/plugins/tokenPlugin/components/tokenMemberList/tokenMemberListBase.test.tsx @@ -2,7 +2,7 @@ import { GukModulesProvider } from '@aragon/gov-ui-kit'; import { render, screen } from '@testing-library/react'; import * as wagmi from 'wagmi'; import * as governanceService from '@/modules/governance/api/governanceService'; -import * as useMemberListData from '@/modules/governance/hooks/useMemberListData'; +import * as useTokenVotingMembershipDataModule from '@/modules/governance/hooks/useTokenVotingMembershipData'; import * as daoService from '@/shared/api/daoService'; import { generateDao, @@ -14,6 +14,7 @@ import * as useTokenCurrentDelegateModule from '../../hooks/useTokenCurrentDeleg import { generateTokenMember, generateTokenPluginSettings, + generateTokenVotingMember, } from '../../testUtils'; import type { ITokenMember } from '../../types'; import { @@ -33,9 +34,9 @@ jest.mock('./components/tokenMemberListItem', () => ({ })); describe('', () => { - const useMemberListDataSpy = jest.spyOn( - useMemberListData, - 'useMemberListData', + const useTokenVotingMembershipDataSpy = jest.spyOn( + useTokenVotingMembershipDataModule, + 'useTokenVotingMembershipData', ); const useDaoSpy = jest.spyOn(daoService, 'useDao'); const resolvePluginDaoIdSpy = jest.spyOn(daoUtils, 'resolvePluginDaoId'); @@ -47,7 +48,7 @@ describe('', () => { const useMemberSpy = jest.spyOn(governanceService, 'useMember'); beforeEach(() => { - useMemberListDataSpy.mockReturnValue({ + useTokenVotingMembershipDataSpy.mockReturnValue({ memberList: undefined, onLoadMore: jest.fn(), state: 'idle', @@ -75,7 +76,7 @@ describe('', () => { }); afterEach(() => { - useMemberListDataSpy.mockReset(); + useTokenVotingMembershipDataSpy.mockReset(); useDaoSpy.mockReset(); resolvePluginDaoIdSpy.mockReset(); useConnectionSpy.mockReset(); @@ -105,10 +106,10 @@ describe('', () => { it('renders the member list', () => { const members = [ - generateTokenMember({ address: '0x123' }), - generateTokenMember({ address: '0x456' }), + generateTokenVotingMember({ address: '0x123' }), + generateTokenVotingMember({ address: '0x456' }), ]; - useMemberListDataSpy.mockReturnValue({ + useTokenVotingMembershipDataSpy.mockReturnValue({ memberList: members, onLoadMore: jest.fn(), state: 'idle', @@ -125,8 +126,8 @@ describe('', () => { }); it('does not render the data-list pagination when hidePagination is set to true', () => { - useMemberListDataSpy.mockReturnValue({ - memberList: [generateTokenMember()], + useTokenVotingMembershipDataSpy.mockReturnValue({ + memberList: [generateTokenVotingMember()], onLoadMore: jest.fn(), state: 'idle', pageSize: 10, @@ -153,11 +154,11 @@ describe('', () => { it('pins the connected user to the top when they have voting power', () => { const userAddress = '0x1234567890abcdef1234567890abcdef12345678'; const paginatedMembers = [ - generateTokenMember({ + generateTokenVotingMember({ address: '0x9999999999999999999999999999999999999999', votingPower: '5000', }), - generateTokenMember({ + generateTokenVotingMember({ address: '0x8888888888888888888888888888888888888888', votingPower: '4000', }), @@ -171,7 +172,7 @@ describe('', () => { address: userAddress, } as unknown as wagmi.UseConnectionReturnType); - useMemberListDataSpy.mockReturnValue({ + useTokenVotingMembershipDataSpy.mockReturnValue({ memberList: paginatedMembers, onLoadMore: jest.fn(), state: 'idle', @@ -203,11 +204,11 @@ describe('', () => { const userAddress = '0x1234567890abcdef1234567890abcdef12345678'; const delegateAddr = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; const paginatedMembers = [ - generateTokenMember({ + generateTokenVotingMember({ address: '0x9999999999999999999999999999999999999999', votingPower: '5000', }), - generateTokenMember({ + generateTokenVotingMember({ address: '0x8888888888888888888888888888888888888888', votingPower: '4000', }), @@ -231,7 +232,7 @@ describe('', () => { isError: false, }); - useMemberListDataSpy.mockReturnValue({ + useTokenVotingMembershipDataSpy.mockReturnValue({ memberList: paginatedMembers, onLoadMore: jest.fn(), state: 'idle', @@ -270,11 +271,11 @@ describe('', () => { const userAddress = '0x1234567890abcdef1234567890abcdef12345678'; const delegateAddr = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; const paginatedMembers = [ - generateTokenMember({ + generateTokenVotingMember({ address: '0x9999999999999999999999999999999999999999', votingPower: '5000', }), - generateTokenMember({ + generateTokenVotingMember({ address: '0x8888888888888888888888888888888888888888', votingPower: '4000', }), @@ -298,7 +299,7 @@ describe('', () => { isError: false, }); - useMemberListDataSpy.mockReturnValue({ + useTokenVotingMembershipDataSpy.mockReturnValue({ memberList: paginatedMembers, onLoadMore: jest.fn(), state: 'idle', @@ -339,11 +340,11 @@ describe('', () => { // a real member, and the "X of Y" counter must reflect what's rendered. const userAddress = '0x1234567890abcdef1234567890abcdef12345678'; const paginatedMembers = [ - generateTokenMember({ + generateTokenVotingMember({ address: '0x9999999999999999999999999999999999999999', votingPower: '5000', }), - generateTokenMember({ + generateTokenVotingMember({ address: '0x8888888888888888888888888888888888888888', votingPower: '4000', }), @@ -357,7 +358,7 @@ describe('', () => { address: userAddress, } as unknown as wagmi.UseConnectionReturnType); - useMemberListDataSpy.mockReturnValue({ + useTokenVotingMembershipDataSpy.mockReturnValue({ memberList: paginatedMembers, onLoadMore: jest.fn(), state: 'idle', @@ -391,20 +392,29 @@ describe('', () => { }); describe('linked-account daoId resolution', () => { - it('passes the original params to useMemberListData for non-linked-account plugins', () => { + it('augments params with domain routing fields for non-linked-account plugins', () => { const initialParams = { queryParams: { daoId: 'dao-id', pluginAddress: '0x123' }, }; resolvePluginDaoIdSpy.mockReturnValue('dao-id'); render(createTestComponent({ initialParams })); - expect(useMemberListDataSpy).toHaveBeenCalledWith(initialParams); + expect(useTokenVotingMembershipDataSpy).toHaveBeenCalledWith( + expect.objectContaining({ + queryParams: expect.objectContaining({ + daoId: 'dao-id', + pluginAddress: '0x123', + pluginInterfaceType: expect.any(String), + tokenAddress: expect.any(String), + }), + }), + ); }); - it('passes the resolved daoId to useMemberListData for linked-account plugins', () => { + it('passes the resolved daoId to useTokenVotingMembershipData for linked-account plugins', () => { const resolvedDaoId = 'eth-mainnet-0xlinked'; resolvePluginDaoIdSpy.mockReturnValue(resolvedDaoId); render(createTestComponent()); - expect(useMemberListDataSpy).toHaveBeenCalledWith( + expect(useTokenVotingMembershipDataSpy).toHaveBeenCalledWith( expect.objectContaining({ queryParams: expect.objectContaining({ daoId: resolvedDaoId, diff --git a/apps/app/src/plugins/tokenPlugin/components/tokenMemberList/tokenMemberListBase.tsx b/apps/app/src/plugins/tokenPlugin/components/tokenMemberList/tokenMemberListBase.tsx index 7aa013ce3c..58d079b660 100644 --- a/apps/app/src/plugins/tokenPlugin/components/tokenMemberList/tokenMemberListBase.tsx +++ b/apps/app/src/plugins/tokenPlugin/components/tokenMemberList/tokenMemberListBase.tsx @@ -1,5 +1,6 @@ 'use client'; +import type { TokenVotingMemberDTO } from '@aragon/aragon-domain'; import { addressUtils, DataListContainer, @@ -9,12 +10,15 @@ import { } from '@aragon/gov-ui-kit'; import { type ReactNode, useMemo } from 'react'; import type { IToken } from '@/modules/finance/api/financeService'; +import { + buildTokenVotingMembershipParams, + mapBackendMemberToTokenVotingDTO, +} from '@/modules/governance/api/governanceService'; import type { IDaoMemberListDefaultProps } from '@/modules/governance/components/daoMemberList'; -import { useMemberListData } from '@/modules/governance/hooks/useMemberListData'; +import { useTokenVotingMembershipData } from '@/modules/governance/hooks/useTokenVotingMembershipData'; import type { IPluginSettings } from '@/shared/api/daoService'; import { useDao } from '@/shared/api/daoService'; import { useTranslations } from '@/shared/components/translationsProvider'; -import { daoUtils } from '@/shared/utils/daoUtils'; import { useTokenPinnedMembers } from '../../hooks/useTokenPinnedMembers'; import type { ITokenMember } from '../../types'; import { TokenMemberListItem } from './components/tokenMemberListItem'; @@ -64,20 +68,12 @@ export const TokenMemberListBase: React.FC = ( // The parent DAO is server-side prefetched → always a cache hit. const { data: dao } = useDao({ urlParams: { id: daoId } }); - // For linked account plugins the API call must target the linked account's own daoId so the - // backend queries the correct DAO. - const apiParams = useMemo(() => { - const resolvedDaoId = daoUtils.resolvePluginDaoId(daoId, plugin, dao); - - if (resolvedDaoId === daoId) { - return initialParams; - } - - return { - ...initialParams, - queryParams: { ...initialParams.queryParams, daoId: resolvedDaoId }, - }; - }, [initialParams, plugin, dao, daoId]); + // Shared with the members-page RSC prefetch. Both sides must build + // identical params so the dehydrated cache resolves this query. + const apiParams = useMemo( + () => buildTokenVotingMembershipParams(initialParams, plugin, dao), + [initialParams, plugin, dao], + ); const { onLoadMore, @@ -87,7 +83,7 @@ export const TokenMemberListBase: React.FC = ( errorState, emptyState, memberList, - } = useMemberListData(apiParams); + } = useTokenVotingMembershipData(apiParams); const { connectedUserMember, delegateMember, hasValidDelegate } = useTokenPinnedMembers({ @@ -103,7 +99,10 @@ export const TokenMemberListBase: React.FC = ( } const pinnedAddresses = new Set(); - const merged: ITokenMember[] = []; + // Pinned single-members arrive as legacy `ITokenMember` and are + // normalized through the same anti-corruption mapper as the legacy + // list branch. + const merged: TokenVotingMemberDTO[] = []; const appendPinnedMember = (member?: ITokenMember) => { if (member?.address == null) { @@ -116,7 +115,7 @@ export const TokenMemberListBase: React.FC = ( } pinnedAddresses.add(memberAddress); - merged.push(member); + merged.push(mapBackendMemberToTokenVotingDTO(member)); }; appendPinnedMember(connectedUserMember); diff --git a/apps/app/src/plugins/tokenPlugin/components/tokenProcessBodyField/tokenProcessBodyField.tsx b/apps/app/src/plugins/tokenPlugin/components/tokenProcessBodyField/tokenProcessBodyField.tsx index 3ebd699774..03b8dd7eb8 100644 --- a/apps/app/src/plugins/tokenPlugin/components/tokenProcessBodyField/tokenProcessBodyField.tsx +++ b/apps/app/src/plugins/tokenPlugin/components/tokenProcessBodyField/tokenProcessBodyField.tsx @@ -15,8 +15,8 @@ import type { ISetupBodyFormNew, } from '@/modules/createDao/dialogs/setupBodyDialog'; import { BodyType } from '@/modules/createDao/types/enum'; -import { useMemberList } from '@/modules/governance/api/governanceService'; -import { useDao } from '@/shared/api/daoService'; +import { useTokenVotingMembershipData } from '@/modules/governance/hooks/useTokenVotingMembershipData'; +import { PluginInterfaceType, useDao } from '@/shared/api/daoService'; import { useTranslations } from '@/shared/components/translationsProvider'; import { useDaoChain } from '@/shared/hooks/useDaoChain'; import { useDaoPluginInfo } from '@/shared/hooks/useDaoPluginInfo'; @@ -67,11 +67,21 @@ export const TokenProcessBodyField = (props: ITokenProcessBodyFieldProps) => { const { membership, governance } = body; const initialParams = { - queryParams: { daoId, pluginAddress: isExisting ? body.address : '' }, + queryParams: { + daoId, + pluginAddress: isExisting ? body.address : '', + network: dao?.network, + pluginInterfaceType: PluginInterfaceType.TOKEN_VOTING, + tokenAddress: membership.token.address, + tokenUnderlying: + (membership.token as { underlying?: string | null }) + .underlying ?? null, + }, }; - const { data: memberList } = useMemberList(initialParams, { - enabled: isExisting, - }); + const { itemsCount: memberCount } = useTokenVotingMembershipData( + initialParams, + { enabled: isExisting }, + ); const { address: tokenAddress, @@ -114,7 +124,7 @@ export const TokenProcessBodyField = (props: ITokenProcessBodyFieldProps) => { ); const numberOfMembers = isExisting - ? memberList?.pages[0].metadata.totalRecords + ? memberCount : membership.members.length; const { buildEntityUrl } = useDaoChain({ network: dao?.network }); diff --git a/apps/app/src/plugins/tokenPlugin/hooks/useFeaturedDelegates/useFeaturedDelegates.ts b/apps/app/src/plugins/tokenPlugin/hooks/useFeaturedDelegates/useFeaturedDelegates.ts index 424272536e..5f2d3111d6 100644 --- a/apps/app/src/plugins/tokenPlugin/hooks/useFeaturedDelegates/useFeaturedDelegates.ts +++ b/apps/app/src/plugins/tokenPlugin/hooks/useFeaturedDelegates/useFeaturedDelegates.ts @@ -1,7 +1,11 @@ +import type { TokenVotingMemberDTO } from '@aragon/aragon-domain'; import { useQueries } from '@tanstack/react-query'; import { useMemo } from 'react'; import type { IToken } from '@/modules/finance/api/financeService'; -import { memberOptions } from '@/modules/governance/api/governanceService'; +import { + mapBackendMemberToTokenVotingDTO, + memberOptions, +} from '@/modules/governance/api/governanceService'; import type { ITokenMember } from '../../types'; export interface IUseFeaturedDelegatesParams { @@ -49,27 +53,22 @@ export const useFeaturedDelegates = (params: IUseFeaturedDelegatesParams) => { return useMemo( () => - addresses.map((address, index) => { + addresses.map((address, index): TokenVotingMemberDTO => { const member = results[index]?.data; if (member != null) { - return member; + return mapBackendMemberToTokenVotingDTO(member); } - // Fallback: non-member delegate — return minimal token member shape + // Fallback: non-member delegate — return minimal member shape return { address, ens: null, - type: 'token-voting' as const, votingPower: null, - firstActive: null, - lastActive: null, - metrics: { - delegationCount: 0, - firstActivity: null, - lastActivity: null, - }, - } satisfies ITokenMember; + delegationCount: 0, + firstActivityTimestamp: null, + lastActivityTimestamp: null, + }; }), [addresses, results], ); diff --git a/apps/app/src/plugins/tokenPlugin/testUtils/generators/index.ts b/apps/app/src/plugins/tokenPlugin/testUtils/generators/index.ts index 6c94f71677..40b35d2d2d 100644 --- a/apps/app/src/plugins/tokenPlugin/testUtils/generators/index.ts +++ b/apps/app/src/plugins/tokenPlugin/testUtils/generators/index.ts @@ -4,3 +4,4 @@ export * from './tokenPluginSettings'; export * from './tokenPluginSettingsToken'; export * from './tokenProposal'; export * from './tokenVote'; +export * from './tokenVotingMember'; diff --git a/apps/app/src/plugins/tokenPlugin/testUtils/generators/tokenVotingMember.ts b/apps/app/src/plugins/tokenPlugin/testUtils/generators/tokenVotingMember.ts new file mode 100644 index 0000000000..179bc20d65 --- /dev/null +++ b/apps/app/src/plugins/tokenPlugin/testUtils/generators/tokenVotingMember.ts @@ -0,0 +1,13 @@ +import type { TokenVotingMemberDTO } from '@aragon/aragon-domain'; + +export const generateTokenVotingMember = ( + member?: Partial, +): TokenVotingMemberDTO => ({ + address: '0x0000000000000000000000000000000000000000', + ens: null, + votingPower: '0', + firstActivityTimestamp: null, + lastActivityTimestamp: null, + delegationCount: 0, + ...member, +}); diff --git a/apps/app/src/shared/api/aragonDomainService/aragonDomainService.backend.ts b/apps/app/src/shared/api/aragonDomainService/aragonDomainService.backend.ts index aa35f49698..c742eadb9f 100644 --- a/apps/app/src/shared/api/aragonDomainService/aragonDomainService.backend.ts +++ b/apps/app/src/shared/api/aragonDomainService/aragonDomainService.backend.ts @@ -1,25 +1,29 @@ import 'server-only'; -import { AragonDomain, EnvioClient } from '@aragon/aragon-domain'; +import { AragonDomain, EnvioClient, type RpcUrls } from '@aragon/aragon-domain'; +import { resolveServerRpcUrl } from '@/modules/application/utils/proxyRpcUtils/resolveServerRpcUrl'; +import { Network } from '@/shared/api/daoService'; +import { networkDefinitions } from '@/shared/constants/networkDefinitions'; -type AragonDomainController = ReturnType; +export type AragonDomainController = ReturnType; /** - * Server-side singleton wrapping the Envio-backed Aragon domain controller. + * Networks the domain controller can read on-chain data from. Expand as more + * networks are indexed by aragon-indexer. + */ +const domainRpcNetworks = [Network.ETHEREUM_MAINNET] as const; + +/** + * Server-side singleton wrapping aragon-domain. * - * The controller is created lazily on first use (not at import time) so a + * The domain is created lazily on first use (not at import time) so a * missing endpoint surfaces inside the API route's `try/catch` as a clean 500 * rather than an opaque import-time crash. `apiToken` is optional on `EnvioClient`. - * - * Only the methods the app consumes are exposed, keeping the surface explicit. */ class AragonDomainServiceBackend { - private controller: AragonDomainController | undefined; + private domain: AragonDomainController | undefined; - getMemberProfileTextRecords: AragonDomainController['getMemberProfileTextRecords'] = - (dto) => this.getController().getMemberProfileTextRecords(dto); - - private getController = (): AragonDomainController => { - if (this.controller == null) { + getDomain = (): AragonDomainController => { + if (this.domain == null) { const endpoint = process.env.NEXT_SECRET_ENVIO_GRAPHQL_ENDPOINT; const apiToken = process.env.NEXT_SECRET_ENVIO_API_TOKEN; @@ -27,12 +31,20 @@ class AragonDomainServiceBackend { throw new Error('Envio endpoint is not set'); } - this.controller = AragonDomain.load( + const rpcUrls: RpcUrls = Object.fromEntries( + domainRpcNetworks.map((network) => [ + networkDefinitions[network].id, + resolveServerRpcUrl(network), + ]), + ); + + this.domain = AragonDomain.load( new EnvioClient(endpoint, apiToken), + rpcUrls, ); } - return this.controller; + return this.domain; }; } diff --git a/apps/app/src/shared/utils/daoUtils/daoUtils.ts b/apps/app/src/shared/utils/daoUtils/daoUtils.ts index a7387634bb..c39ce95c6c 100644 --- a/apps/app/src/shared/utils/daoUtils/daoUtils.ts +++ b/apps/app/src/shared/utils/daoUtils/daoUtils.ts @@ -1,4 +1,6 @@ import { addressUtils } from '@aragon/gov-ui-kit'; +// biome-ignore lint/style/noRestrictedImports: server-safe address check — the gov-ui-kit shim is not callable from RSC code paths; { strict: false } is passed explicitly. +import { isAddress } from 'viem'; import { daoService, type IDao, @@ -218,6 +220,7 @@ class DaoUtils { /** * Checks whether a plugin belongs to a linked account relative to the given DAO context. + * Server safe. */ isLinkedAccountPlugin = ( plugin: Pick, @@ -225,7 +228,15 @@ class DaoUtils { ): boolean => plugin.daoAddress != null && dao != null && - !addressUtils.isAddressEqual(plugin.daoAddress, dao.address); + !this.isSameAddress(plugin.daoAddress, dao.address); + + /** + * Server-safe, case-insensitive address equality. + */ + private isSameAddress = (addressOne: string, addressTwo: string): boolean => + isAddress(addressOne, { strict: false }) && + isAddress(addressTwo, { strict: false }) && + addressOne.toLowerCase() === addressTwo.toLowerCase(); /** * Returns the `daoId` that should be used for API calls targeting this plugin. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7370dc77e0..215586d48d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -227,8 +227,8 @@ importers: apps/app: dependencies: '@aragon/aragon-domain': - specifier: ^0.3.1 - version: 0.3.1(typescript@5.9.3) + specifier: 0.0.0-20260806160246 + version: 0.0.0-20260806160246(typescript@5.9.3) '@aragon/assistant-chat': specifier: workspace:* version: link:../../packages/assistant-chat @@ -711,8 +711,8 @@ packages: '@apm-js-collab/tracing-hooks@0.10.1': resolution: {integrity: sha512-w2OWXR7FWrKqSziuE9+QclaZrStxO/8+OwbXM635s/zs0Eez1Qo3ivSPdB2WsaPY/iznKTytONPx/PitD7IXcA==} - '@aragon/aragon-domain@0.3.1': - resolution: {integrity: sha512-KV30X9uSM0vcX1EsbrKoE0DMxe0NsqTlW+2YoOJND2iKHgjnX8SnlZtO5f5HS6MIQkb/ihHUoRe/9Jjnin9uFA==} + '@aragon/aragon-domain@0.0.0-20260806160246': + resolution: {integrity: sha512-t2WJj5c5CEjjy/+JMWeKvHCTL1Y3visYkHGeqPHt4sjWOGU42x8cV2JpRqCguW9GAwVg6swTSw7mmwpywNSUwQ==} engines: {node: '>=24.13.0', pnpm: '>=11.0.0'} '@aragon/gov-ui-kit@2.9.0': @@ -9489,7 +9489,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@aragon/aragon-domain@0.3.1(typescript@5.9.3)': + '@aragon/aragon-domain@0.0.0-20260806160246(typescript@5.9.3)': dependencies: bignumber.js: 11.1.4 ddd-core-ts: 0.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c2f0ca1573..8d23ab4823 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -67,8 +67,8 @@ catalog: minimumReleaseAge: 10080 # cooldown period for new packages in minutes (7 days) minimumReleaseAgeExclude: # https://pnpm.io/settings#minimumreleaseageexclude - - "@aragon/gov-ui-kit" - "@aragon/aragon-domain" + - "@aragon/gov-ui-kit" # Deliberately pinned assistant-ui release train (see the exact pins in the catalog above); the # widget needs the just-published versions that carry the ai@7 support, so these exact versions # are exempt from the new-package cooldown.