Skip to content
Closed
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
12 changes: 12 additions & 0 deletions app/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
24 changes: 5 additions & 19 deletions src/components/Avatar/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,33 +9,20 @@ type AvatarProps = {
};

export function Avatar({ address, size = 30, rounded = true }: AvatarProps) {
const [useEffigy, setUseEffigy] = useState(true);
const [effigyErrorAddress, setEffigyErrorAddress] = useState<Address | null>(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 (
<div style={{ width: size, height: size }}>
<Image
src={useEffigy ? effigyUrl : dicebearUrl}
src={effigyActive ? effigyUrl : dicebearUrl}
alt={`Avatar for ${address}`}
width={size}
height={size}
style={{ borderRadius: rounded ? '50%' : '5px' }}
onError={() => setUseEffigy(false)}
onError={() => setEffigyErrorAddress(address)}
/>
</div>
);
Expand Down
21 changes: 7 additions & 14 deletions src/data-sources/morpho-api/market.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,24 +84,17 @@ export const fetchMorphoMarkets = async (network: SupportedNetworks): Promise<Ma

const response = await morphoGraphqlFetcher<MarketsGraphQLResponse>(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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string | null>(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) {
Expand Down Expand Up @@ -96,8 +120,8 @@ export function EditMetadata({
</label>
<Input
size="sm"
value={nameInput}
onChange={(event) => setNameInput(event.target.value)}
value={computedNameInput}
onChange={(event) => handleNameChange(event.target.value)}
placeholder={defaultName}
disabled={!isOwner}
id={nameInputId}
Expand All @@ -117,8 +141,8 @@ export function EditMetadata({
</label>
<Input
size="sm"
value={symbolInput}
onChange={(event) => setSymbolInput(event.target.value)}
value={computedSymbolInput}
onChange={(event) => handleSymbolChange(event.target.value)}
placeholder={defaultSymbol}
maxLength={16}
disabled={!isOwner}
Expand Down
43 changes: 35 additions & 8 deletions src/features/market-detail/components/borrowers-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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));
}
Comment on lines +70 to +76
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Borrowers already at or past the liquidation threshold show "—" instead of 0.

When ltv >= lltv, the condition on line 70 fails entirely and daysToLiquidation stays null, producing a "—" in the table — the same display as a borrower with no position. These are actually the most at-risk borrowers.

🐛 Proposed fix
      let daysToLiquidation: number | null = null;
      if (ltv > 0 && borrowApy > 0 && lltv > ltv) {
        const continuousRate = Math.log(1 + borrowApy);
        const yearsToLiquidation = Math.log(lltv / ltv) / continuousRate;
        daysToLiquidation = Math.max(0, Math.round(yearsToLiquidation * 365));
+     } else if (ltv > 0 && lltv <= ltv) {
+       // Already at or past liquidation threshold
+       daysToLiquidation = 0;
      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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));
}
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));
} else if (ltv > 0 && lltv <= ltv) {
// Already at or past liquidation threshold
daysToLiquidation = 0;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/market-detail/components/borrowers-table.tsx` around lines 70 -
76, The current branch only computes daysToLiquidation when ltv>0 && borrowApy>0
&& lltv>ltv, leaving daysToLiquidation null for borrowers already at or beyond
liquidation; update the logic in borrowers-table.tsx so that inside the ltv>0 &&
borrowApy>0 guard you check if lltv>ltv then compute continuousRate = Math.log(1
+ borrowApy) and yearsToLiquidation as before and set daysToLiquidation
accordingly, otherwise set daysToLiquidation = 0 when ltv >= lltv to mark
immediate liquidation risk; keep daysToLiquidation null for cases where
borrowApy <= 0 or ltv <= 0.


return {
...borrower,
ltv,
daysToLiquidation,
};
});
}, [borrowers, oraclePrice]);
}, [borrowers, oraclePrice, market.lltv, market.state.borrowApy]);

return (
<div>
Expand Down Expand Up @@ -116,26 +132,36 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen
<TableHead className="text-right">BORROWED</TableHead>
<TableHead className="text-right">COLLATERAL</TableHead>
<TableHead className="text-right">LTV</TableHead>
<TableHead className="text-right">DAYS TO LIQ.</TableHead>
<TableHead className="text-right">% OF BORROW</TableHead>
</TableRow>
</TableHeader>
<TableBody className="table-body-compact">
{borrowersWithLTV.length === 0 && !isLoading ? (
{borrowersWithMetrics.length === 0 && !isLoading ? (
<TableRow>
<TableCell
colSpan={5}
colSpan={6}
className="text-center text-gray-400"
>
No borrowers found for this market
</TableCell>
</TableRow>
) : (
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 (
<TableRow key={`borrower-${borrower.userAddress}`}>
<TableCell>
Expand Down Expand Up @@ -175,6 +201,7 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen
</div>
</TableCell>
<TableCell className="text-right text-sm">{borrower.ltv.toFixed(2)}%</TableCell>
<TableCell className="text-right text-sm">{daysDisplay}</TableCell>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Color-coding per PR spec is not applied.

The PR description defines explicit thresholds (red < 7 days, orange 7–30, green > 30) but the cell has a static className with no dynamic color. The column reads as plain text for all risk levels.

🎨 Proposed fix
+                  const daysColor =
+                    borrower.daysToLiquidation === null
+                      ? ''
+                      : borrower.daysToLiquidation < 7
+                        ? 'text-red-500'
+                        : borrower.daysToLiquidation < 30
+                          ? 'text-orange-500'
+                          : 'text-green-500';
+
-                  <TableCell className="text-right text-sm">{daysDisplay}</TableCell>
+                  <TableCell className={`text-right text-sm ${daysColor}`}>{daysDisplay}</TableCell>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/market-detail/components/borrowers-table.tsx` at line 204, The
TableCell rendering daysDisplay in borrowers-table.tsx currently uses a static
className; update it to compute the numeric days value used to build daysDisplay
(e.g., days, daysRemaining or whatever variable is used to derive daysDisplay)
and then build a dynamic className that includes "text-right text-sm" plus a
color class based on the thresholds: if days < 7 use the red class (e.g.,
text-red-500), if days is between 7 and 30 inclusive use orange (e.g.,
text-orange-500), and if days > 30 use green (e.g., text-green-500); apply that
computed className to the TableCell that renders daysDisplay so the UI
color-codes per the PR spec.

<TableCell className="text-right text-sm">{percentDisplay}</TableCell>
</TableRow>
);
Expand Down
Loading