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
97 changes: 96 additions & 1 deletion src/features/market-detail/components/suppliers-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { TokenIcon } from '@/components/shared/token-icon';
import { TooltipContent } from '@/components/shared/tooltip-content';
import { MONARCH_PRIMARY } from '@/constants/chartColors';
import { useMarketSuppliers } from '@/hooks/useMarketSuppliers';
import { useSupplierPositionChanges, type SupplierPositionChange } from '@/hooks/useSupplierPositionChanges';
import { formatSimple } from '@/utils/balance';
import type { Market } from '@/utils/types';

Expand All @@ -22,12 +23,90 @@ type SuppliersTableProps = {
onOpenFiltersModal: () => void;
};

type PositionChangeIndicatorProps = {
change: SupplierPositionChange | undefined;
decimals: number;
currentAssets: bigint;
symbol: string;
};

/**
* Displays a 7-day position change indicator with arrow and percentage
*/
function PositionChangeIndicator({ change, decimals, currentAssets, symbol }: PositionChangeIndicatorProps) {
if (!change || change.transactionCount === 0) {
return <span className="text-secondary">−</span>;
}

const netChange = change.netChange;
const isPositive = netChange > 0n;
const isNegative = netChange < 0n;
const isNeutral = netChange === 0n;

// Calculate percentage change relative to current position
// If current position is 0, we can't calculate percentage
let percentChange = 0;
if (currentAssets > 0n && netChange !== 0n) {
// Previous assets = current - net change
const previousAssets = currentAssets - netChange;
if (previousAssets > 0n) {
percentChange = (Number(netChange) / Number(previousAssets)) * 100;
} else if (isPositive) {
// New position entirely from 7d activity
percentChange = 100;
}
}

const absChange = netChange < 0n ? -netChange : netChange;
const formattedChange = formatSimple(Number(formatUnits(absChange, decimals)));
const formattedPercent = Math.abs(percentChange) < 0.01 && percentChange !== 0 ? '<0.01' : Math.abs(percentChange).toFixed(2);

Comment on lines +46 to +63
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check the file structure and get more context around the code
head -80 src/features/market-detail/components/suppliers-table.tsx

Repository: antoncoding/monarch

Length of output: 3002


🏁 Script executed:

# Let's check what decimals value is typically used
grep -n "decimals" src/features/market-detail/components/suppliers-table.tsx | head -20

Repository: antoncoding/monarch

Length of output: 489


🏁 Script executed:

# Check imports and type information
head -30 src/features/market-detail/components/suppliers-table.tsx

Repository: antoncoding/monarch

Length of output: 1349


🏁 Script executed:

node <<'EOF'
// Test JavaScript Number precision with large BigInt values
const testCases = [
  { netChange: 10n ** 18n, previousAssets: 10n ** 18n, desc: "1 token @ 18 decimals" },
  { netChange: 10n ** 24n, previousAssets: 10n ** 24n, desc: "1M tokens @ 18 decimals" },
  { netChange: 5n * 10n ** 23n, previousAssets: 10n ** 24n, desc: "50% change, 1M tokens" },
  { netChange: 123456789n * 10n ** 18n, previousAssets: 987654321n * 10n ** 18n, desc: "Large realistic balances" },
];

console.log("=== Original Approach (loses precision) ===");
testCases.forEach(({ netChange, previousAssets, desc }) => {
  try {
    const original = (Number(netChange) / Number(previousAssets)) * 100;
    console.log(`${desc}: ${original.toFixed(2)}%`);
  } catch (e) {
    console.log(`${desc}: ERROR - ${e.message}`);
  }
});

console.log("\n=== Suggested Approach (basis points) ===");
testCases.forEach(({ netChange, previousAssets, desc }) => {
  try {
    const percentBps = (netChange * 10_000n) / previousAssets;
    const suggested = Number(percentBps) / 100;
    console.log(`${desc}: ${suggested.toFixed(2)}%`);
  } catch (e) {
    console.log(`${desc}: ERROR - ${e.message}`);
  }
});

console.log("\n=== Precision Comparison ===");
const largeNet = 123456789n * 10n ** 18n;
const largePrev = 987654321n * 10n ** 18n;
const original = (Number(largeNet) / Number(largePrev)) * 100;
const percentBps = (largeNet * 10_000n) / largePrev;
const suggested = Number(percentBps) / 100;

console.log(`Original: ${original}`);
console.log(`Suggested: ${suggested}`);
console.log(`Match: ${original === suggested}`);
EOF

Repository: antoncoding/monarch

Length of output: 491


🏁 Script executed:

node <<'EOF'
// Test for overflow/infinity cases
console.log("=== Testing for Overflow to Infinity ===");

// Try extreme values
const extremeCases = [
  { netChange: 10n ** 30n, previousAssets: 10n ** 30n, desc: "10^30 values (extreme)" },
  { netChange: 10n ** 25n, previousAssets: 10n ** 25n, desc: "10^25 values (possible)" },
];

extremeCases.forEach(({ netChange, previousAssets, desc }) => {
  console.log(`\n${desc}:`);
  const numNetChange = Number(netChange);
  const numPrevAssets = Number(previousAssets);
  console.log(`  Number(netChange): ${numNetChange}`);
  console.log(`  Number(previousAssets): ${numPrevAssets}`);
  const original = (numNetChange / numPrevAssets) * 100;
  console.log(`  Original result: ${original}`);
  
  // Suggested approach
  const percentBps = (netChange * 10_000n) / previousAssets;
  const suggested = Number(percentBps) / 100;
  console.log(`  Suggested result: ${suggested}`);
});

console.log("\n=== Max Safe Integer vs BigInt ===");
console.log(`Number.MAX_SAFE_INTEGER: ${Number.MAX_SAFE_INTEGER}`);
console.log(`10^15 (typical max safe range): ${1e15}`);
console.log(`10^20 (1M tokens @ 18 decimals): ${1e20}`);
console.log(`Number(10^20) === Infinity: ${Number(10n ** 20n) === Infinity}`);
console.log(`Number(10^18): ${Number(10n ** 18n)}`);
EOF

Repository: antoncoding/monarch

Length of output: 617


🏁 Script executed:

node <<'EOF'
// Verify the suggested fix handles edge cases correctly
console.log("=== Edge Cases for Suggested Fix ===");

const edgeCases = [
  { netChange: 1n, previousAssets: 100n, desc: "1% change" },
  { netChange: 1n, previousAssets: 10000n, desc: "0.01% change" },
  { netChange: 1n, previousAssets: 1000000n, desc: "0.0001% change" },
  { netChange: 10n ** 18n, previousAssets: 1n, desc: "huge percentage (near infinity)" },
  { netChange: 0n, previousAssets: 100n, desc: "zero change (filtered by code)" },
];

edgeCases.forEach(({ netChange, previousAssets, desc }) => {
  if (netChange === 0n) {
    console.log(`${desc}: skipped (netChange === 0)`);
    return;
  }
  const percentBps = (netChange * 10_000n) / previousAssets;
  const suggested = Number(percentBps) / 100;
  console.log(`${desc}: ${suggested.toFixed(4)}%`);
});

console.log("\n=== Verify Basis Points Math ===");
// 1 basis point = 0.01%, so dividing by 100 gives percentage
const testBps = 5000n; // 5000 basis points = 50%
const testPercent = Number(testBps) / 100;
console.log(`5000 basis points = ${testPercent}%`);

// Reverse: 75% = 7500 basis points
const reverseTest = (75n * 10_000n) / 100n;
console.log(`75% = ${reverseTest} basis points`);
EOF

Repository: antoncoding/monarch

Length of output: 365


Use BigInt for percentage calculation to preserve precision.

The original code converts large BigInt values to Number before dividing, which loses precision. For typical balances (millions of tokens), the precision loss is subtle but real. Compute the division in BigInt space using basis points, then convert only the final result.

Suggested change
-  let percentChange = 0;
-  if (currentAssets > 0n && netChange !== 0n) {
-    // Previous assets = current - net change
-    const previousAssets = currentAssets - netChange;
-    if (previousAssets > 0n) {
-      percentChange = (Number(netChange) / Number(previousAssets)) * 100;
-    } else if (isPositive) {
-      // New position entirely from 7d activity
-      percentChange = 100;
-    }
-  }
+  let percentChange = 0;
+  if (netChange !== 0n) {
+    // Previous assets = current - net change
+    const previousAssets = currentAssets - netChange;
+    if (previousAssets > 0n) {
+      const percentBps = (netChange * 10_000n) / previousAssets; // basis points
+      percentChange = Number(percentBps) / 100;
+    } else if (isPositive) {
+      // New position entirely from 7d activity
+      percentChange = 100;
+    }
+  }
📝 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
// Calculate percentage change relative to current position
// If current position is 0, we can't calculate percentage
let percentChange = 0;
if (currentAssets > 0n && netChange !== 0n) {
// Previous assets = current - net change
const previousAssets = currentAssets - netChange;
if (previousAssets > 0n) {
percentChange = (Number(netChange) / Number(previousAssets)) * 100;
} else if (isPositive) {
// New position entirely from 7d activity
percentChange = 100;
}
}
const absChange = netChange < 0n ? -netChange : netChange;
const formattedChange = formatSimple(Number(formatUnits(absChange, decimals)));
const formattedPercent = Math.abs(percentChange) < 0.01 && percentChange !== 0 ? '<0.01' : Math.abs(percentChange).toFixed(2);
// Calculate percentage change relative to current position
// If current position is 0, we can't calculate percentage
let percentChange = 0;
if (netChange !== 0n) {
// Previous assets = current - net change
const previousAssets = currentAssets - netChange;
if (previousAssets > 0n) {
const percentBps = (netChange * 10_000n) / previousAssets; // basis points
percentChange = Number(percentBps) / 100;
} else if (isPositive) {
// New position entirely from 7d activity
percentChange = 100;
}
}
const absChange = netChange < 0n ? -netChange : netChange;
const formattedChange = formatSimple(Number(formatUnits(absChange, decimals)));
const formattedPercent = Math.abs(percentChange) < 0.01 && percentChange !== 0 ? '<0.01' : Math.abs(percentChange).toFixed(2);

// Color and arrow based on direction
let colorClass = 'text-secondary';
let arrow = '−';
if (isPositive) {
colorClass = 'text-green-500';
arrow = '↑';
} else if (isNegative) {
colorClass = 'text-red-500';
arrow = '↓';
}

const tooltipContent = (
<TooltipContent
title="7d Position Change"
detail={
isNeutral
? 'No net change in the last 7 days'
: `${isPositive ? '+' : '-'}${formattedChange} ${symbol} (${isPositive ? '+' : '-'}${formattedPercent}%)`
}
secondaryDetail={`${change.transactionCount} transaction${change.transactionCount > 1 ? 's' : ''}`}
/>
);

return (
<Tooltip content={tooltipContent}>
<span className={`cursor-help ${colorClass}`}>
{arrow}
{!isNeutral && <span className="ml-0.5">{formattedPercent}%</span>}
</span>
</Tooltip>
);
}

export function SuppliersTable({ chainId, market, minShares, onOpenFiltersModal }: SuppliersTableProps) {
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10;

const { data: paginatedData, isLoading, isFetching } = useMarketSuppliers(market?.uniqueKey, chainId, minShares, currentPage, pageSize);

// Fetch 7-day position changes
const { data: positionChanges, isLoading: isLoadingChanges } = useSupplierPositionChanges(
market?.uniqueKey,
market?.loanAsset?.address,
chainId,
);

const suppliers = paginatedData?.items ?? [];
const totalCount = paginatedData?.totalCount ?? 0;
const totalPages = Math.ceil(totalCount / pageSize);
Expand Down Expand Up @@ -107,14 +186,15 @@ export function SuppliersTable({ chainId, market, minShares, onOpenFiltersModal
<TableRow>
<TableHead className="text-left">ACCOUNT</TableHead>
<TableHead className="text-right">SUPPLIED</TableHead>
<TableHead className="text-right">7D</TableHead>
<TableHead className="text-right">% OF SUPPLY</TableHead>
</TableRow>
</TableHeader>
<TableBody className="table-body-compact">
{suppliersWithAssets.length === 0 && !isLoading ? (
<TableRow>
<TableCell
colSpan={3}
colSpan={4}
className="text-center text-gray-400"
>
No suppliers found for this market
Expand All @@ -127,6 +207,9 @@ export function SuppliersTable({ chainId, market, minShares, onOpenFiltersModal
const percentOfSupply = totalSupply > 0n ? (Number(supplierAssets) / Number(totalSupply)) * 100 : 0;
const percentDisplay = percentOfSupply < 0.01 && percentOfSupply > 0 ? '<0.01%' : `${percentOfSupply.toFixed(2)}%`;

// Get position change for this supplier
const positionChange = positionChanges.get(supplier.userAddress.toLowerCase());

return (
<TableRow key={`supplier-${supplier.userAddress}`}>
<TableCell>
Expand All @@ -151,6 +234,18 @@ export function SuppliersTable({ chainId, market, minShares, onOpenFiltersModal
)}
</div>
</TableCell>
<TableCell className="text-right text-sm">
{isLoadingChanges ? (
<Spinner size={12} />
) : (
<PositionChangeIndicator
change={positionChange}
decimals={market.loanAsset.decimals}
currentAssets={supplierAssets}
symbol={market.loanAsset.symbol}
/>
)}
</TableCell>
<TableCell className="text-right text-sm">{percentDisplay}</TableCell>
</TableRow>
);
Expand Down
8 changes: 2 additions & 6 deletions src/features/markets/components/table/market-table-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,9 +241,7 @@ export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowI
className="z-50 text-center"
style={{ minWidth: '85px', paddingLeft: 3, paddingRight: 3 }}
>
<p className="text-sm">
{item.state.dailySupplyApy != null ? <RateFormatted value={item.state.dailySupplyApy} /> : '—'}
</p>
<p className="text-sm">{item.state.dailySupplyApy != null ? <RateFormatted value={item.state.dailySupplyApy} /> : '—'}</p>
</TableCell>
)}
{columnVisibility.dailyBorrowAPY && (
Expand All @@ -252,9 +250,7 @@ export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowI
className="z-50 text-center"
style={{ minWidth: '85px', paddingLeft: 3, paddingRight: 3 }}
>
<p className="text-sm">
{item.state.dailyBorrowApy != null ? <RateFormatted value={item.state.dailyBorrowApy} /> : '—'}
</p>
<p className="text-sm">{item.state.dailyBorrowApy != null ? <RateFormatted value={item.state.dailyBorrowApy} /> : '—'}</p>
</TableCell>
)}
{columnVisibility.weeklySupplyAPY && (
Expand Down
150 changes: 150 additions & 0 deletions src/hooks/useSupplierPositionChanges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { useQuery } from '@tanstack/react-query';
import { supportsMorphoApi } from '@/config/dataSources';
import { fetchMorphoMarketSupplies } from '@/data-sources/morpho-api/market-supplies';
import { fetchSubgraphMarketSupplies } from '@/data-sources/subgraph/market-supplies';
import type { SupportedNetworks } from '@/utils/networks';
import type { MarketActivityTransaction } from '@/utils/types';

const SEVEN_DAYS_IN_SECONDS = 7 * 24 * 60 * 60;

export type SupplierPositionChange = {
userAddress: string;
netChange: bigint; // positive = net supply, negative = net withdraw
supplyTotal: bigint;
withdrawTotal: bigint;
transactionCount: number;
};

export type SupplierPositionChangesMap = Map<string, SupplierPositionChange>;

/**
* Calculate net position changes from transactions
*/
function calculatePositionChanges(transactions: MarketActivityTransaction[]): SupplierPositionChangesMap {
const changes = new Map<string, SupplierPositionChange>();

for (const tx of transactions) {
const address = tx.userAddress.toLowerCase();
const amount = BigInt(tx.amount);

let existing = changes.get(address);
if (!existing) {
existing = {
userAddress: address,
netChange: 0n,
supplyTotal: 0n,
withdrawTotal: 0n,
transactionCount: 0,
};
}

if (tx.type === 'MarketSupply') {
existing.netChange += amount;
existing.supplyTotal += amount;
} else if (tx.type === 'MarketWithdraw') {
existing.netChange -= amount;
existing.withdrawTotal += amount;
}
existing.transactionCount += 1;

changes.set(address, existing);
}

return changes;
}

/**
* Hook to fetch 7-day supply/withdraw transactions and calculate net position changes per user.
* Returns a map of userAddress (lowercase) -> position change data.
*
* @param marketId The unique key of the market.
* @param loanAssetId The address of the loan asset.
* @param network The blockchain network.
* @returns Map of position changes keyed by lowercase user address.
*/
export const useSupplierPositionChanges = (
marketId: string | undefined,
loanAssetId: string | undefined,
network: SupportedNetworks | undefined,
) => {
const queryKey = ['supplierPositionChanges', marketId, loanAssetId, network];

const queryFn = async (): Promise<SupplierPositionChangesMap> => {
if (!marketId || !loanAssetId || !network) {
return new Map();
}

const sevenDaysAgo = Math.floor(Date.now() / 1000) - SEVEN_DAYS_IN_SECONDS;
const allTransactions: MarketActivityTransaction[] = [];

// Fetch transactions in batches until we have all from the last 7 days
// or reach a reasonable limit
const pageSize = 100;
const maxPages = 10; // Max 1000 transactions
let currentPage = 1;
let hasMore = true;

while (hasMore && currentPage <= maxPages) {
const skip = (currentPage - 1) * pageSize;
let result = null;

// Try Morpho API first if supported
if (supportsMorphoApi(network)) {
try {
result = await fetchMorphoMarketSupplies(marketId, '0', pageSize, skip);
} catch (morphoError) {
console.error('Failed to fetch supplies via Morpho API:', morphoError);
}
}

// Fallback to Subgraph
if (!result) {
try {
result = await fetchSubgraphMarketSupplies(marketId, loanAssetId, network, '0', pageSize, skip);
} catch (subgraphError) {
console.error('Failed to fetch supplies via Subgraph:', subgraphError);
break;
}
}

if (!result || result.items.length === 0) {
hasMore = false;
break;
}

// Filter to only transactions from last 7 days
const recentTransactions = result.items.filter((tx) => tx.timestamp >= sevenDaysAgo);
allTransactions.push(...recentTransactions);

// If oldest transaction in this batch is older than 7 days, we have all we need
const oldestInBatch = result.items.at(-1);
if (oldestInBatch && oldestInBatch.timestamp < sevenDaysAgo) {
hasMore = false;
} else if (result.items.length < pageSize) {
hasMore = false;
} else {
currentPage++;
}
}

return calculatePositionChanges(allTransactions);
};

const { data, isLoading, error, refetch } = useQuery<SupplierPositionChangesMap>({
queryKey,
queryFn,
enabled: !!marketId && !!loanAssetId && !!network,
staleTime: 1000 * 60 * 5, // 5 minutes
placeholderData: () => new Map(),
retry: 1,
});

return {
data: data ?? new Map(),
isLoading,
error,
refetch,
};
};

export default useSupplierPositionChanges;