Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 33 additions & 21 deletions .agents/shared/metrics/hits.jsonl

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions .changeset/sentry-audit-hardening.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions apps/app/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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$': '<rootDir>/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$': '<rootDir>/node_modules/next/navigation',
'^next/(.*)$': '<rootDir>/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).
Expand Down
14 changes: 14 additions & 0 deletions apps/app/src/assets/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
16 changes: 15 additions & 1 deletion apps/app/src/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AragonBackendServiceError } from './shared/api/aragonBackendService';
import { monitoringUtils } from './shared/utils/monitoringUtils';

export async function register() {
Expand All @@ -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);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -82,17 +89,25 @@ describe('<CreateProcessPageClient /> 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();
});

Expand Down Expand Up @@ -131,4 +146,17 @@ describe('<CreateProcessPageClient /> 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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<Page.Error
actionLink={daoUtils.getDaoUrl(dao, 'settings')}
error={errorUtils.serialize(pluginNotFoundError)}
errorNamespace="app.createDao.createProcessPage.error"
/>
);
}

const handleFormSubmit = (values: ICreateProcessFormData) => {
const dialogParams: IPrepareProcessDialogParams = {
daoId,
Expand All @@ -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 (
<Page.Main fullWidth={true}>
<WizardPage.Container
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -66,6 +67,10 @@ export const DaoDashboardPageClient: React.FC<IDaoDashboardPageClientProps> = (
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;
}
Expand All @@ -80,9 +85,11 @@ export const DaoDashboardPageClient: React.FC<IDaoDashboardPageClientProps> = (
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ describe('governance service', () => {
const multisigSub = {
...generateMultisigProposal({
id: 'sub-multisig',
pluginAddress: '0x456',
pluginInterfaceType: PluginInterfaceType.MULTISIG,
}),
stageIndex: 0,
Expand Down Expand Up @@ -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<ISppProposal>(
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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Expand Down
Loading
Loading