diff --git a/app/global.css b/app/global.css
index 7eef9a2f..c7e9c0e3 100644
--- a/app/global.css
+++ b/app/global.css
@@ -513,3 +513,15 @@ pre {
border-radius: 20px;
border: transparent;
}
+
+/* Accessibility: Respect user's preference for reduced motion */
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+}
diff --git a/src/components/Avatar/Avatar.tsx b/src/components/Avatar/Avatar.tsx
index 498b8197..8a8a3d57 100644
--- a/src/components/Avatar/Avatar.tsx
+++ b/src/components/Avatar/Avatar.tsx
@@ -1,7 +1,6 @@
-import { useEffect, useState } from 'react';
+import { useState } from 'react';
import Image from 'next/image';
import type { Address } from 'viem';
-import { getMorphoAddress } from '@/utils/morpho';
type AvatarProps = {
address: Address;
@@ -10,33 +9,20 @@ type AvatarProps = {
};
export function Avatar({ address, size = 30, rounded = true }: AvatarProps) {
- const [useEffigy, setUseEffigy] = useState(true);
+ const [effigyErrorAddress, setEffigyErrorAddress] = useState
(null);
+ const effigyActive = effigyErrorAddress !== address;
const effigyUrl = `https://effigy.im/a/${address}.svg`;
const dicebearUrl = `https://api.dicebear.com/7.x/pixel-art/png?seed=${address}`;
- useEffect(() => {
- const checkEffigyAvailability = async () => {
- const effigyMockurl = `https://effigy.im/a/${getMorphoAddress(1)}.png`;
- try {
- const response = await fetch(effigyMockurl, { method: 'HEAD' });
- setUseEffigy(response.ok);
- } catch (_error) {
- setUseEffigy(false);
- }
- };
-
- void checkEffigyAvailability();
- }, []);
-
return (
setUseEffigy(false)}
+ onError={() => setEffigyErrorAddress(address)}
/>
);
diff --git a/src/data-sources/morpho-api/market.ts b/src/data-sources/morpho-api/market.ts
index 16a57a8a..4fe936f9 100644
--- a/src/data-sources/morpho-api/market.ts
+++ b/src/data-sources/morpho-api/market.ts
@@ -84,24 +84,17 @@ export const fetchMorphoMarkets = async (network: SupportedNetworks): Promise(marketsQuery, variables);
- // Handle NOT_FOUND - break pagination loop
- if (!response) {
- console.warn(`No markets found in Morpho API for network ${network} at skip ${skip}.`);
- break;
- }
-
- if (!response.data || !response.data.markets) {
- console.warn(`Market data not found in Morpho API response for network ${network} at skip ${skip}.`);
- break;
+ // Handle failed pages - skip to next page instead of breaking entirely
+ // This handles corrupted market records that cause NOT_FOUND errors
+ if (!response || !response.data?.markets?.items || !response.data.markets.pageInfo) {
+ console.warn(`[Markets] Skipping failed page at skip=${skip} for network ${network}`);
+ skip += pageSize; // Skip ahead to next page
+ if (totalCount > 0 && skip >= totalCount) break;
+ continue;
}
const { items, pageInfo } = response.data.markets;
- if (!items || !Array.isArray(items) || !pageInfo) {
- console.warn(`No market items or page info found in response for network ${network} at skip ${skip}.`);
- break;
- }
-
// Process and add markets to the collection
const processedMarkets = items.map(processMarketData);
allMarkets.push(...processedMarkets);
diff --git a/src/features/autovault/components/vault-detail/settings/EditMetadata.tsx b/src/features/autovault/components/vault-detail/settings/EditMetadata.tsx
index a3cb197f..0e530e1a 100644
--- a/src/features/autovault/components/vault-detail/settings/EditMetadata.tsx
+++ b/src/features/autovault/components/vault-detail/settings/EditMetadata.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useCallback, useEffect, useId, useState } from 'react';
+import { useCallback, useId, useState, useRef, useEffect } from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
@@ -36,30 +36,54 @@ export function EditMetadata({
const previousName = currentName.trim();
const previousSymbol = currentSymbol.trim();
- const [nameInput, setNameInput] = useState(previousName !== '' ? previousName : defaultName);
- const [symbolInput, setSymbolInput] = useState(previousSymbol !== '' ? previousSymbol : defaultSymbol);
+ // Track if user has edited each field
+ const nameEdited = useRef(false);
+ const symbolEdited = useRef(false);
+
+ const [nameInput, setNameInput] = useState('');
+ const [symbolInput, setSymbolInput] = useState('');
const [metadataError, setMetadataError] = useState(null);
+ // Compute values during render - use default if not edited, otherwise use stored value
+ const computedNameInput = nameEdited.current ? nameInput : (previousName !== '' ? previousName : defaultName);
+ const computedSymbolInput = symbolEdited.current ? symbolInput : (previousSymbol !== '' ? previousSymbol : defaultSymbol);
+
+ const handleNameChange = useCallback((value: string) => {
+ nameEdited.current = true;
+ setNameInput(value);
+ }, []);
+
+ const handleSymbolChange = useCallback((value: string) => {
+ symbolEdited.current = true;
+ setSymbolInput(value);
+ }, []);
+
const { needSwitchChain, switchToNetwork } = useMarketNetwork({
targetChainId: chainId,
});
- // Reset inputs when current values change
- useEffect(() => {
- setNameInput(previousName !== '' ? previousName : defaultName);
- setSymbolInput(previousSymbol !== '' ? previousSymbol : defaultSymbol);
- }, [previousName, previousSymbol, defaultName, defaultSymbol]);
-
- const trimmedName = nameInput.trim();
- const trimmedSymbol = symbolInput.trim();
- const metadataChanged = trimmedName !== previousName || trimmedSymbol !== previousSymbol;
-
// Clear error when inputs change
useEffect(() => {
- if (metadataError && metadataChanged) {
+ if (metadataError) {
setMetadataError(null);
}
- }, [metadataChanged, metadataError]);
+ }, [computedNameInput, computedSymbolInput, metadataError]);
+
+ // Reset name edit state when upstream name changes
+ useEffect(() => {
+ nameEdited.current = false;
+ setNameInput('');
+ }, [previousName]);
+
+ // Reset symbol edit state when upstream symbol changes
+ useEffect(() => {
+ symbolEdited.current = false;
+ setSymbolInput('');
+ }, [previousSymbol]);
+
+ const trimmedName = computedNameInput.trim();
+ const trimmedSymbol = computedSymbolInput.trim();
+ const metadataChanged = trimmedName !== previousName || trimmedSymbol !== previousSymbol;
const handleMetadataSubmit = useCallback(async () => {
if (!metadataChanged) {
@@ -96,8 +120,8 @@ export function EditMetadata({
setNameInput(event.target.value)}
+ value={computedNameInput}
+ onChange={(event) => handleNameChange(event.target.value)}
placeholder={defaultName}
disabled={!isOwner}
id={nameInputId}
@@ -117,8 +141,8 @@ export function EditMetadata({
setSymbolInput(event.target.value)}
+ value={computedSymbolInput}
+ onChange={(event) => handleSymbolChange(event.target.value)}
placeholder={defaultSymbol}
maxLength={16}
disabled={!isOwner}
diff --git a/src/features/market-detail/components/borrowers-table.tsx b/src/features/market-detail/components/borrowers-table.tsx
index a4d088e1..96b6c734 100644
--- a/src/features/market-detail/components/borrowers-table.tsx
+++ b/src/features/market-detail/components/borrowers-table.tsx
@@ -40,10 +40,15 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen
const hasActiveFilter = minShares !== '0';
const tableKey = `borrowers-table-${currentPage}`;
- // Calculate LTV for each borrower
+ // Calculate LTV and Days to Liquidation for each borrower
// LTV = borrowAssets / (collateral * oraclePrice)
- const borrowersWithLTV = useMemo(() => {
- if (!oraclePrice || oraclePrice === 0n) return [];
+ // Days to Liquidation = ln(lltv/ltv) / ln(1 + borrowApy) * 365
+ // (using continuous compounding: r = ln(1 + APY) to convert annual APY to continuous rate)
+ const borrowersWithMetrics = useMemo(() => {
+ if (!oraclePrice) return [];
+
+ const lltv = Number(market.lltv) / 1e16; // lltv in WAD format (e.g., 8e17 = 80%)
+ const borrowApy = market.state.borrowApy;
return borrowers.map((borrower) => {
const borrowAssets = BigInt(borrower.borrowAssets);
@@ -54,18 +59,29 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen
const collateralValueInLoan = (collateral * oraclePrice) / BigInt(10 ** 36);
// Calculate LTV as a percentage
- // LTV = (borrowAssets / collateralValue) * 100
let ltv = 0;
if (collateralValueInLoan > 0n) {
ltv = Number((borrowAssets * 10000n) / collateralValueInLoan) / 100;
}
+ // Calculate Days to Liquidation
+ // Only calculate if borrower has position, LTV > 0, and borrow rate > 0
+ let daysToLiquidation: number | null = null;
+ if (ltv > 0 && borrowApy > 0 && lltv > ltv) {
+ // Use continuous compounding: LTV(t) = LTV * e^(r * t) where r = ln(1 + APY)
+ // Solve for t when LTV(t) = lltv: t = ln(lltv/ltv) / r
+ const continuousRate = Math.log(1 + borrowApy);
+ const yearsToLiquidation = Math.log(lltv / ltv) / continuousRate;
+ daysToLiquidation = Math.max(0, Math.round(yearsToLiquidation * 365));
+ }
+
return {
...borrower,
ltv,
+ daysToLiquidation,
};
});
- }, [borrowers, oraclePrice]);
+ }, [borrowers, oraclePrice, market.lltv, market.state.borrowApy]);
return (
@@ -116,26 +132,36 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen
BORROWED
COLLATERAL
LTV
+
DAYS TO LIQ.
% OF BORROW
- {borrowersWithLTV.length === 0 && !isLoading ? (
+ {borrowersWithMetrics.length === 0 && !isLoading ? (
No borrowers found for this market
) : (
- borrowersWithLTV.map((borrower) => {
+ borrowersWithMetrics.map((borrower) => {
const totalBorrow = BigInt(market.state.borrowAssets);
const borrowerAssets = BigInt(borrower.borrowAssets);
const percentOfBorrow = totalBorrow > 0n ? (Number(borrowerAssets) / Number(totalBorrow)) * 100 : 0;
const percentDisplay = percentOfBorrow < 0.01 && percentOfBorrow > 0 ? '<0.01%' : `${percentOfBorrow.toFixed(2)}%`;
+ // Color code days to liquidation
+ const daysDisplay = borrower.daysToLiquidation !== null
+ ? borrower.daysToLiquidation < 30
+ ? `${borrower.daysToLiquidation}`
+ : borrower.daysToLiquidation > 365
+ ? '>365'
+ : `${borrower.daysToLiquidation}`
+ : '—';
+
return (
@@ -175,6 +201,7 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen
{borrower.ltv.toFixed(2)}%
+ {daysDisplay}
{percentDisplay}
);
diff --git a/src/features/market-detail/components/charts/borrowers-pie-chart.tsx b/src/features/market-detail/components/charts/borrowers-pie-chart.tsx
index 6544df1b..bd1ad12a 100644
--- a/src/features/market-detail/components/charts/borrowers-pie-chart.tsx
+++ b/src/features/market-detail/components/charts/borrowers-pie-chart.tsx
@@ -35,6 +35,67 @@ type PieDataItem = {
const TOP_POSITIONS_TO_SHOW = 8;
const OTHER_COLOR = '#64748B'; // Grey for "Other" category
+// Helper function at module scope
+const formatPercentDisplay = (percent: number): string => {
+ if (percent < 0.01 && percent > 0) return '<0.01%';
+ return `${percent.toFixed(2)}%`;
+};
+
+// Custom tooltip at module scope
+function BorrowersPieTooltip({
+ active,
+ payload,
+ expandedOther,
+ market,
+}: {
+ active?: boolean;
+ payload?: { payload: PieDataItem }[];
+ expandedOther: boolean;
+ market: Market;
+}) {
+ if (!active || !payload || !payload[0]) return null;
+ const data = payload[0].payload;
+
+ return (
+
+
{data.name}
+ {!data.isOther &&
{getSlicedAddress(data.address as `0x${string}`)}
}
+
+
+
Borrowed
+
+ {formatSimple(data.value)}
+
+
+
+
+
Collateral
+
+ {formatSimple(data.collateral)}
+
+
+ {!data.isOther && (
+
+ LTV
+ {data.ltv.toFixed(2)}%
+
+ )}
+
+ % of Borrow
+ {formatPercentDisplay(data.percentage)}
+
+
+ {data.isOther &&
Click to {expandedOther ? 'collapse' : 'expand'}
}
+
+ );
+}
+
export function BorrowersPieChart({ chainId, market, oraclePrice }: BorrowersPieChartProps) {
const { data: borrowers, isLoading, totalCount } = useAllMarketBorrowers(market.uniqueKey, chainId);
const { getVaultByAddress } = useVaultRegistry();
@@ -145,63 +206,6 @@ export function BorrowersPieChart({ chainId, market, oraclePrice }: BorrowersPie
// Extract the "Other" entry once for use in expanded section
const otherEntry = useMemo(() => pieData.find((d) => d.isOther), [pieData]);
- // Format percentage display (matches table)
- const formatPercentDisplay = (percent: number): string => {
- if (percent < 0.01 && percent > 0) return '<0.01%';
- return `${percent.toFixed(2)}%`;
- };
-
- const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: { payload: PieDataItem }[] }) => {
- if (!active || !payload || !payload[0]) return null;
- const data = payload[0].payload;
-
- return (
-
-
{data.name}
- {!data.isOther &&
{getSlicedAddress(data.address as `0x${string}`)}
}
-
-
-
Borrowed
-
- {formatSimple(data.value)}
-
-
-
-
-
Collateral
-
- {formatSimple(data.collateral)}
-
-
-
- {!data.isOther && (
-
- LTV
- {data.ltv.toFixed(2)}%
-
- )}
-
- % of Borrow
- {formatPercentDisplay(data.percentage)}
-
-
- {data.isOther &&
Click to {expandedOther ? 'collapse' : 'expand'}
}
-
- );
- };
-
if (isLoading) {
return (
@@ -253,7 +257,7 @@ export function BorrowersPieChart({ chainId, market, oraclePrice }: BorrowersPie
/>
))}
- } />
+ } />