From 70e0713c7563f007b55b08b182c36e8783e19495 Mon Sep 17 00:00:00 2001 From: Andrew Date: Tue, 1 Sep 2026 14:01:19 +0200 Subject: [PATCH 1/7] fix: Enrich SPP sub-proposals routed to lock-to-vote by their stage body SPP evaluates a body through the stage plugin's interface type while the totalSupply enrichment keyed off the sub-proposal's own interface type, so a sub-proposal with inconsistent backend data reached the lock-to-vote status utils without the enrichment and crashed the proposals page. Enrich by stage body address as well and make the supply lookup tolerant to missing data. Fixes APP-NEXT-2ES --- .agents/shared/metrics/hits.jsonl | 36 +++++------- .../governanceService.test.ts | 58 +++++++++++++++++++ .../governanceService/governanceService.ts | 15 ++++- .../types/lockToVoteProposal.ts | 6 +- .../lockToVoteProposalUtils.test.ts | 26 ++++++++- .../lockToVoteProposalUtils.ts | 15 +++-- 6 files changed, 127 insertions(+), 29 deletions(-) diff --git a/.agents/shared/metrics/hits.jsonl b/.agents/shared/metrics/hits.jsonl index ea9e5b0532..19fee97598 100644 --- a/.agents/shared/metrics/hits.jsonl +++ b/.agents/shared/metrics/hits.jsonl @@ -24,6 +24,7 @@ {"ts":"2026-06-08T11:24:30.263Z","tool":"Edit","file":"src/modules/governance/api/actionSimulationService/actionSimulationService.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-06-08T11:27:15.648Z","tool":"Edit","file":"src/modules/governance/api/actionSimulationService/mutations/useSimulateActions/useSimulateActions.test.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} {"ts":"2026-06-08T12:55:15.492Z","tool":"Edit","file":"src/modules/governance/api/actionSimulationService/actionSimulationService.api.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-06-09T11:03:40.976Z","tool":"Write","file":"src/shared/api/daoService/domain/enum/network.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} {"ts":"2026-06-09T11:56:43.815Z","tool":"Edit","file":"src/modules/governance/api/actionSimulationService/actionSimulationService.api.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-06-09T11:57:24.436Z","tool":"Edit","file":"src/modules/governance/api/actionSimulationService/actionSimulationService.api.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-06-09T11:57:39.250Z","tool":"Edit","file":"src/modules/governance/api/actionSimulationService/actionSimulationService.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":2,"adapter":"claude"} @@ -31,7 +32,6 @@ {"ts":"2026-06-10T10:36:11.062Z","tool":"Edit","file":"src/modules/finance/dialogs/assetSelectionDialog/index.ts","rule":"dialog-conventions","bytes":3499,"elapsed_ms":2,"adapter":"generic"} {"ts":"2026-06-10T10:36:11.106Z","tool":"Edit","file":"src/modules/finance/constants/financeDialogsDefinitions.ts","rule":"dialog-conventions","bytes":3499,"elapsed_ms":1,"adapter":"generic"} {"ts":"2026-06-10T10:36:11.149Z","tool":"Edit","file":"src/plugins/tokenPlugin/index.ts","rule":"plugin-slot-registration","bytes":3795,"elapsed_ms":1,"adapter":"generic"} -{"ts":"2026-06-09T11:03:40.976Z","tool":"Write","file":"src/shared/api/daoService/domain/enum/network.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} {"ts":"2026-06-23T12:49:35.098Z","tool":"Edit","file":"src/shared/api/aragonBackendService/aragonBackendServiceError.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":0,"adapter":"claude"} {"ts":"2026-06-23T12:49:49.841Z","tool":"Edit","file":"src/shared/api/aragonBackendService/aragonBackendServiceError.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} {"ts":"2026-06-23T12:50:57.800Z","tool":"Edit","file":"src/shared/api/aragonBackendService/aragonBackendServiceError.test.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} @@ -106,10 +106,6 @@ {"ts":"2026-07-07T08:25:58.831Z","tool":"Edit","file":"src/shared/components/transactionDialog/transactionDialog.tsx","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-07-07T08:26:08.144Z","tool":"Edit","file":"src/shared/components/transactionDialog/transactionDialog.tsx","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":1,"adapter":"claude"} {"ts":"2026-07-07T08:27:04.958Z","tool":"Edit","file":"src/shared/components/transactionDialog/transactionDialog.test.tsx","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":1,"adapter":"claude"} -{"ts":"2026-07-08T11:05:20.251Z","tool":"Edit","file":"src/modules/application/components/errorBoundary/errorBoundaryClass.tsx","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":29,"adapter":"claude"} -{"ts":"2026-07-08T11:05:45.029Z","tool":"Edit","file":"src/modules/application/components/errorBoundary/errorBoundaryClass.tsx","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":6,"adapter":"claude"} -{"ts":"2026-07-08T11:06:24.791Z","tool":"Edit","file":"src/shared/utils/monitoringUtils/monitoringUtils.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":3,"adapter":"claude"} -{"ts":"2026-07-08T11:06:45.503Z","tool":"Edit","file":"src/shared/utils/monitoringUtils/monitoringUtils.test.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":4,"adapter":"claude"} {"ts":"2026-07-07T13:45:43.994Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-07-07T15:31:34.162Z","tool":"Write","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-07-07T15:31:49.364Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} @@ -118,29 +114,20 @@ {"ts":"2026-07-07T15:38:04.582Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-07-07T15:38:11.715Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":1,"adapter":"claude"} {"ts":"2026-07-07T15:39:53.889Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":3,"adapter":"claude"} +{"ts":"2026-07-08T11:05:20.251Z","tool":"Edit","file":"src/modules/application/components/errorBoundary/errorBoundaryClass.tsx","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":29,"adapter":"claude"} +{"ts":"2026-07-08T11:05:45.029Z","tool":"Edit","file":"src/modules/application/components/errorBoundary/errorBoundaryClass.tsx","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":6,"adapter":"claude"} +{"ts":"2026-07-08T11:06:24.791Z","tool":"Edit","file":"src/shared/utils/monitoringUtils/monitoringUtils.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":3,"adapter":"claude"} +{"ts":"2026-07-08T11:06:45.503Z","tool":"Edit","file":"src/shared/utils/monitoringUtils/monitoringUtils.test.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":4,"adapter":"claude"} {"ts":"2026-07-08T16:28:10.671Z","tool":"Write","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogProcessListItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-07-08T16:29:33.169Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-07-08T16:29:42.858Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-07-08T16:29:55.658Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":11,"adapter":"claude"} {"ts":"2026-07-08T16:30:49.523Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogProcessListItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} -{"ts":"2026-07-14T13:48:20.570Z","tool":"Edit","file":".agents/shared/skills/rules/dialog-conventions.md","rule":"rule-authoring","bytes":2672,"elapsed_ms":1,"adapter":"claude"} -{"ts":"2026-07-14T13:49:10.198Z","tool":"Edit","file":".agents/shared/skills/rules/dialog-conventions.md","rule":"rule-authoring","bytes":2672,"elapsed_ms":4,"adapter":"claude"} {"ts":"2026-07-09T11:51:56.300Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogProcessListItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-07-09T12:00:44.020Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogProcessListItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-07-13T10:09:35.950Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogProcessListItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":3,"adapter":"claude"} -{"ts":"2026-07-07T13:45:43.994Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} -{"ts":"2026-07-07T15:31:34.162Z","tool":"Write","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} -{"ts":"2026-07-07T15:31:49.364Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} -{"ts":"2026-07-07T15:31:51.306Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} -{"ts":"2026-07-07T15:31:55.733Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} -{"ts":"2026-07-07T15:38:04.582Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} -{"ts":"2026-07-07T15:38:11.715Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":1,"adapter":"claude"} -{"ts":"2026-07-07T15:39:53.889Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":3,"adapter":"claude"} -{"ts":"2026-07-08T16:28:10.671Z","tool":"Write","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogProcessListItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":3,"adapter":"claude"} -{"ts":"2026-07-08T16:29:33.169Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} -{"ts":"2026-07-08T16:29:42.858Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":3,"adapter":"claude"} -{"ts":"2026-07-08T16:29:55.658Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":11,"adapter":"claude"} -{"ts":"2026-07-08T16:30:49.523Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogProcessListItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-07-14T13:48:20.570Z","tool":"Edit","file":".agents/shared/skills/rules/dialog-conventions.md","rule":"rule-authoring","bytes":2672,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-07-14T13:49:10.198Z","tool":"Edit","file":".agents/shared/skills/rules/dialog-conventions.md","rule":"rule-authoring","bytes":2672,"elapsed_ms":4,"adapter":"claude"} {"ts":"2026-07-22T08:55:56.658Z","tool":"Edit","file":"apps/app/src/shared/api/daoService/queries/useAllDaoPermissions/useAllDaoPermissions.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":6,"adapter":"claude"} {"ts":"2026-07-24T17:37:00.673Z","tool":"Edit","file":"apps/app/src/shared/components/transactionDialog/transactionDialog.tsx","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-07-28T16:26:08.696Z","tool":"Edit","file":"apps/app/src/shared/components/transactionDialog/useManagedTransaction.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":2,"adapter":"claude"} @@ -154,9 +141,9 @@ {"ts":"2026-08-03T13:13:36.306Z","tool":"Edit","file":"apps/app/src/modules/governance/api/smartContractService/smartContractService.api.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-08-03T14:29:17.284Z","tool":"Edit","file":"apps/app/src/actions/core/createProposal/createProposalActionDetails.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-08-03T14:29:22.784Z","tool":"Edit","file":"apps/app/src/actions/core/execute/executeActionDetails.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":2,"adapter":"claude"} -{"ts":"2026-08-04T10:56:30.749Z","tool":"Edit","file":"apps/app/src/shared/api/daoService/domain/pluginSettings.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-08-04T07:16:05.722Z","tool":"Edit","file":"apps/app/src/modules/governance/api/executeSelectorsService/domain/allowedActionDecoded.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-08-04T07:16:07.165Z","tool":"Edit","file":"apps/app/src/modules/governance/api/executeSelectorsService/domain/allowedAction.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-08-04T10:56:30.749Z","tool":"Edit","file":"apps/app/src/shared/api/daoService/domain/pluginSettings.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-08-04T12:34:00.962Z","tool":"Edit","file":"apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-08-04T12:34:05.191Z","tool":"Edit","file":"apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} {"ts":"2026-08-04T12:34:06.974Z","tool":"Edit","file":"apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":7,"adapter":"claude"} @@ -211,3 +198,10 @@ {"ts":"2026-08-31T11:16:41.725Z","tool":"Edit","file":".agents/shared/skills/rules/plugin-visibility.md","rule":"rule-authoring","bytes":2672,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-08-31T11:16:50.965Z","tool":"Edit","file":".agents/shared/skills/rules/plugin-visibility.md","rule":"rule-authoring","bytes":2672,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-08-31T11:17:03.502Z","tool":"Edit","file":".agents/shared/skills/rules/plugin-visibility.md","rule":"rule-authoring","bytes":2672,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-09-01T11:56:07.219Z","tool":"Edit","file":"apps/app/src/modules/governance/api/governanceService/governanceService.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-01T11:57:14.375Z","tool":"Edit","file":"apps/app/src/plugins/lockToVotePlugin/utils/lockToVoteProposalUtils/lockToVoteProposalUtils.test.ts","rule":"test-scope-and-selectors","bytes":1727,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-09-01T11:57:32.160Z","tool":"Edit","file":"apps/app/src/plugins/lockToVotePlugin/utils/lockToVoteProposalUtils/lockToVoteProposalUtils.test.ts","rule":"test-scope-and-selectors","bytes":1727,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-09-01T11:57:48.971Z","tool":"Edit","file":"apps/app/src/modules/governance/api/governanceService/governanceService.test.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-09-01T11:57:48.971Z","tool":"Edit","file":"apps/app/src/modules/governance/api/governanceService/governanceService.test.ts","rule":"test-scope-and-selectors","bytes":1727,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-09-01T11:58:13.848Z","tool":"Edit","file":"apps/app/src/modules/governance/api/governanceService/governanceService.test.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-01T11:58:13.848Z","tool":"Edit","file":"apps/app/src/modules/governance/api/governanceService/governanceService.test.ts","rule":"test-scope-and-selectors","bytes":1727,"elapsed_ms":1,"adapter":"claude"} 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 a6e433ca47..bac047c330 100644 --- a/apps/app/src/modules/governance/api/governanceService/governanceService.test.ts +++ b/apps/app/src/modules/governance/api/governanceService/governanceService.test.ts @@ -237,6 +237,7 @@ describe('governance service', () => { const multisigSub = { ...generateMultisigProposal({ id: 'sub-multisig', + pluginAddress: '0x456', pluginInterfaceType: PluginInterfaceType.MULTISIG, }), stageIndex: 0, @@ -293,6 +294,63 @@ describe('governance service', () => { ).toBeUndefined(); }); + it('getProposalBySlug enriches a sub-proposal of a lock-to-vote stage body even when its own interface type disagrees', async () => { + const tokenAddress = '0xCcCc'; + const bodyAddress = '0xBodyLtv'; + // SPP dispatches by the stage plugin's interface type, so a sub-proposal whose own + // pluginInterfaceType is inconsistent must still receive the supply enrichment. + const mismatchedSub = { + ...generateProposal({ + id: 'sub-mismatched', + pluginAddress: bodyAddress, + pluginInterfaceType: PluginInterfaceType.MULTISIG, + }), + stageIndex: 0, + }; + const proposal = generateSppProposal({ + id: '004', + network: Network.ETHEREUM_MAINNET, + pluginInterfaceType: PluginInterfaceType.SPP, + settings: generateSppPluginSettings({ + stages: [ + generateSppStage({ + plugins: [ + generateLockToVoteStagePlugin({ + address: bodyAddress.toUpperCase(), + settings: generateLockToVotePluginSettings({ + token: generateLockToVotePluginSettingsToken( + { address: tokenAddress }, + ), + }), + }), + ], + }), + ], + }), + subProposals: [mismatchedSub], + }); + const proposalParams = { + urlParams: { slug: proposal.id }, + queryParams: { daoId: 'test-id' }, + }; + requestSpy.mockResolvedValue(proposal); + fetchTokensTotalSupplySpy.mockResolvedValue({ + [tokenAddress.toLowerCase()]: '5000', + }); + + const result = + await governanceService.getProposalBySlug( + proposalParams, + ); + + const [decoratedSub] = result.subProposals; + expect( + (decoratedSub as unknown as ILockToVoteProposal).tokensTotalSupply, + ).toEqual({ + [tokenAddress.toLowerCase()]: '5000', + }); + }); + it('getProposalBySlug leaves non-LTV non-SPP proposals untouched', async () => { const proposal = generateProposal({ id: '003', diff --git a/apps/app/src/modules/governance/api/governanceService/governanceService.ts b/apps/app/src/modules/governance/api/governanceService/governanceService.ts index bbc9c95513..981e09a7cd 100644 --- a/apps/app/src/modules/governance/api/governanceService/governanceService.ts +++ b/apps/app/src/modules/governance/api/governanceService/governanceService.ts @@ -159,10 +159,23 @@ class GovernanceService extends AragonBackendService { } if (sppProposalUtils.isSppProposal(proposal)) { + // SPP evaluates a body through the STAGE plugin's interface type (see + // sppStageUtils.isBodySucceeded), so a sub-proposal needs the supply enrichment + // when either itself or its stage body is lock-to-vote — the backend does not + // guarantee the two interface types agree. + const lockToVoteBodies = new Set( + proposal.settings.stages.flatMap((stage) => + stage.plugins + .filter(lockToVoteProposalUtils.isLockToVoteStagePlugin) + .map((plugin) => plugin.address.toLowerCase()), + ), + ); + return { ...proposal, subProposals: proposal.subProposals.map((sub) => - lockToVoteProposalUtils.isLockToVoteProposal(sub) + lockToVoteProposalUtils.isLockToVoteProposal(sub) || + lockToVoteBodies.has(sub.pluginAddress.toLowerCase()) ? { ...sub, tokensTotalSupply } : sub, ), diff --git a/apps/app/src/plugins/lockToVotePlugin/types/lockToVoteProposal.ts b/apps/app/src/plugins/lockToVotePlugin/types/lockToVoteProposal.ts index 2841bce4fc..cd0d8b325d 100644 --- a/apps/app/src/plugins/lockToVotePlugin/types/lockToVoteProposal.ts +++ b/apps/app/src/plugins/lockToVotePlugin/types/lockToVoteProposal.ts @@ -9,7 +9,9 @@ export interface ILockToVoteProposal extends Omit { /** * Live ERC-20 totalSupply values keyed by lowercased token address. Populated by the api * service layer (`governanceService.getProposalBySlug` / `getProposalList`). Values are - * stringified bigints for SSR-safe React Query hydration. + * stringified bigints for SSR-safe React Query hydration. Optional because SPP sub-proposals + * routed by the stage plugin's interface type may miss the enrichment when the backend data + * is inconsistent — consumers must safe-parse missing values. */ - tokensTotalSupply: Record; + tokensTotalSupply?: Record; } diff --git a/apps/app/src/plugins/lockToVotePlugin/utils/lockToVoteProposalUtils/lockToVoteProposalUtils.test.ts b/apps/app/src/plugins/lockToVotePlugin/utils/lockToVoteProposalUtils/lockToVoteProposalUtils.test.ts index a987a06534..b55d2b8236 100644 --- a/apps/app/src/plugins/lockToVotePlugin/utils/lockToVoteProposalUtils/lockToVoteProposalUtils.test.ts +++ b/apps/app/src/plugins/lockToVotePlugin/utils/lockToVoteProposalUtils/lockToVoteProposalUtils.test.ts @@ -11,7 +11,10 @@ import { } from '../../../tokenPlugin/types'; import { generateLockToVotePluginSettings } from '../../testUtils/generators/lockToVotePluginSettings'; import { generateLockToVoteProposal } from '../../testUtils/generators/lockToVoteProposal'; -import type { ILockToVoteProposal } from '../../types'; +import type { + ILockToVotePluginSettings, + ILockToVoteProposal, +} from '../../types'; import { lockToVoteProposalUtils } from './lockToVoteProposalUtils'; describe('lockToVoteProposalUtils', () => { @@ -533,6 +536,27 @@ describe('lockToVoteProposalUtils', () => { lockToVoteProposalUtils.getProposalTokenTotalSupply(proposal), ).toBeUndefined(); }); + + it('returns undefined when the enrichment is missing entirely', () => { + const proposal = generateLockToVoteProposal({ + tokensTotalSupply: undefined, + }); + expect( + lockToVoteProposalUtils.getProposalTokenTotalSupply(proposal), + ).toBeUndefined(); + }); + + it('returns undefined when the proposal carries foreign settings without a token', () => { + const proposal = generateLockToVoteProposal({ + settings: { + ...generateLockToVotePluginSettings(), + token: undefined, + } as unknown as ILockToVotePluginSettings, + }); + expect( + lockToVoteProposalUtils.getProposalTokenTotalSupply(proposal), + ).toBeUndefined(); + }); }); describe('isLockToVoteProposal', () => { diff --git a/apps/app/src/plugins/lockToVotePlugin/utils/lockToVoteProposalUtils/lockToVoteProposalUtils.ts b/apps/app/src/plugins/lockToVotePlugin/utils/lockToVoteProposalUtils/lockToVoteProposalUtils.ts index b78fc59b6f..2fcf3b3a30 100644 --- a/apps/app/src/plugins/lockToVotePlugin/utils/lockToVoteProposalUtils/lockToVoteProposalUtils.ts +++ b/apps/app/src/plugins/lockToVotePlugin/utils/lockToVoteProposalUtils/lockToVoteProposalUtils.ts @@ -152,10 +152,17 @@ class LockToVoteProposalUtils { IDaoPlugin => plugin.interfaceType === PluginInterfaceType.LOCK_TO_VOTE; - getProposalTokenTotalSupply = (proposal: ILockToVoteProposal) => { - const tokenAddress = proposal.settings.token.address.toLowerCase(); - - return proposal.tokensTotalSupply[tokenAddress]; + getProposalTokenTotalSupply = ( + proposal: ILockToVoteProposal, + ): string | undefined => { + // SPP routes sub-proposals here based on the STAGE plugin's interface type, so the + // proposal may carry inconsistent backend data (foreign settings or missing supply + // enrichment). Returns undefined in that case; callers safe-parse the value to 0. + const tokenAddress = proposal.settings.token?.address.toLowerCase(); + + return tokenAddress != null + ? proposal.tokensTotalSupply?.[tokenAddress] + : undefined; }; } From 81813ea24c197b2604f32912ddc7352bd0e1bd8b Mon Sep 17 00:00:00 2001 From: Andrew Date: Tue, 1 Sep 2026 14:11:31 +0200 Subject: [PATCH 2/7] fix: Render a not-found state on create routes for unknown plugin addresses The create proposal/process pages destructured the first useDaoPlugins result unconditionally, so a direct load with an unknown or uninstalled plugin address (or a not-yet-loaded DAO) crashed the page both server- and client-side. The pages now render the standard not-found error state and the permission guard skips its check until the plugin is resolved. Fixes APP-NEXT-2EX Fixes APP-NEXT-2EP --- apps/app/src/assets/locales/en.json | 14 ++++++ .../createProcessPageClient.test.tsx | 30 +++++++++++- .../createProcessPageClient.tsx | 49 +++++++++++++++---- .../useProposalPermissionCheckGuard.ts | 14 ++++-- .../createProposalPageClient.test.tsx | 15 ++++++ .../createProposalPageClient.tsx | 49 ++++++++++++++----- 6 files changed, 146 insertions(+), 25 deletions(-) diff --git a/apps/app/src/assets/locales/en.json b/apps/app/src/assets/locales/en.json index 377896a014..20f1b6c9f3 100644 --- a/apps/app/src/assets/locales/en.json +++ b/apps/app/src/assets/locales/en.json @@ -1268,6 +1268,13 @@ } }, "createProcessPage": { + "error": { + "action": "View settings", + "notFound": { + "description": "We couldn't find the governance process you're looking for.", + "title": "Process not found" + } + }, "finalStep": "Publish process", "steps": { "METADATA": { @@ -1976,6 +1983,13 @@ } }, "createProposalPage": { + "error": { + "action": "Explore proposals", + "notFound": { + "description": "We couldn't find the governance process you're looking for.", + "title": "Process not found" + } + }, "finalStep": "Publish proposal", "steps": { "ACTIONS": { diff --git a/apps/app/src/modules/createDao/pages/createProcessPage/createProcessPageClient.test.tsx b/apps/app/src/modules/createDao/pages/createProcessPage/createProcessPageClient.test.tsx index 3d0d13b10a..2aa4685742 100644 --- a/apps/app/src/modules/createDao/pages/createProcessPage/createProcessPageClient.test.tsx +++ b/apps/app/src/modules/createDao/pages/createProcessPage/createProcessPageClient.test.tsx @@ -1,8 +1,15 @@ import { render, screen, waitFor } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import * as proposalPermissionGuard from '@/modules/governance/hooks/useProposalPermissionCheckGuard'; +import * as daoService from '@/shared/api/daoService'; import * as DialogProvider from '@/shared/components/dialogProvider'; -import { generateDialogContext } from '@/shared/testUtils'; +import * as useDaoPlugins from '@/shared/hooks/useDaoPlugins'; +import { + generateDao, + generateDialogContext, + generateFilterComponentPlugin, + generateReactQueryResultSuccess, +} from '@/shared/testUtils'; import { plausibleAnalyticsUtils } from '@/shared/utils/plausibleAnalyticsUtils'; import { GovernanceType, @@ -82,17 +89,25 @@ describe(' component', () => { proposalPermissionGuard, 'useProposalPermissionCheckGuard', ); + const useDaoPluginsSpy = jest.spyOn(useDaoPlugins, 'useDaoPlugins'); + const useDaoSpy = jest.spyOn(daoService, 'useDao'); const trackAnalyticsSpy = jest.spyOn(plausibleAnalyticsUtils, 'track'); beforeEach(() => { useDialogContextSpy.mockReturnValue(generateDialogContext()); useProposalPermissionCheckGuardSpy.mockImplementation(() => undefined); + useDaoPluginsSpy.mockReturnValue([generateFilterComponentPlugin()]); + useDaoSpy.mockReturnValue( + generateReactQueryResultSuccess({ data: generateDao() }), + ); trackAnalyticsSpy.mockImplementation(() => undefined); }); afterEach(() => { useDialogContextSpy.mockReset(); useProposalPermissionCheckGuardSpy.mockReset(); + useDaoPluginsSpy.mockReset(); + useDaoSpy.mockReset(); trackAnalyticsSpy.mockReset(); }); @@ -131,4 +146,17 @@ describe(' component', () => { }, }); }); + + it('renders a not-found state instead of the wizard when the plugin address matches no DAO plugin', () => { + useDaoPluginsSpy.mockReturnValue([]); + + renderPage(); + + expect( + screen.getByText( + 'app.createDao.createProcessPage.error.notFound.title', + ), + ).toBeInTheDocument(); + expect(screen.queryByTestId('submit')).not.toBeInTheDocument(); + }); }); diff --git a/apps/app/src/modules/createDao/pages/createProcessPage/createProcessPageClient.tsx b/apps/app/src/modules/createDao/pages/createProcessPage/createProcessPageClient.tsx index 3a4f63b687..121e89845e 100644 --- a/apps/app/src/modules/createDao/pages/createProcessPage/createProcessPageClient.tsx +++ b/apps/app/src/modules/createDao/pages/createProcessPage/createProcessPageClient.tsx @@ -2,10 +2,15 @@ import { useMemo } from 'react'; import { useProposalPermissionCheckGuard } from '@/modules/governance/hooks/useProposalPermissionCheckGuard'; +import { AragonBackendServiceError } from '@/shared/api/aragonBackendService'; +import { useDao } from '@/shared/api/daoService'; import { useDialogContext } from '@/shared/components/dialogProvider'; import { Page } from '@/shared/components/page'; import { useTranslations } from '@/shared/components/translationsProvider'; import { WizardPage } from '@/shared/components/wizards/wizardPage'; +import { useDaoPlugins } from '@/shared/hooks/useDaoPlugins'; +import { daoUtils } from '@/shared/utils/daoUtils'; +import { errorUtils } from '@/shared/utils/errorUtils'; import { plausibleAnalyticsUtils } from '@/shared/utils/plausibleAnalyticsUtils'; import { GovernanceType, @@ -35,12 +40,47 @@ export const CreateProcessPageClient: React.FC< const { t } = useTranslations(); const { open } = useDialogContext(); + // Undefined when the DAO is not loaded or the plugin address is unknown (e.g. a stale + // link to an uninstalled process) — the page renders a not-found state in that case. + const plugin = useDaoPlugins({ + daoId, + pluginAddress, + includeLinkedAccounts: true, + })?.[0]?.meta; + + const { data: dao } = useDao({ urlParams: { id: daoId } }); + useProposalPermissionCheckGuard({ daoId, pluginAddress, redirectTab: 'settings', }); + const processedSteps = useMemo( + () => + createProcessWizardSteps.map(({ meta, ...step }) => ({ + ...step, + meta: { ...meta, name: t(meta.name) }, + })), + [t], + ); + + if (plugin == null) { + const pluginNotFoundError = new AragonBackendServiceError( + AragonBackendServiceError.pluginNotFoundCode, + `CreateProcessPageClient: no plugin found for address ${pluginAddress}`, + 404, + ); + + return ( + + ); + } + const handleFormSubmit = (values: ICreateProcessFormData) => { const dialogParams: IPrepareProcessDialogParams = { daoId, @@ -61,15 +101,6 @@ export const CreateProcessPageClient: React.FC< open(CreateDaoDialogId.PREPARE_PROCESS, { params: dialogParams }); }; - const processedSteps = useMemo( - () => - createProcessWizardSteps.map(({ meta, ...step }) => ({ - ...step, - meta: { ...meta, name: t(meta.name) }, - })), - [t], - ); - return ( { - if (!canCreateProposal && !hasCalledGuardRef.current) { + if ( + plugin != null && + !canCreateProposal && + !hasCalledGuardRef.current + ) { hasCalledGuardRef.current = true; createProposalGuard(); } - }, [canCreateProposal, createProposalGuard]); + }, [plugin, canCreateProposal, createProposalGuard]); }; diff --git a/apps/app/src/modules/governance/pages/createProposalPage/createProposalPageClient.test.tsx b/apps/app/src/modules/governance/pages/createProposalPage/createProposalPageClient.test.tsx index c7dff6a09a..37df4297eb 100644 --- a/apps/app/src/modules/governance/pages/createProposalPage/createProposalPageClient.test.tsx +++ b/apps/app/src/modules/governance/pages/createProposalPage/createProposalPageClient.test.tsx @@ -221,4 +221,19 @@ describe(' component', () => { params: resumeParams, }); }); + + it('renders a not-found state instead of the wizard when the plugin address matches no DAO plugin', () => { + useDaoPluginsSpy.mockReturnValue([]); + + render( + , + ); + + expect( + screen.getByText( + 'app.governance.createProposalPage.error.notFound.title', + ), + ).toBeInTheDocument(); + expect(screen.queryByTestId('steps-mock')).not.toBeInTheDocument(); + }); }); diff --git a/apps/app/src/modules/governance/pages/createProposalPage/createProposalPageClient.tsx b/apps/app/src/modules/governance/pages/createProposalPage/createProposalPageClient.tsx index 00111f990e..2bf668297d 100644 --- a/apps/app/src/modules/governance/pages/createProposalPage/createProposalPageClient.tsx +++ b/apps/app/src/modules/governance/pages/createProposalPage/createProposalPageClient.tsx @@ -1,12 +1,16 @@ 'use client'; import { useCallback, useMemo, useState } from 'react'; +import { AragonBackendServiceError } from '@/shared/api/aragonBackendService'; +import { useDao } from '@/shared/api/daoService'; import { TransactionType } from '@/shared/api/transactionService'; import { useDialogContext } from '@/shared/components/dialogProvider'; import { Page } from '@/shared/components/page'; import { useTranslations } from '@/shared/components/translationsProvider'; import { WizardPage } from '@/shared/components/wizards/wizardPage'; import { useDaoPlugins } from '@/shared/hooks/useDaoPlugins'; +import { daoUtils } from '@/shared/utils/daoUtils'; +import { errorUtils } from '@/shared/utils/errorUtils'; import { pendingTransactionManager } from '@/shared/utils/pendingTransactionManager'; import { plausibleAnalyticsUtils } from '@/shared/utils/plausibleAnalyticsUtils'; import { @@ -48,11 +52,18 @@ export const CreateProposalPageClient: React.FC< const { t } = useTranslations(); const { open } = useDialogContext(); - const { meta: plugin } = useDaoPlugins({ + // Undefined only when the plugin address is unknown (e.g. a stale link to an uninstalled + // process), which is why the not-found state below needs no loading guard: the route's + // wizard layout fetches the DAO and dehydrates it into this tree, so `useDao` already holds + // it on the first render — server and client alike. Should that layout ever stop + // prefetching, this branch would flash a not-found on every legitimate load. + const plugin = useDaoPlugins({ daoId, pluginAddress, includeLinkedAccounts: true, - })![0]; + })?.[0]?.meta; + + const { data: dao } = useDao({ urlParams: { id: daoId } }); useProposalPermissionCheckGuard({ daoId, @@ -77,6 +88,31 @@ export const CreateProposalPageClient: React.FC< [prepareActions, addPrepareAction, plugin], ); + const processedSteps = useMemo( + () => + createProposalWizardSteps.map((step) => ({ + ...step, + meta: { ...step.meta, name: t(step.meta.name) }, + })), + [t], + ); + + if (plugin == null) { + const pluginNotFoundError = new AragonBackendServiceError( + AragonBackendServiceError.pluginNotFoundCode, + `CreateProposalPageClient: no plugin found for address ${pluginAddress}`, + 404, + ); + + return ( + + ); + } + const handleFormSubmit = (values: ICreateProposalFormData) => { // We are always saving actions on the form so that user doesn't lose them if they navigate around the form. const { actions, addActions } = values; @@ -151,15 +187,6 @@ export const CreateProposalPageClient: React.FC< openPublishDialog(); }; - const processedSteps = useMemo( - () => - createProposalWizardSteps.map((step) => ({ - ...step, - meta: { ...step.meta, name: t(step.meta.name) }, - })), - [t], - ); - return ( Date: Tue, 1 Sep 2026 14:17:21 +0200 Subject: [PATCH 3/7] fix: Serve the 404 page for bot-probed DAO and proposal URLs Unknown or malformed DAO addresses and proposal slugs (HEAD/GET bot traffic, stale links) failed the server render and were reported to Sentry as errors. URL-addressed lookups now map backend client errors to notFound(), the metadata utils suppress the same class, and onRequestError skips expected not-found errors for render paths without their own handling. The backend error class also carries its name so Sentry titles are no longer a bare "Error: ...". Fixes APP-NEXT-248 Fixes APP-NEXT-2CF Fixes APP-NEXT-2CE Fixes APP-NEXT-2D9 Fixes APP-NEXT-24C Fixes APP-NEXT-24D --- .agents/shared/metrics/hits.jsonl | 18 +++++ apps/app/jest.config.js | 3 + apps/app/src/instrumentation.ts | 16 +++- .../applicationMetadataUtils.ts | 12 ++- .../pages/daoMembersPage/daoMembersPage.tsx | 22 ++++-- .../daoProposalDetailsPage.tsx | 7 +- .../daoProposalsPage/daoProposalsPage.tsx | 11 ++- .../governanceMetadataUtils.ts | 12 ++- .../aragonBackendServiceError.ts | 19 +++++ .../src/shared/utils/notFoundUtils/index.ts | 1 + .../utils/notFoundUtils/notFoundUtils.test.ts | 79 +++++++++++++++++++ .../utils/notFoundUtils/notFoundUtils.ts | 32 ++++++++ 12 files changed, 215 insertions(+), 17 deletions(-) create mode 100644 apps/app/src/shared/utils/notFoundUtils/index.ts create mode 100644 apps/app/src/shared/utils/notFoundUtils/notFoundUtils.test.ts create mode 100644 apps/app/src/shared/utils/notFoundUtils/notFoundUtils.ts diff --git a/.agents/shared/metrics/hits.jsonl b/.agents/shared/metrics/hits.jsonl index 19fee97598..8ff20153ff 100644 --- a/.agents/shared/metrics/hits.jsonl +++ b/.agents/shared/metrics/hits.jsonl @@ -205,3 +205,21 @@ {"ts":"2026-09-01T11:57:48.971Z","tool":"Edit","file":"apps/app/src/modules/governance/api/governanceService/governanceService.test.ts","rule":"test-scope-and-selectors","bytes":1727,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-09-01T11:58:13.848Z","tool":"Edit","file":"apps/app/src/modules/governance/api/governanceService/governanceService.test.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} {"ts":"2026-09-01T11:58:13.848Z","tool":"Edit","file":"apps/app/src/modules/governance/api/governanceService/governanceService.test.ts","rule":"test-scope-and-selectors","bytes":1727,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-01T12:13:00.291Z","tool":"Edit","file":"apps/app/src/shared/api/aragonBackendService/aragonBackendServiceError.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-01T12:13:00.291Z","tool":"Edit","file":"apps/app/src/shared/api/aragonBackendService/aragonBackendServiceError.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-01T12:13:06.030Z","tool":"Edit","file":"apps/app/src/shared/api/aragonBackendService/aragonBackendServiceError.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-01T12:13:06.030Z","tool":"Edit","file":"apps/app/src/shared/api/aragonBackendService/aragonBackendServiceError.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-01T12:13:56.114Z","tool":"Edit","file":"apps/app/src/modules/governance/utils/governanceMetadataUtils/governanceMetadataUtils.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-09-01T12:13:58.803Z","tool":"Edit","file":"apps/app/src/modules/application/utils/applicationMetadataUtils/applicationMetadataUtils.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-09-01T12:14:39.178Z","tool":"Write","file":"apps/app/src/shared/utils/notFoundUtils/notFoundUtils.test.ts","rule":"test-scope-and-selectors","bytes":1727,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-01T12:22:08.858Z","tool":"Edit","file":"apps/app/src/shared/utils/monitoringUtils/monitoringUtils.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-09-01T12:22:11.722Z","tool":"Edit","file":"apps/app/src/shared/utils/monitoringUtils/monitoringUtils.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-01T12:22:28.911Z","tool":"Edit","file":"apps/app/src/shared/utils/monitoringUtils/monitoringUtils.test.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-01T12:22:28.911Z","tool":"Edit","file":"apps/app/src/shared/utils/monitoringUtils/monitoringUtils.test.ts","rule":"test-scope-and-selectors","bytes":1727,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-01T12:26:08.994Z","tool":"Edit","file":"apps/app/src/shared/hooks/useDaoPlugins/useDaoPlugins.ts","rule":"plugin-visibility","bytes":3517,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-01T12:26:11.643Z","tool":"Edit","file":"apps/app/src/shared/hooks/useDaoPlugins/useDaoPlugins.ts","rule":"plugin-visibility","bytes":3517,"elapsed_ms":0,"adapter":"claude"} +{"ts":"2026-09-02T15:06:12.045Z","tool":"Edit","file":"apps/app/src/shared/api/aragonBackendService/aragonBackendServiceError.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-02T15:06:12.045Z","tool":"Edit","file":"apps/app/src/shared/api/aragonBackendService/aragonBackendServiceError.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-02T15:06:32.738Z","tool":"Edit","file":"apps/app/src/shared/utils/notFoundUtils/notFoundUtils.test.ts","rule":"test-scope-and-selectors","bytes":1727,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-02T15:07:11.649Z","tool":"Edit","file":"apps/app/src/modules/governance/utils/governanceMetadataUtils/governanceMetadataUtils.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-02T15:07:20.587Z","tool":"Edit","file":"apps/app/src/modules/application/utils/applicationMetadataUtils/applicationMetadataUtils.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":1,"adapter":"claude"} diff --git a/apps/app/jest.config.js b/apps/app/jest.config.js index b19fc33e6d..2eb784994c 100644 --- a/apps/app/jest.config.js +++ b/apps/app/jest.config.js @@ -25,6 +25,9 @@ const config = { // under src/. Pin next imports to the app's real package so tests are // unaffected by the shims' presence. '^next$': '/node_modules/next', + // Mirror the tsconfig `next/navigation-original` alias (the real next/navigation + // behind the client-hooks wrapper) for Jest's resolver. + '^next/navigation-original$': '/node_modules/next/navigation', '^next/(.*)$': '/node_modules/next/$1', // Package only exposes a `module` field (no `main`/`exports`), which Jest's // Node-style resolver doesn't understand unlike bundlers (webpack/Next.js). diff --git a/apps/app/src/instrumentation.ts b/apps/app/src/instrumentation.ts index 97c8829096..33b0590705 100644 --- a/apps/app/src/instrumentation.ts +++ b/apps/app/src/instrumentation.ts @@ -1,3 +1,4 @@ +import { AragonBackendServiceError } from './shared/api/aragonBackendService'; import { monitoringUtils } from './shared/utils/monitoringUtils'; export async function register() { @@ -10,4 +11,17 @@ export async function register() { } } -export const onRequestError = monitoringUtils.logRequestError; +// Expected not-found lookups (bots and stale links probing removed DAO/plugin URLs) +// render 404-style states and are not reported — mirrors the suppression in the +// metadata utils and PageError for render paths without their own error handling. +export const onRequestError: typeof monitoringUtils.logRequestError = ( + error, + request, + context, +) => { + if (AragonBackendServiceError.isExpectedNotFoundError(error)) { + return; + } + + return monitoringUtils.logRequestError(error, request, context); +}; diff --git a/apps/app/src/modules/application/utils/applicationMetadataUtils/applicationMetadataUtils.ts b/apps/app/src/modules/application/utils/applicationMetadataUtils/applicationMetadataUtils.ts index 9c1bc1c4bd..22a73742f5 100644 --- a/apps/app/src/modules/application/utils/applicationMetadataUtils/applicationMetadataUtils.ts +++ b/apps/app/src/modules/application/utils/applicationMetadataUtils/applicationMetadataUtils.ts @@ -73,10 +73,14 @@ class ApplicationMetadataUtils { image, }); } catch (error: unknown) { - // Suppress notFound / pluginNotFound: the page renders an empty/404 state - // for arbitrary URLs (bots, stale links to removed plugins) — not bugs, and - // would flood Sentry. - if (!AragonBackendServiceError.isExpectedNotFoundError(error)) { + // Suppress the errors that mean the URL points at nothing: the address/ENS comes + // straight from the URL, so a rejected identifier means an arbitrary URL (bots, + // stale links, malformed addresses) — not a bug, and would flood Sentry. A refused + // request (401/403/429) is a different story and still gets reported. + if ( + !AragonBackendServiceError.isExpectedNotFoundError(error) && + !AragonBackendServiceError.isUnresolvableResourceError(error) + ) { monitoringUtils.logError(error); } diff --git a/apps/app/src/modules/governance/pages/daoMembersPage/daoMembersPage.tsx b/apps/app/src/modules/governance/pages/daoMembersPage/daoMembersPage.tsx index 41b945828c..f587200f2f 100644 --- a/apps/app/src/modules/governance/pages/daoMembersPage/daoMembersPage.tsx +++ b/apps/app/src/modules/governance/pages/daoMembersPage/daoMembersPage.tsx @@ -7,6 +7,7 @@ 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 { notFoundUtils } from '@/shared/utils/notFoundUtils'; import { memberListOptions } from '../../api/governanceService'; import { DaoMembersPageClient } from './daoMembersPageClient'; @@ -30,12 +31,23 @@ export const DaoMembersPage: React.FC = async (props) => { const queryClient = new QueryClient(); - const daoId = await daoUtils.resolveDaoId(daoPageParams); + // Bots constantly probe DAO URLs with unknown or malformed addresses — render the + // 404 page for those instead of failing the request. + const daoId = await notFoundUtils.fetchOrNotFound(() => + daoUtils.resolveDaoId(daoPageParams), + ); const daoUrlParams = { id: daoId }; - const [dao, daoOverrides, featuredDelegates] = await Promise.all([ - queryClient.fetchQuery(daoOptions({ urlParams: daoUrlParams })), - queryClient.fetchQuery(daoOverridesOptions()), - cmsService.getFeaturedDelegates(), + // Only the DAO read is addressed by the URL, so only it maps a rejected identifier onto the + // 404 page. The CMS reads answer "what does the CMS say about DAOs", not "does this DAO + // exist" — folding them in would turn a content hiccup into a not-found page. + const [dao, [daoOverrides, featuredDelegates]] = await Promise.all([ + notFoundUtils.fetchOrNotFound(() => + queryClient.fetchQuery(daoOptions({ urlParams: daoUrlParams })), + ), + Promise.all([ + queryClient.fetchQuery(daoOverridesOptions()), + cmsService.getFeaturedDelegates(), + ]), ]); const daoOverride = daoOverrides[daoId]; diff --git a/apps/app/src/modules/governance/pages/daoProposalDetailsPage/daoProposalDetailsPage.tsx b/apps/app/src/modules/governance/pages/daoProposalDetailsPage/daoProposalDetailsPage.tsx index 7e5f897027..2873928789 100644 --- a/apps/app/src/modules/governance/pages/daoProposalDetailsPage/daoProposalDetailsPage.tsx +++ b/apps/app/src/modules/governance/pages/daoProposalDetailsPage/daoProposalDetailsPage.tsx @@ -2,6 +2,7 @@ import { QueryClient } from '@tanstack/react-query'; import { Page } from '@/shared/components/page'; import { daoUtils } from '@/shared/utils/daoUtils'; import { errorUtils } from '@/shared/utils/errorUtils'; +import { notFoundUtils } from '@/shared/utils/notFoundUtils'; import { proposalActionsOptions, proposalBySlugOptions, @@ -21,7 +22,11 @@ export const DaoProposalDetailsPage: React.FC< > = async (props) => { const { params } = props; const { addressOrEns, network, proposalSlug } = await params; - const daoId = await daoUtils.resolveDaoId({ addressOrEns, network }); + // Bots constantly probe proposal URLs with unknown or malformed addresses — render + // the 404 page for those instead of failing the request. + const daoId = await notFoundUtils.fetchOrNotFound(() => + daoUtils.resolveDaoId({ addressOrEns, network }), + ); const queryClient = new QueryClient(); diff --git a/apps/app/src/modules/governance/pages/daoProposalsPage/daoProposalsPage.tsx b/apps/app/src/modules/governance/pages/daoProposalsPage/daoProposalsPage.tsx index 28be9ad32a..2dbfaac0a1 100644 --- a/apps/app/src/modules/governance/pages/daoProposalsPage/daoProposalsPage.tsx +++ b/apps/app/src/modules/governance/pages/daoProposalsPage/daoProposalsPage.tsx @@ -7,6 +7,7 @@ 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 { notFoundUtils } from '@/shared/utils/notFoundUtils'; import { proposalListOptions } from '../../api/governanceService'; import { DaoProposalsPageClient } from './daoProposalsPageClient'; @@ -33,10 +34,16 @@ export const DaoProposalsPage: React.FC = async ( const queryClient = new QueryClient(); - const daoId = await daoUtils.resolveDaoId(daoPageParams); + // Bots constantly probe DAO URLs with unknown or malformed addresses — render the + // 404 page for those instead of failing the request. + const daoId = await notFoundUtils.fetchOrNotFound(() => + daoUtils.resolveDaoId(daoPageParams), + ); const daoUrlParams = { id: daoId }; const daoParams = { urlParams: daoUrlParams }; - const dao = await queryClient.fetchQuery(daoOptions(daoParams)); + const dao = await notFoundUtils.fetchOrNotFound(() => + queryClient.fetchQuery(daoOptions(daoParams)), + ); const daoOverrides = await queryClient.fetchQuery(daoOverridesOptions()); const daoOverride = daoOverrides[daoId]; diff --git a/apps/app/src/modules/governance/utils/governanceMetadataUtils/governanceMetadataUtils.ts b/apps/app/src/modules/governance/utils/governanceMetadataUtils/governanceMetadataUtils.ts index 3c7ebf7c39..f24f37cd53 100644 --- a/apps/app/src/modules/governance/utils/governanceMetadataUtils/governanceMetadataUtils.ts +++ b/apps/app/src/modules/governance/utils/governanceMetadataUtils/governanceMetadataUtils.ts @@ -46,10 +46,14 @@ class GovernanceMetadataUtils { type: 'article', }); } catch (error: unknown) { - // Suppress notFound / pluginNotFound: the page renders an empty/404 state - // for arbitrary URLs (bots, stale links to removed plugins) — not bugs, and - // would flood Sentry. - if (!AragonBackendServiceError.isExpectedNotFoundError(error)) { + // Suppress the errors that mean the URL points at nothing: the slug/address comes + // straight from the URL, so a rejected identifier means an arbitrary URL (bots, + // stale links, malformed slugs) — not a bug, and would flood Sentry. A refused + // request (401/403/429) is a different story and still gets reported. + if ( + !AragonBackendServiceError.isExpectedNotFoundError(error) && + !AragonBackendServiceError.isUnresolvableResourceError(error) + ) { monitoringUtils.logError(error); } diff --git a/apps/app/src/shared/api/aragonBackendService/aragonBackendServiceError.ts b/apps/app/src/shared/api/aragonBackendService/aragonBackendServiceError.ts index 8fecb83889..9b8cba4a7d 100644 --- a/apps/app/src/shared/api/aragonBackendService/aragonBackendServiceError.ts +++ b/apps/app/src/shared/api/aragonBackendService/aragonBackendServiceError.ts @@ -15,6 +15,7 @@ export class AragonBackendServiceError extends Error { constructor(code: string, description: string, status: number) { super(description); + this.name = 'AragonBackendServiceError'; this.code = code; this.description = description; this.status = status; @@ -79,4 +80,22 @@ export class AragonBackendServiceError extends Error { */ static isExpectedNotFoundError = (error: unknown) => this.isNotFoundError(error) || this.isPluginNotFoundError(error); + + /** + * True when the backend rejected the identifier the URL carries: a 404, or a 400 refusing + * the address or slug itself ("Bad parameters" — what a bot probing a malformed DAO address + * produces). For lookups addressed purely by URL parts (DAO address/ENS, proposal slug) both + * mean the URL points at nothing, so such lookups render the 404 page instead of reporting + * the failure. + * + * Deliberately not the whole 4xx range: a 401, 403 or 429 means the request was refused, not + * that the resource is gone. Folding those into a not-found would show a legitimate visitor + * the 404 page and hide a rate limit or an auth outage from Sentry. Only meaningful for + * URL-addressed reads; the same statuses on app-built requests still indicate bugs. + */ + static isUnresolvableResourceError = ( + error: unknown, + ): error is AragonBackendServiceError => + error instanceof AragonBackendServiceError && + (error.status === 400 || error.status === 404); } diff --git a/apps/app/src/shared/utils/notFoundUtils/index.ts b/apps/app/src/shared/utils/notFoundUtils/index.ts new file mode 100644 index 0000000000..2a4ff6b23b --- /dev/null +++ b/apps/app/src/shared/utils/notFoundUtils/index.ts @@ -0,0 +1 @@ +export { notFoundUtils } from './notFoundUtils'; diff --git a/apps/app/src/shared/utils/notFoundUtils/notFoundUtils.test.ts b/apps/app/src/shared/utils/notFoundUtils/notFoundUtils.test.ts new file mode 100644 index 0000000000..33856da016 --- /dev/null +++ b/apps/app/src/shared/utils/notFoundUtils/notFoundUtils.test.ts @@ -0,0 +1,79 @@ +import { notFound } from 'next/navigation-original'; +import { AragonBackendServiceError } from '@/shared/api/aragonBackendService'; +import { notFoundUtils } from './notFoundUtils'; + +jest.mock('next/navigation-original', () => ({ + notFound: jest.fn(() => { + throw new Error('NEXT_HTTP_ERROR_FALLBACK;404'); + }), +})); + +describe('notFoundUtils', () => { + const notFoundMock = notFound as jest.MockedFunction; + + afterEach(() => { + notFoundMock.mockClear(); + }); + + describe('fetchOrNotFound', () => { + it('returns the resolved value of the fetch callback', async () => { + const result = await notFoundUtils.fetchOrNotFound(() => + Promise.resolve('value'), + ); + expect(result).toEqual('value'); + expect(notFoundMock).not.toHaveBeenCalled(); + }); + + it.each([ + ['badParameters', 'Bad parameters', 400], + ['notFound', 'Resource not found', 404], + ])( + 'renders the 404 page when the backend rejects the identifier (%s)', + async (code, description, status) => { + const error = new AragonBackendServiceError( + code, + description, + status, + ); + await expect( + notFoundUtils.fetchOrNotFound(() => Promise.reject(error)), + ).rejects.toThrow('NEXT_HTTP_ERROR_FALLBACK;404'); + expect(notFoundMock).toHaveBeenCalled(); + }, + ); + + // A refused request says nothing about the URL: turning it into a 404 would show a + // legitimate visitor a not-found page and keep the outage out of Sentry. + it.each([ + ['unauthorized', 401], + ['forbidden', 403], + ['rateLimited', 429], + ])('propagates refused requests (%s)', async (code, status) => { + const error = new AragonBackendServiceError(code, code, status); + await expect( + notFoundUtils.fetchOrNotFound(() => Promise.reject(error)), + ).rejects.toBe(error); + expect(notFoundMock).not.toHaveBeenCalled(); + }); + + it('propagates backend server errors', async () => { + const error = new AragonBackendServiceError( + 'serverError', + 'Internal error', + 502, + ); + await expect( + notFoundUtils.fetchOrNotFound(() => Promise.reject(error)), + ).rejects.toBe(error); + expect(notFoundMock).not.toHaveBeenCalled(); + }); + + it('propagates errors that are not backend errors', async () => { + const error = new Error('network down'); + await expect( + notFoundUtils.fetchOrNotFound(() => Promise.reject(error)), + ).rejects.toBe(error); + expect(notFoundMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/app/src/shared/utils/notFoundUtils/notFoundUtils.ts b/apps/app/src/shared/utils/notFoundUtils/notFoundUtils.ts new file mode 100644 index 0000000000..5e86cc2fe7 --- /dev/null +++ b/apps/app/src/shared/utils/notFoundUtils/notFoundUtils.ts @@ -0,0 +1,32 @@ +// The `next/navigation` alias points to the client-hooks wrapper (src/shared/lib/nextNavigation), +// which cannot re-export server functions — import notFound from the real module instead. +import { notFound } from 'next/navigation-original'; +import { AragonBackendServiceError } from '@/shared/api/aragonBackendService'; + +class NotFoundUtils { + /** + * Runs a server-side lookup for a URL-addressed resource (DAO address/ENS, proposal slug) + * and renders the 404 page when the backend says the identifier resolves to nothing — an + * unknown resource or a malformed one, produced constantly by bots and stale links. + * Everything else — a refused request (401/403/429), a server failure, a network error — + * keeps propagating so it stays visible. + * + * Wrap the URL-addressed lookups only. A call whose failure says nothing about the URL (CMS + * content, overrides) must stay outside, or its hiccup turns the whole page into a 404. + */ + fetchOrNotFound = async ( + fetchResource: () => Promise, + ): Promise => { + try { + return await fetchResource(); + } catch (error: unknown) { + if (AragonBackendServiceError.isUnresolvableResourceError(error)) { + notFound(); + } + + throw error; + } + }; +} + +export const notFoundUtils = new NotFoundUtils(); From 9a927dacc78aab31ecfd61f1290bd5a0b79ca965 Mon Sep 17 00:00:00 2001 From: Andrew Date: Tue, 1 Sep 2026 14:20:55 +0200 Subject: [PATCH 4/7] fix: Format dates after mount to stop SSR hydration mismatches formatterUtils.formatDate renders in the viewer's timezone (and RELATIVE recomputes against "now"), while the server renders in UTC, so the members, proposals, proposal-details and dashboard pages hydrated different text than the server sent whenever a timestamp crossed a UTC boundary for the viewer's zone. Gate those values behind useIsMounted, following the existing pattern in daoProcessDetailsPageClient. Fixes APP-NEXT-267 Fixes APP-NEXT-155 Fixes APP-NEXT-1YB Fixes APP-NEXT-22 --- .../daoDashboardPageClient.tsx | 13 +++++++-- .../proposalListStats.test.tsx | 29 ++++++++----------- .../proposalListStats/proposalListStats.tsx | 15 +++++++--- .../daoProposalDetailsPageClient.tsx | 16 ++++++---- .../useDaoPluginInfo/useDaoPluginInfo.ts | 13 +++++++-- 5 files changed, 53 insertions(+), 33 deletions(-) diff --git a/apps/app/src/modules/dashboard/pages/daoDashboardPage/daoDashboardPageClient.tsx b/apps/app/src/modules/dashboard/pages/daoDashboardPage/daoDashboardPageClient.tsx index bb5ef22178..2fb8f351f1 100644 --- a/apps/app/src/modules/dashboard/pages/daoDashboardPage/daoDashboardPageClient.tsx +++ b/apps/app/src/modules/dashboard/pages/daoDashboardPage/daoDashboardPageClient.tsx @@ -22,6 +22,7 @@ import { useTranslations } from '@/shared/components/translationsProvider'; import { useAdminStatus } from '@/shared/hooks/useAdminStatus'; import { useDaoChain } from '@/shared/hooks/useDaoChain'; import { useDaoPlugins } from '@/shared/hooks/useDaoPlugins'; +import { useIsMounted } from '@/shared/hooks/useIsMounted'; import { PluginType } from '@/shared/types'; import { daoUtils } from '@/shared/utils/daoUtils'; import { DashboardDefaultHeader } from '../../components/dashboardDefaultHeader'; @@ -66,6 +67,10 @@ export const DaoDashboardPageClient: React.FC = ( useDaoPlugins({ daoId, type: PluginType.PROCESS, visibleOnly: true }) ?? []; + // Dates are formatted in the viewer's timezone while the server renders in UTC — + // render them only after mount to avoid a hydration mismatch. + const isMounted = useIsMounted(); + if (dao == null) { return null; } @@ -80,9 +85,11 @@ export const DaoDashboardPageClient: React.FC = ( const daoEns = daoUtils.getDaoEns(dao); const truncatedAddress = addressUtils.truncateAddress(dao.address); - const daoLaunchedAt = formatterUtils.formatDate(dao.blockTimestamp * 1000, { - format: DateFormat.YEAR_MONTH, - }); + const daoLaunchedAt = isMounted + ? formatterUtils.formatDate(dao.blockTimestamp * 1000, { + format: DateFormat.YEAR_MONTH, + }) + : '-'; const daoAddressLink = buildEntityUrl({ type: ChainEntityType.ADDRESS, diff --git a/apps/app/src/modules/governance/components/proposalListStats/proposalListStats.test.tsx b/apps/app/src/modules/governance/components/proposalListStats/proposalListStats.test.tsx index 41143ae27d..2283e13c1f 100644 --- a/apps/app/src/modules/governance/components/proposalListStats/proposalListStats.test.tsx +++ b/apps/app/src/modules/governance/components/proposalListStats/proposalListStats.test.tsx @@ -66,16 +66,15 @@ describe(' component', () => { it('renders all stats with valid data and formatted relative date', () => { const proposals = [generateProposal({ blockTimestamp: 1_720_000_000 })]; - useProposalListDataSpy - .mockReturnValueOnce( - createProposalListData({ - proposalList: proposals, - itemsCount: 20, - }), - ) - .mockReturnValueOnce( - createProposalListData({ proposalList: [], itemsCount: 5 }), - ); + // Keyed on params (not call order) as the component re-renders after mount. + useProposalListDataSpy.mockImplementation((params) => + params.queryParams.isExecuted + ? createProposalListData({ proposalList: [], itemsCount: 5 }) + : createProposalListData({ + proposalList: proposals, + itemsCount: 20, + }), + ); useDaoPluginsSpy.mockReturnValue([ generateFilterComponentPlugin(), @@ -106,13 +105,9 @@ describe(' component', () => { }); it('renders "-" when data is missing', () => { - useProposalListDataSpy - .mockReturnValueOnce( - createProposalListData({ itemsCount: undefined }), - ) - .mockReturnValueOnce( - createProposalListData({ itemsCount: undefined }), - ); + useProposalListDataSpy.mockReturnValue( + createProposalListData({ itemsCount: undefined }), + ); useDaoPluginsSpy.mockReturnValue(undefined); formatDateSpy.mockReturnValue('-'); diff --git a/apps/app/src/modules/governance/components/proposalListStats/proposalListStats.tsx b/apps/app/src/modules/governance/components/proposalListStats/proposalListStats.tsx index 8b5beebafe..e2065340f2 100644 --- a/apps/app/src/modules/governance/components/proposalListStats/proposalListStats.tsx +++ b/apps/app/src/modules/governance/components/proposalListStats/proposalListStats.tsx @@ -10,6 +10,7 @@ import type { IDao } from '@/shared/api/daoService'; import { StatCard } from '@/shared/components/statCard'; import { useTranslations } from '@/shared/components/translationsProvider'; import { useDaoPlugins } from '@/shared/hooks/useDaoPlugins'; +import { useIsMounted } from '@/shared/hooks/useIsMounted'; import { PluginType } from '@/shared/types'; import { daoUtils } from '@/shared/utils/daoUtils'; import type { IGetProposalListParams } from '../../api/governanceService'; @@ -49,14 +50,20 @@ export const ProposalListStats: React.FC = (props) => { }); const buttonUrl = daoUtils.getDaoUrl(dao, 'settings#governance'); + // Relative time is computed against "now" (and the viewer's timezone), so the server + // and client render different text — format only after mount to avoid a hydration + // mismatch; the stat shows the same placeholder as the loading state until then. + const isMounted = useIsMounted(); + const latestProposalDate = proposalList != null && proposalList.length > 0 ? proposalList[0].blockTimestamp * 1000 : undefined; - const formattedProposalDate = formatterUtils.formatDate( - latestProposalDate, - { format: DateFormat.RELATIVE }, - ); + const formattedProposalDate = isMounted + ? formatterUtils.formatDate(latestProposalDate, { + format: DateFormat.RELATIVE, + }) + : undefined; const [proposalDateValue, proposalDateUnit] = formattedProposalDate?.split( ' ', diff --git a/apps/app/src/modules/governance/pages/daoProposalDetailsPage/daoProposalDetailsPageClient.tsx b/apps/app/src/modules/governance/pages/daoProposalDetailsPage/daoProposalDetailsPageClient.tsx index d131386311..1378e01f19 100644 --- a/apps/app/src/modules/governance/pages/daoProposalDetailsPage/daoProposalDetailsPageClient.tsx +++ b/apps/app/src/modules/governance/pages/daoProposalDetailsPage/daoProposalDetailsPageClient.tsx @@ -29,6 +29,7 @@ import { ResourceLink } from '@/shared/components/resourceLink'; import { SafeDocumentParser } from '@/shared/components/SafeDocumentParser'; import { useTranslations } from '@/shared/components/translationsProvider'; import { useDaoChain } from '@/shared/hooks/useDaoChain'; +import { useIsMounted } from '@/shared/hooks/useIsMounted'; import { useSlotSingleFunction } from '@/shared/hooks/useSlotSingleFunction'; import { daoUtils } from '@/shared/utils/daoUtils'; import { @@ -135,6 +136,10 @@ export const DaoProposalDetailsPageClient: React.FC< const { data: creatorEnsName } = useEnsName(proposal?.creator.address); + // Dates are formatted in the viewer's timezone while the server renders in UTC — + // render them only after mount to avoid a hydration mismatch. + const isMounted = useIsMounted(); + if (proposal == null || dao == null) { return null; } @@ -194,12 +199,11 @@ export const DaoProposalDetailsPageClient: React.FC< proposalActions, dao, ); - const formattedCreationDate = formatterUtils.formatDate( - blockTimestamp * 1000, - { - format: DateFormat.YEAR_MONTH_DAY, - }, - ); + const formattedCreationDate = isMounted + ? formatterUtils.formatDate(blockTimestamp * 1000, { + format: DateFormat.YEAR_MONTH_DAY, + }) + : '-'; const creatorName = creatorEnsName ?? addressUtils.truncateAddress(creator.address); diff --git a/apps/app/src/shared/hooks/useDaoPluginInfo/useDaoPluginInfo.ts b/apps/app/src/shared/hooks/useDaoPluginInfo/useDaoPluginInfo.ts index 5ca1a630bb..98bf414e47 100644 --- a/apps/app/src/shared/hooks/useDaoPluginInfo/useDaoPluginInfo.ts +++ b/apps/app/src/shared/hooks/useDaoPluginInfo/useDaoPluginInfo.ts @@ -10,6 +10,7 @@ import { useTranslations } from '@/shared/components/translationsProvider'; import { daoUtils } from '@/shared/utils/daoUtils'; import { useDaoChain } from '../useDaoChain'; import { useDaoPlugins } from '../useDaoPlugins'; +import { useIsMounted } from '../useIsMounted'; export interface IUseDaoPluginInfoParams { /** @@ -43,14 +44,20 @@ export const useDaoPluginInfo = ( const { buildEntityUrl } = useDaoChain({ network: dao?.network }); + // Dates are formatted in the viewer's timezone while the server renders in UTC — + // render them only after mount to avoid a hydration mismatch. + const isMounted = useIsMounted(); + if (dao == null || plugin == null) { return settings; } const { blockTimestamp, transactionHash, release, build } = plugin.meta; - const pluginLaunchedAt = formatterUtils.formatDate(blockTimestamp * 1000, { - format: DateFormat.YEAR_MONTH, - })!; + const pluginLaunchedAt = isMounted + ? formatterUtils.formatDate(blockTimestamp * 1000, { + format: DateFormat.YEAR_MONTH, + })! + : '-'; const pluginCreationLink = buildEntityUrl({ type: ChainEntityType.TRANSACTION, From d43ae4f11ce125e6c1033afda803b06f60bdb890 Mon Sep 17 00:00:00 2001 From: Andrew Date: Tue, 1 Sep 2026 14:25:04 +0200 Subject: [PATCH 5/7] chore: Classify environment noise (in-app browsers, extension conflicts) as expected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an environmentNoisePatterns list to the beforeSend taxonomy for errors injected by the user's environment rather than our code: WKWebView scripts serializing cyclic structures, wallet extensions fighting over window.ethereum, extensions mutating the DOM behind React, private-mode storage, deploy skew and aborted streaming responses. Routed like expected user behaviour — kept searchable, tagged expected, demoted to info — plus the AppKit disconnect failure as expected wallet behaviour. Fixes APP-NEXT-2B5 Fixes APP-NEXT-2D1 Fixes APP-NEXT-272 Fixes APP-NEXT-29W Fixes APP-NEXT-2ED Fixes APP-NEXT-2EZ --- .../monitoringUtils/monitoringUtils.test.ts | 27 +++++++++++++++++++ .../utils/monitoringUtils/monitoringUtils.ts | 22 +++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/apps/app/src/shared/utils/monitoringUtils/monitoringUtils.test.ts b/apps/app/src/shared/utils/monitoringUtils/monitoringUtils.test.ts index 00a3240a85..13a5b13590 100644 --- a/apps/app/src/shared/utils/monitoringUtils/monitoringUtils.test.ts +++ b/apps/app/src/shared/utils/monitoringUtils/monitoringUtils.test.ts @@ -64,6 +64,33 @@ describe('monitoring utils', () => { buildEvent('TypeError: Converting circular structure to JSON'), ); expect(result).not.toBeNull(); + expect(result?.tags?.noise_class).toBeUndefined(); + }); + + it('tags environment noise (in-app browsers, extension conflicts, deploy skew) as expected/info', () => { + const environmentMessages = [ + 'TypeError: JSON.stringify cannot serialize cyclic structures.', + "TypeError: 'get' on proxy: property 'removeListener' is a read-only and non-configurable data property", + "NotFoundError: Failed to execute 'removeChild' on 'Node'", + "ReferenceError: Can't find variable: indexedDB", + 'Error: Failed to find Server Action. This request might be from an older or newer deployment.', + 'Error: The destination stream closed early.', + ]; + + environmentMessages.forEach((message) => { + const result = monitoringUtils.beforeSend(buildEvent(message)); + expect(result).not.toBeNull(); + expect(result?.tags?.noise_class).toEqual('expected'); + expect(result?.level).toEqual('info'); + }); + }); + + it('tags AppKit disconnect failures as expected wallet behaviour', () => { + const result = monitoringUtils.beforeSend( + buildEvent('AppKitError: Failed to disconnect'), + ); + expect(result).not.toBeNull(); + expect(result?.tags?.noise_class).toEqual('expected'); }); it('keeps expected wallet behaviour, tags it expected and demotes to info', () => { diff --git a/apps/app/src/shared/utils/monitoringUtils/monitoringUtils.ts b/apps/app/src/shared/utils/monitoringUtils/monitoringUtils.ts index 33331fa656..562facec32 100644 --- a/apps/app/src/shared/utils/monitoringUtils/monitoringUtils.ts +++ b/apps/app/src/shared/utils/monitoringUtils/monitoringUtils.ts @@ -73,6 +73,25 @@ class MonitoringUtils { 'replacement transaction underpriced', // Wallet has a stuck same-nonce transaction; user-resolvable "session topic doesn't exist", // WalletConnect: stale session 'No matching key. session topic', // WalletConnect: stale session + 'Failed to disconnect', // AppKit: disconnect with an already-gone wallet session + ]; + + /** + * Noise injected by the user's environment rather than our code or their actions: + * in-app-browser scripts, conflicting wallet extensions, private-mode storage, + * aborted streaming responses and deploy skew. Routed exactly like expected user + * behaviour: kept for investigation, tagged `expected`, demoted to info, out of alerts. + */ + private environmentNoisePatterns = [ + // WKWebView in-app-browser scripts JSON.stringify-ing cyclic host objects. The Chrome + // variant names the property (see the `__reactFiber` drop pattern); Safari's generic + // text is kept-but-demoted so a genuine cyclic bug of ours stays searchable. + 'cannot serialize cyclic structures', + "'get' on proxy: property 'removeListener'", // Two wallet extensions fighting over window.ethereum + "Failed to execute 'removeChild'", // Extensions/Google Translate mutating the DOM behind React + "Can't find variable: indexedDB", // Private mode / restricted WebView storage + 'Failed to find Server Action', // Deploy skew: the client bundle is older/newer than the server + 'The destination stream closed early', // Client aborted the streaming response mid-render ]; /** @@ -210,6 +229,9 @@ class MonitoringUtils { this.nonActionableExternalPatterns.some((pattern) => message.includes(pattern), ) || + this.environmentNoisePatterns.some((pattern) => + message.includes(pattern), + ) || this.hasExpectedProviderErrorCode(hint, event) ) { return 'expected'; From 89915864002ad69683aa22fa7622c986ad325238 Mon Sep 17 00:00:00 2001 From: Andrew Date: Tue, 1 Sep 2026 14:39:11 +0200 Subject: [PATCH 6/7] fix: Compare plugin daoAddress case-insensitively in useDaoPlugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The linked-accounts-off filter compared plugin.daoAddress with the DAO record address by strict string equality, so a checksum/lowercase mismatch between the two backend fields silently dropped the plugin client-side while the server-side daoUtils.getDaoPlugins kept it — crashing pages that dereference the first plugin (e.g. gauge voter). Fixes APP-NEXT-2B9 --- apps/app/src/shared/hooks/useDaoPlugins/useDaoPlugins.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/app/src/shared/hooks/useDaoPlugins/useDaoPlugins.ts b/apps/app/src/shared/hooks/useDaoPlugins/useDaoPlugins.ts index 2425c4e638..7e5b28a055 100644 --- a/apps/app/src/shared/hooks/useDaoPlugins/useDaoPlugins.ts +++ b/apps/app/src/shared/hooks/useDaoPlugins/useDaoPlugins.ts @@ -114,7 +114,12 @@ const buildFilterPlugins = ( return true; } - return daoAddress === rootDaoAddress; + // Case-insensitive: the backend may return a differently-checksummed + // daoAddress than the DAO record, which must not drop the plugin. + return ( + rootDaoAddress != null && + daoAddress.toLowerCase() === rootDaoAddress.toLowerCase() + ); }); const processedPlugins = filteredPlugins.map((plugin) => ({ From 49f8c6a2929480a7daa4d7ea01a8ea1ab2de892e Mon Sep 17 00:00:00 2001 From: Andrew Date: Tue, 1 Sep 2026 14:44:48 +0200 Subject: [PATCH 7/7] chore: Add changeset for the Sentry-audit fixes --- .changeset/sentry-audit-hardening.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/sentry-audit-hardening.md diff --git a/.changeset/sentry-audit-hardening.md b/.changeset/sentry-audit-hardening.md new file mode 100644 index 0000000000..ffcf49ce3b --- /dev/null +++ b/.changeset/sentry-audit-hardening.md @@ -0,0 +1,5 @@ +--- +"@aragon/app": patch +--- + +Fix the Sentry-audit crash and noise findings: enrich SPP sub-proposals routed to lock-to-vote by their stage body so the proposals page no longer crashes on inconsistent backend data, render a not-found state on the create proposal/process routes for unknown plugin addresses, serve the 404 page (instead of a reported server error) for bot-probed DAO and proposal URLs, format dates after mount to stop SSR hydration mismatches on the members/proposals/proposal-details/dashboard pages, compare plugin daoAddress case-insensitively in useDaoPlugins, and classify environment noise (in-app browsers, wallet-extension conflicts, private-mode storage, deploy skew) as expected in the monitoring taxonomy.