diff --git a/app/api/chain/currentBlockNumber/route.ts b/app/api/chain/currentBlockNumber/route.ts deleted file mode 100644 index 223058cd..00000000 --- a/app/api/chain/currentBlockNumber/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getChainById } from '@/store/supportedChains'; -import { getRpcProviderForChain } from '@/utils/provider'; - -/** - * Handler for the /api/chain/blockNumber route, this route will return the current block number - * @param req - * @param res - */ -export async function GET(req: NextRequest): Promise { - try { - // Get the Chain Id from the request - const chainId = req.nextUrl.searchParams.get('chainId'); - if (!chainId) { - return NextResponse.json({ error: 'chainid is required' }, { status: 400 }); - } - const chain = getChainById(chainId); - if (!chain) { - return NextResponse.json({ error: 'chain not supported' }, { status: 400 }); - } - const provider = getRpcProviderForChain(chain); - const block = await provider.getBlockNumber(); - return NextResponse.json({ block: block.toString() }, { status: 200 }); - } catch (error) { - console.error('Error fetching chains:', error); - return NextResponse.json({}, { status: 500, statusText: 'Internal Server Error' }); - } -} - -export const dynamic = 'force-dynamic'; diff --git a/app/api/positions/historical/route.ts b/app/api/positions/historical/route.ts new file mode 100644 index 00000000..14283073 --- /dev/null +++ b/app/api/positions/historical/route.ts @@ -0,0 +1,252 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createPublicClient, http, Address } from 'viem'; +import { mainnet, base } from 'viem/chains'; +import morphoABI from '@/abis/morpho'; +import { MORPHO } from '@/utils/morpho'; + +// Initialize Alchemy clients for each chain +const mainnetClient = createPublicClient({ + chain: mainnet, + transport: http(`https://eth-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}`), +}); + +const baseClient = createPublicClient({ + chain: base, + transport: http(`https://base-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}`), +}); + +const BLOCK_TIME = { + 1: 12, // Ethereum mainnet: 12 seconds + 8453: 2, // Base: 2 seconds +} as const; + +type Position = { + supplyShares: bigint; + borrowShares: bigint; + collateral: bigint; +}; + +type Market = { + totalSupplyAssets: bigint; + totalSupplyShares: bigint; + totalBorrowAssets: bigint; + totalBorrowShares: bigint; + lastUpdate: bigint; + fee: bigint; +}; + +function arrayToPosition(arr: readonly bigint[]): Position { + return { + supplyShares: arr[0], + borrowShares: arr[1], + collateral: arr[2], + }; +} + +function arrayToMarket(arr: readonly bigint[]): Market { + return { + totalSupplyAssets: arr[0], + totalSupplyShares: arr[1], + totalBorrowAssets: arr[2], + totalBorrowShares: arr[3], + lastUpdate: arr[4], + fee: arr[5], + }; +} + +function convertSharesToAssets(shares: bigint, totalAssets: bigint, totalShares: bigint): bigint { + if (totalShares === 0n) return 0n; + return (shares * totalAssets) / totalShares; +} + +async function getBlockNumberFromTimestamp(timestamp: number, chainId: number): Promise { + const client = chainId === 1 ? mainnetClient : baseClient; + + // Get current block and its timestamp + const currentBlock = await client.getBlockNumber(); + const currentBlockData = await client.getBlock({ blockNumber: currentBlock }); + const currentTimestamp = Number(currentBlockData.timestamp); + + // Calculate blocks ago based on timestamp difference and block time + const timestampDiff = currentTimestamp - timestamp; + const blockTime = BLOCK_TIME[chainId as keyof typeof BLOCK_TIME] ?? 12; + const blocksAgo = Math.floor(timestampDiff / blockTime); + + // Calculate target block number + const targetBlockNumber = Number(currentBlock) - blocksAgo; + + console.log('Block number calculation:', { + currentBlock: Number(currentBlock), + currentTimestamp, + targetTimestamp: timestamp, + timestampDiff, + blockTime, + blocksAgo, + targetBlockNumber, + }); + + return Math.max(0, targetBlockNumber); +} + +async function getPositionAtBlock( + marketId: string, + userAddress: string, + timestamp: number, + chainId: number, +) { + console.log(`Processing position request for timestamp ${timestamp}`, { + marketId, + userAddress, + timestamp, + chainId, + }); + + // Convert timestamp to block number + const blockNumber = await getBlockNumberFromTimestamp(timestamp, chainId); + console.log(`Estimated block number: ${blockNumber} for timestamp ${timestamp}`); + + const client = chainId === 1 ? mainnetClient : baseClient; + + try { + // Get the actual block to get its precise timestamp + const block = await client.getBlock({ blockNumber: BigInt(blockNumber) }); + console.log(`Retrieved block ${blockNumber}:`, { + timestamp: Number(block.timestamp), + hash: block.hash, + }); + + // First get the position data + const positionArray = (await client.readContract({ + address: MORPHO, + abi: morphoABI, + functionName: 'position', + args: [marketId as `0x${string}`, userAddress as Address], + blockNumber: BigInt(blockNumber), + })) as readonly bigint[]; + + // Convert array to position object + const position = arrayToPosition(positionArray); + + // If position has no shares, return zeros early + if ( + position.supplyShares === 0n && + position.borrowShares === 0n && + position.collateral === 0n + ) { + console.log('Position has no shares, returning zeros'); + return { + supplyShares: '0', + supplyAssets: '0', + borrowShares: '0', + borrowAssets: '0', + collateral: '0', + timestamp: Number(block.timestamp), + }; + } + + // Only fetch market data if position has shares + const marketArray = (await client.readContract({ + address: MORPHO, + abi: morphoABI, + functionName: 'market', + args: [marketId as `0x${string}`], + blockNumber: BigInt(blockNumber), + })) as readonly bigint[]; + + // Convert array to market object + const market = arrayToMarket(marketArray); + + // Convert shares to assets + const supplyAssets = convertSharesToAssets( + position.supplyShares, + market.totalSupplyAssets, + market.totalSupplyShares, + ); + + const borrowAssets = convertSharesToAssets( + position.borrowShares, + market.totalBorrowAssets, + market.totalBorrowShares, + ); + + console.log(`Successfully retrieved position data:`, { + marketId, + userAddress, + blockNumber, + timestamp: Number(block.timestamp), + supplyShares: position.supplyShares.toString(), + supplyAssets: supplyAssets.toString(), + borrowShares: position.borrowShares.toString(), + borrowAssets: borrowAssets.toString(), + collateral: position.collateral.toString(), + market: { + totalSupplyAssets: market.totalSupplyAssets.toString(), + totalSupplyShares: market.totalSupplyShares.toString(), + totalBorrowAssets: market.totalBorrowAssets.toString(), + totalBorrowShares: market.totalBorrowShares.toString(), + }, + }); + + return { + supplyShares: position.supplyShares.toString(), + supplyAssets: supplyAssets.toString(), + borrowShares: position.borrowShares.toString(), + borrowAssets: borrowAssets.toString(), + collateral: position.collateral.toString(), + timestamp: Number(block.timestamp), + }; + } catch (error) { + console.error(`Error reading position:`, { + marketId, + userAddress, + blockNumber, + timestamp, + error, + }); + throw error; + } +} + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const timestamp = parseInt(searchParams.get('timestamp') ?? '0'); + const marketId = searchParams.get('marketId'); + const userAddress = searchParams.get('userAddress'); + const chainId = parseInt(searchParams.get('chainId') ?? '1'); + + console.log(`Historical position request:`, { + timestamp, + marketId, + userAddress, + chainId, + }); + + if (!timestamp || !marketId || !userAddress) { + console.error('Missing required parameters:', { + timestamp: !!timestamp, + marketId: !!marketId, + userAddress: !!userAddress, + }); + return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }); + } + + // Get position data at the specified timestamp + const position = await getPositionAtBlock(marketId, userAddress, timestamp, chainId); + + console.log(`Successfully retrieved historical position data:`, { + timestamp, + marketId, + userAddress, + chainId, + position, + }); + + return NextResponse.json({ + position, + }); + } catch (error) { + console.error('Error in historical position API:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/positions/components/PositionsSummaryTable.tsx b/app/positions/components/PositionsSummaryTable.tsx index 029910a9..445b55f9 100644 --- a/app/positions/components/PositionsSummaryTable.tsx +++ b/app/positions/components/PositionsSummaryTable.tsx @@ -1,9 +1,9 @@ import React, { useMemo, useState, useEffect } from 'react'; -import { Spinner } from '@nextui-org/react'; +import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem } from '@nextui-org/react'; import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons'; import { motion, AnimatePresence } from 'framer-motion'; import Image from 'next/image'; -import { GrRefresh } from 'react-icons/gr'; +import { IoRefreshOutline, IoChevronDownOutline } from 'react-icons/io5'; import { toast } from 'react-toastify'; import { TokenIcon } from '@/components/TokenIcon'; import { formatReadable, formatBalance } from '@/utils/balance'; @@ -17,6 +17,13 @@ import { import { RebalanceModal } from './RebalanceModal'; import { SuppliedMarketsDetail } from './SuppliedMarketsDetail'; +export enum EarningsPeriod { + All = 'all', + Day = '1D', + Week = '7D', + Month = '30D', +} + type PositionsSummaryTableProps = { marketPositions: MarketPosition[]; setShowWithdrawModal: (show: boolean) => void; @@ -39,68 +46,112 @@ export function PositionsSummaryTable({ const [selectedGroupedPosition, setSelectedGroupedPosition] = useState( null, ); + const [earningsPeriod, setEarningsPeriod] = useState(EarningsPeriod.Day); - const groupedPositions: GroupedPosition[] = useMemo(() => { - return marketPositions.reduce((acc: GroupedPosition[], position) => { - const loanAssetAddress = position.market.loanAsset.address; - const loanAssetDecimals = position.market.loanAsset.decimals; - const chainId = position.market.morphoBlue.chain.id; + const getEarningsForPeriod = (position: MarketPosition) => { + if (!position.earned) return '0'; - let groupedPosition = acc.find( - (gp) => gp.loanAssetAddress === loanAssetAddress && gp.chainId === chainId, - ); + switch (earningsPeriod) { + case EarningsPeriod.All: + return position.earned.lifetimeEarned; + case EarningsPeriod.Day: + return position.earned.last24hEarned; + case EarningsPeriod.Week: + return position.earned.last7dEarned; + case EarningsPeriod.Month: + return position.earned.last30dEarned; + default: + return '0'; + } + }; - if (!groupedPosition) { - groupedPosition = { - loanAsset: position.market.loanAsset.symbol || 'Unknown', - loanAssetAddress, - loanAssetDecimals, - chainId, - totalSupply: 0, - totalWeightedApy: 0, - collaterals: [], - markets: [], - processedCollaterals: [], - allWarnings: [], // Initialize allWarnings as an empty array - }; - acc.push(groupedPosition); - } + const getGroupedEarnings = (groupedPosition: GroupedPosition) => { + return ( + groupedPosition.markets + .reduce( + (total, position) => { + const earnings = getEarningsForPeriod(position); + if (earnings === null) return null; + return total === null ? BigInt(earnings) : total + BigInt(earnings); + }, + null as bigint | null, + ) + ?.toString() ?? null + ); + }; - groupedPosition.markets.push(position); + const periodLabels: Record = { + [EarningsPeriod.All]: 'All Time', + [EarningsPeriod.Day]: '1D', + [EarningsPeriod.Week]: '7D', + [EarningsPeriod.Month]: '30D', + }; - // Combine warnings from all markets - groupedPosition.allWarnings = [ - ...new Set([...groupedPosition.allWarnings, ...(position.market.warningsWithDetail || [])]), - ] as WarningWithDetail[]; + const groupedPositions: GroupedPosition[] = useMemo(() => { + return marketPositions + .reduce((acc: GroupedPosition[], position) => { + const loanAssetAddress = position.market.loanAsset.address; + const loanAssetDecimals = position.market.loanAsset.decimals; + const chainId = position.market.morphoBlue.chain.id; - const supplyAmount = Number( - formatBalance(position.supplyAssets, position.market.loanAsset.decimals), - ); - groupedPosition.totalSupply += supplyAmount; + let groupedPosition = acc.find( + (gp) => gp.loanAssetAddress === loanAssetAddress && gp.chainId === chainId, + ); - const weightedApy = supplyAmount * position.market.state.supplyApy; - groupedPosition.totalWeightedApy += weightedApy; + if (!groupedPosition) { + groupedPosition = { + loanAsset: position.market.loanAsset.symbol || 'Unknown', + loanAssetAddress, + loanAssetDecimals, + chainId, + totalSupply: 0, + totalWeightedApy: 0, + collaterals: [], + markets: [], + processedCollaterals: [], + allWarnings: [], + }; + acc.push(groupedPosition); + } + + groupedPosition.markets.push(position); - const collateralAddress = position.market.collateralAsset?.address; - const collateralSymbol = position.market.collateralAsset?.symbol; + groupedPosition.allWarnings = [ + ...new Set([ + ...groupedPosition.allWarnings, + ...(position.market.warningsWithDetail || []), + ]), + ] as WarningWithDetail[]; - if (collateralAddress && collateralSymbol) { - const existingCollateral = groupedPosition.collaterals.find( - (c) => c.address === collateralAddress, + const supplyAmount = Number( + formatBalance(position.supplyAssets, position.market.loanAsset.decimals), ); - if (existingCollateral) { - existingCollateral.amount += supplyAmount; - } else { - groupedPosition.collaterals.push({ - address: collateralAddress, - symbol: collateralSymbol, - amount: supplyAmount, - }); + groupedPosition.totalSupply += supplyAmount; + + const weightedApy = supplyAmount * position.market.state.supplyApy; + groupedPosition.totalWeightedApy += weightedApy; + + const collateralAddress = position.market.collateralAsset?.address; + const collateralSymbol = position.market.collateralAsset?.symbol; + + if (collateralAddress && collateralSymbol) { + const existingCollateral = groupedPosition.collaterals.find( + (c) => c.address === collateralAddress, + ); + if (existingCollateral) { + existingCollateral.amount += supplyAmount; + } else { + groupedPosition.collaterals.push({ + address: collateralAddress, + symbol: collateralSymbol, + amount: supplyAmount, + }); + } } - } - return acc; - }, []); + return acc; + }, []) + .sort((a, b) => b.totalSupply - a.totalSupply); }, [marketPositions]); const processedPositions = useMemo(() => { @@ -133,9 +184,6 @@ export function PositionsSummaryTable({ }); }, [groupedPositions]); - console.log('processedPositions', processedPositions); - - // Update selectedGroupedPosition when groupedPositions change, don't depend on selectedGroupedPosition useEffect(() => { if (selectedGroupedPosition) { const updatedPosition = processedPositions.find( @@ -147,7 +195,6 @@ export function PositionsSummaryTable({ setSelectedGroupedPosition(updatedPosition); } } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [processedPositions]); const toggleRow = (rowKey: string) => { @@ -168,147 +215,185 @@ export function PositionsSummaryTable({ return (
-
-
-

Your Supply

- {isRefetching && } -
+
+ + + + + setEarningsPeriod(key as EarningsPeriod)} + > + {Object.entries(periodLabels).map(([period, label]) => ( + {label} + ))} + +
- - - - - - - - - - - - - {processedPositions.map((groupedPosition) => { - const rowKey = `${groupedPosition.loanAssetAddress}-${groupedPosition.chainId}`; - const isExpanded = expandedRows.has(rowKey); - const avgApy = groupedPosition.totalWeightedApy / groupedPosition.totalSupply; +
+
- NetworkSizeAvg APYCollateral ExposureWarningsActions
+ + + + + + + + + + + + + {processedPositions.map((groupedPosition) => { + const rowKey = `${groupedPosition.loanAssetAddress}-${groupedPosition.chainId}`; + const isExpanded = expandedRows.has(rowKey); + const avgApy = groupedPosition.totalWeightedApy / groupedPosition.totalSupply; + + const earnings = getGroupedEarnings(groupedPosition); - return ( - - toggleRow(rowKey)}> - - - - - - - - - - {expandedRows.has(rowKey) && ( - - toggleRow(rowKey)}> + + + + + + + + - - )} - - - ); - })} - -
+ NetworkSizeAPY (now)Interest Accrued ({earningsPeriod})CollateralWarningsActions
- {isExpanded ? : } - -
- {`Chain -
-
-
- - {formatReadable(groupedPosition.totalSupply)} - - {groupedPosition.loanAsset} - -
-
-
{formatReadable(avgApy * 100)}%
-
-
- {groupedPosition.collaterals.length > 0 ? ( - groupedPosition.collaterals.map((collateral, index) => ( - - )) - ) : ( - No known collaterals - )} -
-
-
- - - -
-
-
- -
-
- +
+ {isExpanded ? : } + +
+ {`Chain +
+
+
+ + {formatReadable(groupedPosition.totalSupply)} + + {groupedPosition.loanAsset} + +
+
+
+ {formatReadable(avgApy * 100)}% +
+
+
+ + {(() => { + if (earnings === null) return '-'; + return ( + formatReadable( + Number(formatBalance(earnings, groupedPosition.loanAssetDecimals)), + ) + + ' ' + + groupedPosition.loanAsset + ); + })()} + +
+
+
+ {groupedPosition.collaterals.length > 0 ? ( + groupedPosition.collaterals.map((collateral, index) => ( + + )) + ) : ( + No known collaterals + )} +
+
+
+ + + +
+
+
+
+ Rebalance + +
+ + + + {expandedRows.has(rowKey) && ( + + + + + + + + )} + + + ); + })} + + +
{showRebalanceModal && selectedGroupedPosition && ( void; @@ -43,7 +42,8 @@ export function SuppliedMarketsDetail({ setShowSupplyModal, setSelectedPosition, }: SuppliedMarketsDetailProps) { - const sortedMarkets = [...groupedPosition.markets].sort( + // Sort active markets by size + const sortedActiveMarkets = [...groupedPosition.markets].sort( (a, b) => Number(formatBalance(b.supplyAssets, b.market.loanAsset.decimals)) - Number(formatBalance(a.supplyAssets, a.market.loanAsset.decimals)), @@ -59,13 +59,13 @@ export function SuppliedMarketsDetail({ return ( -
+

Collateral Exposure

@@ -104,6 +104,8 @@ export function SuppliedMarketsDetail({
+ + {/* Markets Table - Always visible */} @@ -118,7 +120,7 @@ export function SuppliedMarketsDetail({ - {sortedMarkets.map((position) => { + {sortedActiveMarkets.map((position) => { const suppliedAmount = Number( formatBalance(position.supplyAssets, position.market.loanAsset.decimals), ); @@ -146,14 +148,12 @@ export function SuppliedMarketsDetail({
)}
- {/* */} {position.market.uniqueKey.slice(2, 8)} - {/* */}
@@ -173,7 +173,10 @@ export function SuppliedMarketsDetail({
- +
diff --git a/next.config.js b/next.config.js index 88983259..d8d54fa8 100644 --- a/next.config.js +++ b/next.config.js @@ -10,6 +10,14 @@ const nextConfig = { protocol: 'https', hostname: 'ipfs.io', }, + { + protocol: 'https', + hostname: 'effigy.im', + }, + { + protocol: 'https', + hostname: 'api.dicebear.com', + }, ], }, }; diff --git a/src/components/Avatar/Avatar.tsx b/src/components/Avatar/Avatar.tsx index b3b4e5e0..18012707 100644 --- a/src/components/Avatar/Avatar.tsx +++ b/src/components/Avatar/Avatar.tsx @@ -1,15 +1,41 @@ +import { useEffect, useState } from 'react'; import Image from 'next/image'; import { Address } from 'viem'; +import { MORPHO } from '@/utils/morpho'; + +type AvatarProps = { + address: Address; + size?: number; +}; + +export function Avatar({ address, size = 30 }: AvatarProps) { + const [useEffigy, setUseEffigy] = useState(true); + 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/${MORPHO}.png`; + try { + const response = await fetch(effigyMockurl, { method: 'HEAD' }); + setUseEffigy(response.ok); + } catch (error) { + setUseEffigy(false); + } + }; + + void checkEffigyAvailability(); + }, []); -export function Avatar({ address }: { address: Address }) { return ( -
+
{address} setUseEffigy(false)} />
); diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index 656ec634..4a68b406 100644 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -72,6 +72,12 @@ export const marketFragment = ` amountPerSuppliedToken amountPerBorrowedToken } + monthlySupplyApy + monthlyBorrowApy + dailySupplyApy + dailyBorrowApy + weeklySupplyApy + weeklyBorrowApy } dailyApys { netSupplyApy diff --git a/src/hooks/usePositionSnapshot.ts b/src/hooks/usePositionSnapshot.ts new file mode 100644 index 00000000..90783f70 --- /dev/null +++ b/src/hooks/usePositionSnapshot.ts @@ -0,0 +1,83 @@ +import { useCallback } from 'react'; +import { Address } from 'viem'; + +export type PositionSnapshot = { + supplyAssets: string; + supplyShares: string; + borrowAssets: string; + borrowShares: string; + timestamp: number; +}; + +type ApiResponse = { + position: { + supplyAssets: string; + supplyShares: string; + borrowAssets: string; + borrowShares: string; + timestamp: number; + } | null; +}; + +export function usePositionSnapshot() { + const fetchPositionSnapshot = useCallback( + async ( + marketId: string, + userAddress: Address, + chainId: number, + timestamp: number, + ): Promise => { + try { + console.log('Fetching position snapshot...', { + marketId, + userAddress, + timestamp, + chainId, + }); + + const response = await fetch( + `/api/positions/historical?` + + `marketId=${encodeURIComponent(marketId)}` + + `&userAddress=${encodeURIComponent(userAddress)}` + + `×tamp=${encodeURIComponent(timestamp)}` + + `&chainId=${encodeURIComponent(chainId)}`, + ); + + if (!response.ok) { + const errorData = (await response.json()) as { error?: string }; + console.error('Failed to fetch position snapshot:', errorData); + return null; + } + + const data = (await response.json()) as ApiResponse; + + // If position is empty, return zeros + if (!data.position) { + return { + supplyAssets: '0', + supplyShares: '0', + borrowAssets: '0', + borrowShares: '0', + timestamp: timestamp, + }; + } + + console.log('Position snapshot response:', data); + + return { + supplyAssets: data.position.supplyAssets, + supplyShares: data.position.supplyShares, + borrowAssets: data.position.borrowAssets, + borrowShares: data.position.borrowShares, + timestamp: data.position.timestamp, + }; + } catch (error) { + console.error('Error fetching position snapshot:', error); + return null; + } + }, + [], + ); + + return { fetchPositionSnapshot }; +} diff --git a/src/hooks/useUserPositions.ts b/src/hooks/useUserPositions.ts index a03eea0f..c58c8210 100644 --- a/src/hooks/useUserPositions.ts +++ b/src/hooks/useUserPositions.ts @@ -1,10 +1,40 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { useState, useEffect, useCallback } from 'react'; +import { Address } from 'viem'; import { userPositionsQuery } from '@/graphql/queries'; import { SupportedNetworks } from '@/utils/networks'; -import { MarketPosition, UserTransaction } from '@/utils/types'; +import { MarketPosition, UserTransaction, UserTxTypes } from '@/utils/types'; import { getMarketWarningsWithDetail } from '@/utils/warnings'; +import { usePositionSnapshot } from './usePositionSnapshot'; + +export type PositionEarnings = { + lifetimeEarned: string; + last24hEarned: string | null; + last7dEarned: string | null; + last30dEarned: string | null; +}; + +export function calculateEarningsFromSnapshot( + currentBalance: bigint, + snapshotBalance: bigint, + transactions: UserTransaction[], + timestamp: number, +): string { + // Get transactions after snapshot timestamp + const txsAfterSnapshot = transactions.filter((tx) => Number(tx.timestamp) > timestamp); + + const depositsAfter = txsAfterSnapshot + .filter((tx) => tx.type === UserTxTypes.MarketSupply) + .reduce((sum, tx) => sum + BigInt(tx.data?.assets || '0'), 0n); + + const withdrawsAfter = txsAfterSnapshot + .filter((tx) => tx.type === UserTxTypes.MarketWithdraw) + .reduce((sum, tx) => sum + BigInt(tx.data?.assets || '0'), 0n); + + const earned = currentBalance + withdrawsAfter - (snapshotBalance + depositsAfter); + return earned.toString(); +} const useUserPositions = (user: string | undefined) => { const [loading, setLoading] = useState(true); @@ -13,9 +43,78 @@ const useUserPositions = (user: string | undefined) => { const [history, setHistory] = useState([]); const [error, setError] = useState(null); + const { fetchPositionSnapshot } = usePositionSnapshot(); + + const calculateEarningsFromPeriod = async ( + position: MarketPosition, + transactions: UserTransaction[], + userAddress: Address, + chainId: number, + ) => { + const currentBalance = BigInt(position.supplyAssets); + const marketId = position.market.uniqueKey; + + // Filter transactions for this specific market + const marketTxs = transactions.filter((tx) => tx.data?.market?.uniqueKey === marketId); + + // Calculate lifetime earnings using all transactions + const totalDeposits = marketTxs + .filter((tx) => tx.type === UserTxTypes.MarketSupply) + .reduce((sum, tx) => sum + BigInt(tx.data?.assets || '0'), 0n); + + const totalWithdraws = marketTxs + .filter((tx) => tx.type === UserTxTypes.MarketWithdraw) + .reduce((sum, tx) => sum + BigInt(tx.data?.assets || '0'), 0n); + + const lifetimeEarned = currentBalance + totalWithdraws - totalDeposits; + + // Get historical snapshots + const now = Math.floor(Date.now() / 1000); + const snapshots = await Promise.all([ + fetchPositionSnapshot(marketId, userAddress, chainId, now - 24 * 60 * 60), // 24h ago + fetchPositionSnapshot(marketId, userAddress, chainId, now - 7 * 24 * 60 * 60), // 7d ago + fetchPositionSnapshot(marketId, userAddress, chainId, now - 30 * 24 * 60 * 60), // 30d ago + ]); + + const [snapshot24h, snapshot7d, snapshot30d] = snapshots; + + return { + lifetimeEarned: lifetimeEarned.toString(), + last24hEarned: snapshot24h + ? calculateEarningsFromSnapshot( + currentBalance, + BigInt(snapshot24h.supplyAssets), + marketTxs, + now - 24 * 60 * 60, + ) + : null, + last7dEarned: snapshot7d + ? calculateEarningsFromSnapshot( + currentBalance, + BigInt(snapshot7d.supplyAssets), + marketTxs, + now - 7 * 24 * 60 * 60, + ) + : null, + last30dEarned: snapshot30d + ? calculateEarningsFromSnapshot( + currentBalance, + BigInt(snapshot30d.supplyAssets), + marketTxs, + now - 30 * 24 * 60 * 60, + ) + : null, + }; + }; + const fetchData = useCallback( async (isRefetch = false) => { - if (!user) return; + if (!user) { + console.error('Missing user address'); + setLoading(false); + setIsRefetching(false); + return; + } try { if (isRefetch) { @@ -24,6 +123,7 @@ const useUserPositions = (user: string | undefined) => { setLoading(true); } + // Fetch position data from both networks const [responseMainnet, responseBase] = await Promise.all([ fetch('https://blue-api.morpho.org/graphql', { method: 'POST', @@ -59,42 +159,56 @@ const useUserPositions = (user: string | undefined) => { const marketPositions: MarketPosition[] = []; const transactions: UserTransaction[] = []; + // Collect positions and transactions for (const result of [result1, result2]) { - // eslint-disable-next-line @typescript-eslint/prefer-optional-chain - if (result.data && result.data.userByAddress) { + if (result.data?.userByAddress) { marketPositions.push( ...(result.data.userByAddress.marketPositions as MarketPosition[]), ); - const parsableTxs = ( result.data.userByAddress.transactions as UserTransaction[] ).filter((t) => t.data?.market); - transactions.push(...(parsableTxs as UserTransaction[])); + transactions.push(...parsableTxs); } } - const filtered = marketPositions - .filter((position: MarketPosition) => position.supplyShares.toString() !== '0') - .map((position: MarketPosition) => ({ - ...position, - - // add warningWithDetail to each market - market: { - ...position.market, - warningsWithDetail: getMarketWarningsWithDetail(position.market), - }, - })); + // Sort transactions by timestamp (newest first) + transactions.sort((a, b) => Number(b.timestamp) - Number(a.timestamp)); + + // Process positions and calculate earnings + const enhancedPositions = await Promise.all( + marketPositions + .filter((position: MarketPosition) => position.supplyShares.toString() !== '0') + .map(async (position: MarketPosition) => { + const earnings = await calculateEarningsFromPeriod( + position, + transactions, + user as Address, + position.market.morphoBlue.chain.id, + ); + + return { + ...position, + market: { + ...position.market, + warningsWithDetail: getMarketWarningsWithDetail(position.market), + }, + earned: earnings, + }; + }), + ); setHistory(transactions); - setData(filtered); + setData(enhancedPositions); } catch (_error) { + console.error('Error fetching positions:', _error); setError(_error); } finally { setLoading(false); setIsRefetching(false); } }, - [user], + [user, fetchPositionSnapshot], ); useEffect(() => { diff --git a/src/utils/types.ts b/src/utils/types.ts index b09505e1..6aac0ce7 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -6,6 +6,7 @@ export type MarketPosition = { borrowAssets: string; borrowAssetsUsd: number; market: Market; // Now using the full Market type + earned?: PositionEarnings; }; export enum UserTxTypes { @@ -196,6 +197,13 @@ export type RebalanceAction = { shares?: bigint; }; +export type PositionEarnings = { + lifetimeEarned: string; + last24hEarned: string | null; + last7dEarned: string | null; + last30dEarned: string | null; +}; + export type GroupedPosition = { loanAsset: string; loanAssetAddress: string; @@ -203,11 +211,18 @@ export type GroupedPosition = { chainId: number; totalSupply: number; totalWeightedApy: number; - collaterals: { address: string; symbol: string | undefined; amount: number }[]; + + earned?: PositionEarnings; + + collaterals: { + address: string; + symbol: string; + amount: number; + }[]; markets: MarketPosition[]; processedCollaterals: { address: string; - symbol: string | undefined; + symbol: string; amount: number; percentage: number; }[]; @@ -280,6 +295,12 @@ export type Market = { amountPerSuppliedToken: string; amountPerBorrowedToken: string; }[]; + monthlySupplyApy: number; + monthlyBorrowApy: number; + dailySupplyApy: number; + dailyBorrowApy: number; + weeklySupplyApy: number; + weeklyBorrowApy: number; }; warnings: MarketWarning[]; badDebt?: {