diff --git a/AGENTS.md b/AGENTS.md
index a87b6dc6..22ff40e5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,6 +13,16 @@ Always consult these docs for detailed information:
---
+## Core Philosophy
+
+1. Anchor on the user-critical failure before proposing solutions.
+2. Prefer the smallest chokepoint change over broad instrumentation.
+3. Avoid scope creep unless it is required to solve the root cause.
+4. Do not claim repo facts without evidence (no invented counts).
+5. Prevent double-capture, noisy heuristics, or duplicate logic.
+
+---
+
## 🛠️ Skills System
This project uses **skills** for domain-specific patterns. Agents should load the relevant skill before working on related tasks.
@@ -68,6 +78,34 @@ The skill injects detailed patterns and conventions into the conversation contex
---
+## First-Principles Self-Review (Before Proposing Fixes)
+
+Before proposing a solution, add a short self-review:
+
+1. What user-critical failure are we solving?
+2. Is the change scoped to the smallest chokepoint that fixes the root cause?
+3. Does it avoid scope creep (new features) unless required to solve the root cause?
+4. What evidence in the repo supports the claim? (no invented counts)
+5. What is the simplest safe rollout path?
+6. What would we NOT do to keep the change auditable and safe?
+7. What could cause double-capture, noise, or duplicate logic?
+
+If you cannot answer these briefly, do not propose the change yet.
+
+---
+
+## Plan Gate (Scope Check)
+
+If the proposed work touches more than 2 files, adds a new module, or changes runtime behavior, provide a short plan first and wait for confirmation.
+
+Plan format:
+1. Goal
+2. Smallest viable change
+3. Files touched
+4. Risk/rollback note
+
+---
+
## MANDATORY: Validate After Every Implementation Step
**STOP after each implementation step and validate before moving on.** This is NOT optional. Do NOT batch all validation to the end.
diff --git a/app/api/admin/monarch-indexer/route.ts b/app/api/admin/monarch-indexer/route.ts
index d2fbd66e..0882b2fe 100644
--- a/app/api/admin/monarch-indexer/route.ts
+++ b/app/api/admin/monarch-indexer/route.ts
@@ -1,5 +1,6 @@
import { type NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
+import { reportApiRouteError } from '@/utils/sentry-server';
/**
* Proxy API route for Monarch Indexer
@@ -47,6 +48,11 @@ export async function POST(request: NextRequest) {
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
+ reportApiRouteError(error, {
+ route: '/api/admin/monarch-indexer',
+ method: 'POST',
+ status: 500,
+ });
console.error('Monarch indexer proxy error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
diff --git a/app/api/monarch/metrics/route.ts b/app/api/monarch/metrics/route.ts
index 2c022e3f..f737c6d2 100644
--- a/app/api/monarch/metrics/route.ts
+++ b/app/api/monarch/metrics/route.ts
@@ -1,5 +1,6 @@
import { type NextRequest, NextResponse } from 'next/server';
import { MONARCH_API_KEY, getMonarchUrl } from '../utils';
+import { reportApiRouteError } from '@/utils/sentry-server';
export async function GET(req: NextRequest) {
if (!MONARCH_API_KEY) {
@@ -29,6 +30,11 @@ export async function GET(req: NextRequest) {
return NextResponse.json(await response.json());
} catch (error) {
+ reportApiRouteError(error, {
+ route: '/api/monarch/metrics',
+ method: 'GET',
+ status: 500,
+ });
console.error('[Monarch Metrics API] Failed to fetch:', error);
return NextResponse.json({ error: 'Failed to fetch market metrics' }, { status: 500 });
}
diff --git a/app/api/oracle-metadata/[chainId]/route.ts b/app/api/oracle-metadata/[chainId]/route.ts
index 61de9d40..fbc3f4b0 100644
--- a/app/api/oracle-metadata/[chainId]/route.ts
+++ b/app/api/oracle-metadata/[chainId]/route.ts
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server';
+import { reportApiRouteError } from '@/utils/sentry-server';
const ORACLE_GIST_BASE_URL = process.env.ORACLE_GIST_BASE_URL;
@@ -31,6 +32,14 @@ export async function GET(request: Request, { params }: { params: Promise<{ chai
return NextResponse.json(data);
} catch (error) {
+ reportApiRouteError(error, {
+ route: '/api/oracle-metadata/[chainId]',
+ method: 'GET',
+ status: 500,
+ extras: {
+ chainId,
+ },
+ });
console.error('Failed to fetch oracle metadata:', error);
return NextResponse.json({ error: 'Failed to fetch oracle metadata' }, { status: 500 });
}
diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md
index fd58f567..e3d035bb 100644
--- a/docs/TECHNICAL_OVERVIEW.md
+++ b/docs/TECHNICAL_OVERVIEW.md
@@ -24,6 +24,11 @@ Monarch is a client-side DeFi dashboard for the Morpho Blue lending protocol. It
| @morpho-org/blue-sdk | 5.3.0 | Morpho Blue protocol SDK |
| @cowprotocol/cow-sdk | 7.2.9 | Intent-based swaps |
+**Wagmi v3 integration notes:**
+- Prefer `useConnection()` when you need wallet state (`address`, `chainId`, `isConnected`) in one place.
+- Avoid introducing `useAccount()` + `useChainId()` pairs for new wallet-state sync logic.
+- `useTransactionWithToast` already reports `useSendTransaction` failures to Sentry; global React Query mutation telemetry should not re-capture `sendTransaction` mutation errors.
+
### State Management
| Technology | Version | Purpose |
|-----------|---------|---------|
diff --git a/src/OnchainProviders.tsx b/src/OnchainProviders.tsx
index 90bbd027..1b5abbb0 100644
--- a/src/OnchainProviders.tsx
+++ b/src/OnchainProviders.tsx
@@ -1,7 +1,10 @@
'use client';
+import { useEffect } from 'react';
import type { ReactNode } from 'react';
-import { WagmiProvider } from 'wagmi';
+// biome-ignore lint/performance/noNamespaceImport: Sentry SDK requires namespace import
+import * as Sentry from '@sentry/nextjs';
+import { useConnection, WagmiProvider } from 'wagmi';
import { wagmiAdapter } from '@/config/appkit';
import { createWagmiConfig } from '@/store/createWagmiConfig';
import { ConnectRedirectProvider } from './components/providers/ConnectRedirectProvider';
@@ -9,6 +12,26 @@ import { CustomRpcProvider, useCustomRpcContext } from './components/providers/C
type Props = { children: ReactNode };
+function SentryWalletScopeSync() {
+ const { address, chainId, isConnected } = useConnection();
+
+ useEffect(() => {
+ if (isConnected && address) {
+ const truncatedAddress = `${address.slice(0, 6)}...${address.slice(-4)}`;
+ Sentry.setUser({ id: truncatedAddress });
+ Sentry.setTag('wallet_connected', 'true');
+ Sentry.setTag('chain_id', String(chainId));
+ return;
+ }
+
+ Sentry.setUser(null);
+ Sentry.setTag('wallet_connected', 'false');
+ Sentry.setTag('chain_id', 'unknown');
+ }, [address, chainId, isConnected]);
+
+ return null;
+}
+
function WagmiConfigProvider({ children }: Props) {
const { customRpcUrls } = useCustomRpcContext();
@@ -22,6 +45,7 @@ function WagmiConfigProvider({ children }: Props) {
config={wagmiConfig}
reconnectOnMount
>
+
{children}
);
diff --git a/src/components/providers/QueryProvider.tsx b/src/components/providers/QueryProvider.tsx
index dd307f7f..e74601d7 100644
--- a/src/components/providers/QueryProvider.tsx
+++ b/src/components/providers/QueryProvider.tsx
@@ -1,15 +1,108 @@
'use client';
import type { ReactNode } from 'react';
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MutationCache, QueryCache, QueryClient, QueryClientProvider, type QueryKey } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
+import { reportHandledError } from '@/utils/sentry';
type QueryProviderProps = {
children: ReactNode;
};
+const ACTIONABLE_QUERY_ROOT_KEYS = new Set([
+ 'all-position-snapshots',
+ 'enhanced-positions',
+ 'fresh-markets-state',
+ 'historicalSupplierPositions',
+ 'marketData',
+ 'marketLiquidations',
+ 'market-metrics',
+ 'markets',
+ 'monarch-transactions',
+ 'merkl-campaigns',
+ 'morpho-market-v1-adapters',
+ 'oracle-data',
+ 'oracle-metadata',
+ 'morpho-vaults',
+ 'public-allocator-vaults',
+ 'tokenPrices',
+ 'majorPrices',
+ 'positions',
+ 'tokens',
+ 'user-vaults-v2',
+ 'user-rewards',
+ 'user-transactions',
+ 'vault-historical-apy',
+ 'vault-v2-data',
+ 'vault-allocations',
+]);
+
+const TRANSACTION_MUTATION_ROOT_KEYS = new Set([
+ 'sendTransaction',
+]);
+
+const getQueryRootKey = (queryKey: QueryKey): string => {
+ const root = queryKey[0];
+ return typeof root === 'string' ? root : String(root);
+};
+
// Create a single QueryClient with merged configuration from both previous clients
const queryClient = new QueryClient({
+ queryCache: new QueryCache({
+ onError: (error, query) => {
+ // Skip background refetch failures when stale data is already available.
+ // These usually do not break UX and create noisy events.
+ if (query.state.data !== undefined) {
+ return;
+ }
+
+ const rootKey = getQueryRootKey(query.queryKey);
+ if (!ACTIONABLE_QUERY_ROOT_KEYS.has(rootKey)) {
+ return;
+ }
+
+ reportHandledError(error, {
+ scope: 'react_query',
+ operation: `query:${rootKey}`,
+ level: 'error',
+ tags: {
+ query_root_key: rootKey,
+ query_failure_count: query.state.fetchFailureCount,
+ },
+ extras: {
+ query_key: query.queryKey,
+ query_failure_reason:
+ query.state.fetchFailureReason instanceof Error
+ ? query.state.fetchFailureReason.message
+ : String(query.state.fetchFailureReason),
+ },
+ });
+ },
+ }),
+ mutationCache: new MutationCache({
+ onError: (error, _variables, _context, mutation) => {
+ const mutationKey = mutation.options.mutationKey;
+ const rootKey = Array.isArray(mutationKey) ? getQueryRootKey(mutationKey) : 'unknown';
+
+ // Transaction mutation failures are reported in `useTransactionWithToast`.
+ // Skip them here to avoid duplicate telemetry events.
+ if (TRANSACTION_MUTATION_ROOT_KEYS.has(rootKey)) {
+ return;
+ }
+
+ reportHandledError(error, {
+ scope: 'react_query_mutation',
+ operation: `mutation:${rootKey}`,
+ level: 'error',
+ tags: {
+ mutation_root_key: rootKey,
+ },
+ extras: {
+ mutation_key: mutationKey ?? null,
+ },
+ });
+ },
+ }),
defaultOptions: {
queries: {
// From ClientProviders - good for caching and UX
diff --git a/src/hooks/queries/useUserRewardsQuery.ts b/src/hooks/queries/useUserRewardsQuery.ts
index e9eba029..146b6bc5 100644
--- a/src/hooks/queries/useUserRewardsQuery.ts
+++ b/src/hooks/queries/useUserRewardsQuery.ts
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { Address } from 'viem';
import { merklClient } from '@/utils/merklApi';
+import { reportHandledError } from '@/utils/sentry';
import type { RewardResponseType } from '@/utils/types';
import { URLS } from '@/utils/urls';
import { ALL_SUPPORTED_NETWORKS } from '@/utils/networks';
@@ -152,10 +153,20 @@ async function fetchAllRewards(userAddress: string): Promise {
const [morphoData, merklData] = await Promise.all([
fetchMorphoRewards(userAddress).catch((err) => {
console.error('Morpho rewards fetch failed:', err);
+ reportHandledError(err, {
+ scope: 'user_rewards',
+ operation: 'fetch:morpho',
+ level: 'warning',
+ });
return { rewards: [] as RewardResponseType[], distributions: [] as DistributionResponseType[] };
}),
fetchMerklRewards(userAddress).catch((err) => {
console.error('Merkl rewards fetch failed:', err);
+ reportHandledError(err, {
+ scope: 'user_rewards',
+ operation: 'fetch:merkl',
+ level: 'warning',
+ });
return { rewards: [] as RewardResponseType[], rewardsWithProofs: [] as MerklRewardWithProofs[] };
}),
]);
diff --git a/src/hooks/useTransactionWithToast.tsx b/src/hooks/useTransactionWithToast.tsx
index d4095423..5ef2b3c7 100644
--- a/src/hooks/useTransactionWithToast.tsx
+++ b/src/hooks/useTransactionWithToast.tsx
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from 'react';
import { toast } from 'react-toastify';
import { useSendTransaction, useWaitForTransactionReceipt } from 'wagmi';
import { StyledToast, TransactionToast } from '@/components/ui/styled-toast';
+import { reportHandledError } from '@/utils/sentry';
import { getExplorerTxURL } from '../utils/external';
import type { SupportedNetworks } from '../utils/networks';
@@ -16,6 +17,15 @@ type UseTransactionWithToastProps = {
onSuccess?: () => void;
};
+const MAX_TOAST_MESSAGE_LENGTH = 160;
+
+const truncateToastMessage = (message: string): string => {
+ if (message.length <= MAX_TOAST_MESSAGE_LENGTH) {
+ return message;
+ }
+ return `${message.slice(0, MAX_TOAST_MESSAGE_LENGTH - 3)}...`;
+};
+
export function useTransactionWithToast({
toastId,
pendingText,
@@ -27,11 +37,13 @@ export function useTransactionWithToast({
onSuccess,
}: UseTransactionWithToastProps) {
const { data: hash, mutate: sendTransaction, error: txError, mutateAsync: sendTransactionAsync } = useSendTransaction();
+ const reportedErrorKeyRef = useRef(null);
const {
isLoading: isConfirming,
isSuccess: isConfirmed,
isError,
+ error: receiptError,
} = useWaitForTransactionReceipt({
hash,
chainId: chainId,
@@ -93,11 +105,35 @@ export function useTransactionWithToast({
}
}
if (isError || txError) {
+ const errorToReport = txError ?? receiptError ?? new Error('Transaction failed while waiting for confirmation');
+ const reportKey = `${toastId}:${hash ?? 'no_hash'}:${errorToReport.message}`;
+
+ if (reportedErrorKeyRef.current !== reportKey) {
+ reportHandledError(errorToReport, {
+ scope: 'transaction',
+ operation: pendingText,
+ level: 'error',
+ tags: {
+ tx_toast_id: toastId,
+ tx_chain_id: chainId ?? 'unknown',
+ tx_has_hash: Boolean(hash),
+ },
+ extras: {
+ tx_hash: hash ?? null,
+ tx_error_message: errorToReport.message,
+ tx_error_name: errorToReport.name,
+ },
+ });
+ reportedErrorKeyRef.current = reportKey;
+ }
+
+ const errorMessage = (txError ?? receiptError)?.message ?? 'Transaction Failed';
+
toast.update(toastId, {
render: (
),
type: 'error',
@@ -106,8 +142,23 @@ export function useTransactionWithToast({
onClick,
closeButton: true,
});
+ } else {
+ reportedErrorKeyRef.current = null;
}
- }, [hash, isConfirmed, isError, txError, successText, successDescription, errorText, toastId, onClick]);
+ }, [
+ hash,
+ isConfirmed,
+ isError,
+ txError,
+ receiptError,
+ successText,
+ successDescription,
+ errorText,
+ toastId,
+ onClick,
+ chainId,
+ pendingText,
+ ]);
return { sendTransactionAsync, sendTransaction, isConfirming, isConfirmed };
}
diff --git a/src/utils/sentry-server.ts b/src/utils/sentry-server.ts
new file mode 100644
index 00000000..c6088978
--- /dev/null
+++ b/src/utils/sentry-server.ts
@@ -0,0 +1,23 @@
+import 'server-only';
+import { reportHandledError } from '@/utils/sentry';
+
+type ApiRouteErrorContext = {
+ route: string;
+ method: string;
+ status?: number;
+ extras?: Record;
+};
+
+export const reportApiRouteError = (error: unknown, context: ApiRouteErrorContext): void => {
+ reportHandledError(error, {
+ scope: 'api_route',
+ operation: `${context.method} ${context.route}`,
+ level: 'error',
+ tags: {
+ api_route: context.route,
+ api_method: context.method,
+ api_status: context.status ?? 500,
+ },
+ extras: context.extras,
+ });
+};
diff --git a/src/utils/sentry.ts b/src/utils/sentry.ts
new file mode 100644
index 00000000..dce08f48
--- /dev/null
+++ b/src/utils/sentry.ts
@@ -0,0 +1,108 @@
+// biome-ignore lint/performance/noNamespaceImport: Sentry SDK requires namespace import
+import * as Sentry from '@sentry/nextjs';
+
+type SentryTagValue = string | number | boolean;
+type SentryLevel = 'fatal' | 'error' | 'warning' | 'log' | 'info' | 'debug';
+
+export type HandledErrorContext = {
+ scope: string;
+ operation?: string;
+ level?: SentryLevel;
+ tags?: Record;
+ extras?: Record;
+ fingerprint?: string[];
+};
+
+const toError = (error: unknown): Error => {
+ if (error instanceof Error) {
+ return error;
+ }
+ if (typeof error === 'string') {
+ return new Error(error);
+ }
+ return new Error('Unknown error');
+};
+
+const getErrorMessage = (error: unknown): string => {
+ if (error instanceof Error) {
+ return error.message;
+ }
+ if (typeof error === 'string') {
+ return error;
+ }
+ return '';
+};
+
+export const isUserRejectedError = (error: unknown): boolean => {
+ const message = getErrorMessage(error).toLowerCase();
+ if (!message) {
+ return false;
+ }
+ if (message.includes('user rejected') || message.includes('rejected by user')) {
+ return true;
+ }
+ if (message.includes('user denied') || message.includes('request rejected')) {
+ return true;
+ }
+ if (message.includes('denied transaction signature')) {
+ return true;
+ }
+ if (message.includes('action_rejected')) {
+ return true;
+ }
+ if (message.includes(' 4001') || message.includes('error 4001') || message.includes('code 4001')) {
+ return true;
+ }
+ if (message.includes('request rejected') || message.includes('request denied')) {
+ return true;
+ }
+ if (message.includes('signing rejected') || message.includes('signature rejected')) {
+ return true;
+ }
+ if (message.includes('user canceled') || message.includes('user cancelled')) {
+ return true;
+ }
+ return false;
+};
+
+export const reportHandledError = (error: unknown, context: HandledErrorContext): void => {
+ if (isUserRejectedError(error)) {
+ return;
+ }
+
+ const normalizedError = toError(error);
+
+ Sentry.withScope((scope) => {
+ scope.setTag('handled_error', 'true');
+ scope.setTag('handled_error_scope', context.scope);
+
+ if (context.operation) {
+ scope.setTag('handled_error_operation', context.operation);
+ }
+
+ if (context.level) {
+ scope.setLevel(context.level);
+ }
+
+ if (context.fingerprint) {
+ scope.setFingerprint(context.fingerprint);
+ }
+
+ if (context.tags) {
+ for (const [key, value] of Object.entries(context.tags)) {
+ if (value === null || value === undefined) {
+ continue;
+ }
+ scope.setTag(key, String(value));
+ }
+ }
+
+ if (context.extras) {
+ for (const [key, value] of Object.entries(context.extras)) {
+ scope.setExtra(key, value);
+ }
+ }
+
+ Sentry.captureException(normalizedError);
+ });
+};