diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md index 0671c6bc..66dc28d8 100644 --- a/docs/TECHNICAL_OVERVIEW.md +++ b/docs/TECHNICAL_OVERVIEW.md @@ -241,9 +241,9 @@ Hooks omitted from this matrix are local-state hooks or pure view/composition he | `useUserPositions` | Discover all current and exited markets where a user has positions, then attach lifetime supply aggregates and live balances | Monarch batched `Position` discovery/aggregates + RPC snapshots/oracle reads + market metadata from `useProcessedMarkets`; Morpho API and transaction-discovery fallback | Monarch market registry/detail if position objects should no longer depend on Morpho API market metadata | | `useUserPosition` | Single-market user position | RPC snapshot first; if snapshot unavailable, Monarch position state when local market exists; then Morpho API fallback | Same market-registry/detail gap as `useUserPositions` | | `useUserTransactionsQuery` / `fetchUserTransactions` | User history across one or many chains | Monarch user-event tables first; fallback Morpho API; `assetIds` filter still bypasses Monarch | Asset-address filtered history support to fully back reports and any asset-scoped history views | -| `useUserPositionsSummaryData` | Portfolio earnings summary for current and exited supply positions | Lifetime `Position` aggregates plus cursor-filtered recent events for all time; bounded period events + RPC boundary snapshots otherwise | Inherits the remaining `useUserPositions` and `useUserTransactionsQuery` gaps; the aggregate path requires the earnings-enabled Envio schema | +| `useUserPositionsSummaryData` | Portfolio earnings summary for current and exited supply positions | Lifetime `Position` aggregates plus cursor-filtered recent events for all time; completed UTC periods use sparse daily position/market aggregates plus RPC boundary snapshots; rolling 24h and custom ranges use bounded events | Inherits the remaining `useUserPositions` and `useUserTransactionsQuery` gaps; aggregate periods require the earnings-enabled Envio schema | | `usePositionReport` | Asset-scoped earnings/report generation | `fetchUserTransactions(assetIds=...)` + RPC block/snapshot helpers | Still blocked on Monarch support for `assetIds`-scoped user history | -| `usePositionHistoryChart` | Derive chart points for one asset/market group | Daily net-flow buckets for all time; bounded period transactions and boundary snapshots otherwise | The daily path requires Envio `PositionDailyFlow`; a data-API join could further reduce multi-market payloads | +| `usePositionHistoryChart` | Derive chart points for one asset/market group | Daily net-flow buckets for all time and completed UTC periods; bounded period transactions and boundary snapshots otherwise | The daily path requires Envio `PositionDailyFlow`; a data-API join could further reduce multi-market payloads | #### Market Detail And Admin Reads @@ -294,8 +294,9 @@ Split: allMarkets vs whitelistedMarkets 2. Fetch on-chain snapshots per market (`usePositionSnapshots`) 3. Combine live balances with market metadata from `useProcessedMarkets` 4. Group by loan asset -5. Calculate all-time earnings from lifetime aggregates plus events strictly after the indexed block/log cursor; use boundary snapshots and bounded event windows for shorter periods -6. Build all-time charts from completed sparse `PositionDailyFlow` buckets, with current balances as the live endpoint +5. Calculate all-time earnings from lifetime aggregates plus events strictly after the indexed block/log cursor +6. Calculate completed 7d/30d/3m/6m UTC periods from sparse `PositionDailyFlow` and `MarketDailySnapshot` rows plus boundary snapshots; keep 24h rolling and custom ranges on bounded events +7. Build all-time and completed-period charts from sparse `PositionDailyFlow` buckets, with current balances as the live endpoint for all time ``` **Vault Data Flow:** diff --git a/docs/VALIDATIONS.md b/docs/VALIDATIONS.md index dd974db0..f0e7be54 100644 --- a/docs/VALIDATIONS.md +++ b/docs/VALIDATIONS.md @@ -93,6 +93,7 @@ Use this file at the end of non-trivial work. Do not front-load it at task start - Portfolio and position analysis must preserve transaction-discovered market IDs even when current on-chain balances are zero. Generic list-level hide settings must not remove those markets from summary or history inputs. - Lifetime position earnings must use indexed lifetime aggregates plus only events strictly after the aggregate's block/log cursor, with a zero opening balance before the first supply. Complete paginated history is a fallback for indexes that do not expose the aggregate; do not request protocol contract state at chain block 0. - All-time position charts must use stable completed daily-flow buckets plus the live position endpoint; transaction-history tables must page at the data boundary instead of downloading lifelong events and slicing in the component. +- Completed-period position analytics must remain bounded by market-day aggregates with stable cursors; verify request count and payload size against a high-activity account. - Exception: the blacklisted-position visibility preference allows users to opt out. This preference must filter at the shared `useUserPositions` boundary so visible rows and portfolio APR/APY agree. - Current portfolio value and holdings breakdowns must use current positive balances only; history-preserved zero-balance positions belong in analytics/history inputs, not current holdings tooltips. - Shared components/modals launched from multiple pages may receive prefetched data, but every launcher must be verified to provide the same canonical data source and field completeness; do not let one route skip fields required by shared limits, previews, or transaction availability. diff --git a/src/data-sources/monarch-api/index.ts b/src/data-sources/monarch-api/index.ts index 949057f6..37dfee4e 100644 --- a/src/data-sources/monarch-api/index.ts +++ b/src/data-sources/monarch-api/index.ts @@ -6,7 +6,13 @@ export { type MonarchUserPositionState, } from './positions'; export { fetchMonarchUserTransactions } from './user-transactions'; -export { fetchCompletedPositionDailyFlows } from './position-daily-flows'; +export { + fetchCompletedPositionDailyAnalytics, + fetchCompletedPositionDailyFlows, + type MarketDailySupplySnapshot, + type PositionDailyAnalytics, + type PositionDailyFlow, +} from './position-daily-flows'; export { fetchMonarchMarketBorrowers, fetchMonarchMarketBorrows, diff --git a/src/data-sources/monarch-api/position-daily-flows.ts b/src/data-sources/monarch-api/position-daily-flows.ts index 70870515..3f92f8cd 100644 --- a/src/data-sources/monarch-api/position-daily-flows.ts +++ b/src/data-sources/monarch-api/position-daily-flows.ts @@ -1,89 +1,144 @@ import { buildEnvioPositionDailyFlowsPageQuery } from '@/graphql/envio-queries'; import { monarchGraphqlFetcher } from './fetchers'; -type PositionDailyFlow = { +export type PositionDailyFlow = { id: string; marketId: string; + bucketStart: number; lastActivityTimestamp: number; + suppliedAssets: string; + withdrawnAssets: string; netSupplyAssets: string; + closingSupplyShares: string; + supplyWeightedSharesSeconds: string; + supplyActiveSeconds: number; }; -type PositionDailyFlowRow = Omit & { +export type MarketDailySupplySnapshot = { + id: string; + marketId: string; + bucketStart: number; + totalSupplyAssets: string; + totalSupplyShares: string; +}; + +export type PositionDailyAnalytics = { + flows: PositionDailyFlow[]; + marketSnapshots: MarketDailySupplySnapshot[]; +}; + +type PositionDailyFlowRow = Omit & { bucketStart: string; lastActivityTimestamp: string; + supplyActiveSeconds: string; }; +type MarketDailySupplySnapshotRow = Omit & { bucketStart: string }; + type PositionDailyFlowsResponse = { data?: { PositionDailyFlow?: PositionDailyFlowRow[]; + markets?: MarketDailySupplySnapshotRow[]; }; }; +type PositionDailyFlowsParams = { + userAddress: string; + chainId: number; + marketIds: string[]; + startTimestamp: number; + endTimestamp: number; +}; + const SECONDS_PER_DAY = 86_400; const PAGE_SIZE = 1_000; const MAX_PAGES = 50; const getDayStart = (timestamp: number): number => Math.floor(timestamp / SECONDS_PER_DAY) * SECONDS_PER_DAY; -export async function fetchCompletedPositionDailyFlows({ - userAddress, - chainId, - marketIds, - startTimestamp, - endTimestamp, -}: { - userAddress: string; - chainId: number; - marketIds: string[]; - startTimestamp: number; - endTimestamp: number; -}): Promise { +type Cursor = { bucketStart: string; id: string }; + +const getCursor = (row: { bucketStart: string; id: string } | undefined): Cursor | undefined => + row ? { bucketStart: row.bucketStart, id: row.id } : undefined; + +const fetchPositionDailyFlowData = async ( + { userAddress, chainId, marketIds, startTimestamp, endTimestamp }: PositionDailyFlowsParams, + includeMarketSnapshots: boolean, +): Promise => { const startBucket = getDayStart(startTimestamp); - // The current UTC bucket is mutable. Excluding it keeps every paged row - // stable; the chart uses the live on-chain position for its final point. + // The current UTC bucket is mutable. Excluding it keeps every paged row stable. const endBucket = Math.min(getDayStart(endTimestamp), getDayStart(Math.floor(Date.now() / 1000))); - if (marketIds.length === 0 || endBucket <= startBucket) { - return []; - } + const result: PositionDailyAnalytics = { flows: [], marketSnapshots: [] }; + if (marketIds.length === 0 || endBucket <= startBucket) return result; - const flows: PositionDailyFlow[] = []; - let cursor: { bucketStart: string; id: string } | undefined; + let flowCursor: Cursor | undefined; + let marketCursor: Cursor | undefined; + let flowsComplete = false; + let marketsComplete = !includeMarketSnapshots; for (let page = 0; page < MAX_PAGES; page++) { - const response = await monarchGraphqlFetcher(buildEnvioPositionDailyFlowsPageQuery(Boolean(cursor)), { - user: userAddress.toLowerCase(), - chainId, - marketIds: marketIds.map((marketId) => marketId.toLowerCase()), - startBucket: String(startBucket), - endBucket: String(endBucket), - limit: PAGE_SIZE, - ...(cursor ? { cursorBucket: cursor.bucketStart, cursorId: cursor.id } : {}), - }); - const rows = response.data?.PositionDailyFlow ?? []; - - for (const row of rows) { - flows.push({ - id: row.id, - marketId: row.marketId, + const response: PositionDailyFlowsResponse = await monarchGraphqlFetcher( + buildEnvioPositionDailyFlowsPageQuery({ + afterFlowCursor: Boolean(flowCursor), + afterMarketCursor: Boolean(marketCursor), + includeMarketSnapshots, + }), + { + user: userAddress.toLowerCase(), + chainId, + marketIds: marketIds.map((marketId) => marketId.toLowerCase()), + startBucket: String(startBucket), + endBucket: String(endBucket), + flowLimit: flowsComplete ? 0 : PAGE_SIZE, + ...(includeMarketSnapshots + ? { marketStartBucket: String(Math.max(0, startBucket - SECONDS_PER_DAY)), marketLimit: marketsComplete ? 0 : PAGE_SIZE } + : {}), + ...(flowCursor ? { flowCursorBucket: flowCursor.bucketStart, flowCursorId: flowCursor.id } : {}), + ...(marketCursor ? { marketCursorBucket: marketCursor.bucketStart, marketCursorId: marketCursor.id } : {}), + }, + ); + const flowRows: PositionDailyFlowRow[] = response.data?.PositionDailyFlow ?? []; + const marketRows: MarketDailySupplySnapshotRow[] = response.data?.markets ?? []; + + for (const row of flowRows) { + result.flows.push({ + ...row, + bucketStart: Number(row.bucketStart), lastActivityTimestamp: Number(row.lastActivityTimestamp), - netSupplyAssets: row.netSupplyAssets, + supplyActiveSeconds: Number(row.supplyActiveSeconds), }); } - if (rows.length < PAGE_SIZE) { - return flows; + for (const row of marketRows) { + result.marketSnapshots.push({ ...row, bucketStart: Number(row.bucketStart) }); } - const lastRow = rows.at(-1); - if (!lastRow) { - return flows; + flowsComplete ||= flowRows.length < PAGE_SIZE; + marketsComplete ||= marketRows.length < PAGE_SIZE; + if (flowsComplete && marketsComplete) return result; + + if (!flowsComplete) { + const nextCursor = getCursor(flowRows.at(-1)); + if (!nextCursor || (nextCursor.bucketStart === flowCursor?.bucketStart && nextCursor.id === flowCursor.id)) { + throw new Error('Position daily flow pagination did not advance'); + } + flowCursor = nextCursor; } - const nextCursor = { bucketStart: lastRow.bucketStart, id: lastRow.id }; - if (cursor?.bucketStart === nextCursor.bucketStart && cursor.id === nextCursor.id) { - throw new Error('Position daily flow pagination did not advance'); + + if (!marketsComplete) { + const nextCursor = getCursor(marketRows.at(-1)); + if (!nextCursor || (nextCursor.bucketStart === marketCursor?.bucketStart && nextCursor.id === marketCursor.id)) { + throw new Error('Market daily snapshot pagination did not advance'); + } + marketCursor = nextCursor; } - cursor = nextCursor; } - throw new Error(`Position daily flow history exceeded the safe pagination limit (${MAX_PAGES * PAGE_SIZE} rows)`); -} + throw new Error(`Position daily analytics exceeded the safe pagination limit (${MAX_PAGES * PAGE_SIZE} rows)`); +}; + +export const fetchCompletedPositionDailyFlows = async (params: PositionDailyFlowsParams): Promise => + (await fetchPositionDailyFlowData(params, false)).flows; + +export const fetchCompletedPositionDailyAnalytics = (params: PositionDailyFlowsParams): Promise => + fetchPositionDailyFlowData(params, true); diff --git a/src/features/position-detail/components/history-tab.tsx b/src/features/position-detail/components/history-tab.tsx index a3559e9d..1a80248d 100644 --- a/src/features/position-detail/components/history-tab.tsx +++ b/src/features/position-detail/components/history-tab.tsx @@ -32,6 +32,7 @@ import type { PositionSnapshot } from '@/utils/positions'; import type { SupportedNetworks } from '@/utils/networks'; import type { EarningsTimeRange } from '@/hooks/useUserPositionsSummaryData'; import type { EarningsPeriod } from '@/stores/usePositionsFilters'; +import { usesCompletedUtcDays } from '@/utils/earnings-period'; const PAGE_SIZE = 10; @@ -114,7 +115,7 @@ export function HistoryTab({ groupedPosition, startTimestamp: reportRange?.startTimestamp ?? actualBlockData[chainId]?.timestamp, endTimestamp: reportRange?.endTimestamp, - useDailyBuckets: period === 'all' && !requiresEndSnapshots, + useDailyBuckets: period === 'all' || usesCompletedUtcDays(period), }); const maxDate = useMemo(() => now(getLocalTimeZone()), []); @@ -292,8 +293,7 @@ export function HistoryTab({ snapshotsByChain={snapshotsByChain} endSnapshotsByChain={endSnapshotsByChain} chainBlockData={actualBlockData} - endTimestamp={reportRange?.endTimestamp} - requiresEndSnapshots={requiresEndSnapshots} + endTimestamp={requiresEndSnapshots ? reportRange?.endTimestamp : undefined} height={220} /> )} diff --git a/src/features/position-detail/position-view.tsx b/src/features/position-detail/position-view.tsx index 0fd3ab66..ca417416 100644 --- a/src/features/position-detail/position-view.tsx +++ b/src/features/position-detail/position-view.tsx @@ -24,6 +24,7 @@ import { } from './components/report-range-picker'; import type { SupportedNetworks } from '@/utils/networks'; import type { EarningsPeriod } from '@/stores/usePositionsFilters'; +import { usesCompletedUtcDays } from '@/utils/earnings-period'; type PositionDetailContentProps = { chainId: number; @@ -89,6 +90,7 @@ export default function PositionDetailContent({ chainId, loanAssetAddress, userA const hasCustomRange = Boolean(reportCustomRange); const periodLabel = hasCustomRange && customRange ? formatReportRangeLabel(customRange) : PERIOD_LABELS[period]; const reportRange = earningsRangesByChain[chainId]; + const requiresEndSnapshots = hasCustomRange || usesCompletedUtcDays(period); // Handle refetch const handleRefetch = () => { @@ -226,7 +228,7 @@ export default function PositionDetailContent({ chainId, loanAssetAddress, userA endSnapshotsByChain={endSnapshotsByChain} actualBlockData={actualBlockData} reportRange={reportRange} - requiresEndSnapshots={hasCustomRange} + requiresEndSnapshots={requiresEndSnapshots} period={period} /> diff --git a/src/features/positions/components/adapter-managed-exposure.tsx b/src/features/positions/components/adapter-managed-exposure.tsx index 2e37cbc1..f3d430ec 100644 --- a/src/features/positions/components/adapter-managed-exposure.tsx +++ b/src/features/positions/components/adapter-managed-exposure.tsx @@ -57,8 +57,17 @@ function ExposureStatus({ message }: { message: string }) { function AdapterExposure({ adapterAddress, adapterType, chainId, period }: AdapterExposureProps) { const chainIds = useMemo(() => [chainId as SupportedNetworks], [chainId]); - const { isPositionsLoading, positions, refetch, isRefetching, isEarningsLoading, actualBlockData, snapshotsByChain } = - useUserPositionsSummaryData(adapterAddress, period, chainIds); + const { + isPositionsLoading, + positions, + refetch, + isRefetching, + isEarningsLoading, + actualBlockData, + snapshotsByChain, + endSnapshotsByChain, + earningsRangesByChain, + } = useUserPositionsSummaryData(adapterAddress, period, chainIds); const hasSuppliedMarkets = positions.some(hasSupplyPositionHistory); const hasBorrowPositions = positions.some( @@ -85,6 +94,8 @@ function AdapterExposure({ adapterAddress, adapterType, chainId, period }: Adapt isEarningsLoading={isEarningsLoading} actualBlockData={actualBlockData} snapshotsByChain={snapshotsByChain} + endSnapshotsByChain={endSnapshotsByChain} + earningsRangesByChain={earningsRangesByChain} /> )} diff --git a/src/features/positions/components/positions-period-settings.tsx b/src/features/positions/components/positions-period-settings.tsx index b811c2c4..12cfa442 100644 --- a/src/features/positions/components/positions-period-settings.tsx +++ b/src/features/positions/components/positions-period-settings.tsx @@ -1,9 +1,11 @@ import { CheckIcon } from '@radix-ui/react-icons'; import { Button } from '@/components/ui/button'; +import { Tooltip } from '@/components/ui/tooltip'; import { Modal, ModalBody, ModalFooter, ModalHeader } from '@/components/common/Modal'; import { useDisclosure } from '@/hooks/useDisclosure'; import type { EarningsPeriod } from '@/stores/usePositionsFilters'; import { cn } from '@/utils/components'; +import { formatEarningsTimeRange, getEarningsTimeRange } from '@/utils/earnings-period'; type PeriodOption = { value: EarningsPeriod; @@ -39,15 +41,22 @@ export function PositionsPeriodSettingsButton({ period, onPeriodChange, classNam return ( <> - + + + + >; + endSnapshotsByChain?: Record>; chainBlockData: Record; + endTimestamp?: number; isEarningsLoading: boolean; isOwner: boolean; useDailyBuckets?: boolean; @@ -163,7 +165,9 @@ export function SuppliedMarketsDetail({ account, groupedPosition, snapshotsByChain, + endSnapshotsByChain, chainBlockData, + endTimestamp, isEarningsLoading, isOwner, useDailyBuckets, @@ -177,6 +181,7 @@ export function SuppliedMarketsDetail({ account, groupedPosition, startTimestamp: chainBlockData[groupedPosition.chainId]?.timestamp, + endTimestamp, useDailyBuckets, }); @@ -223,7 +228,9 @@ export function SuppliedMarketsDetail({ groupedPosition={groupedPosition} transactions={transactions} snapshotsByChain={snapshotsByChain} + endSnapshotsByChain={endSnapshotsByChain} chainBlockData={chainBlockData} + endTimestamp={endTimestamp} /> )} diff --git a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx index c7e1f380..ba51e4a1 100644 --- a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx +++ b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx @@ -37,6 +37,7 @@ import { processCollaterals, } from '@/utils/positions'; import { convertApyToApr } from '@/utils/rateMath'; +import { usesCompletedUtcDays } from '@/utils/earnings-period'; import { useTokenPrices } from '@/hooks/useTokenPrices'; import { getTokenPriceKey } from '@/data-sources/morpho-api/prices'; import { APYCell } from '@/features/markets/components/apy-breakdown-tooltip'; @@ -50,6 +51,7 @@ import { getPositionsPeriodShortLabel } from './positions-period-settings'; import { RiArrowRightLine, RiSparklingFill } from 'react-icons/ri'; import type { MarketPositionWithEarnings } from '@/utils/types'; import type { PositionSnapshot } from '@/utils/positions'; +import type { EarningsTimeRange } from '@/utils/earnings-period'; type SuppliedMorphoBlueGroupedTableProps = { account: string; @@ -59,6 +61,8 @@ type SuppliedMorphoBlueGroupedTableProps = { isEarningsLoading: boolean; actualBlockData: Record; snapshotsByChain: Record>; + endSnapshotsByChain: Record>; + earningsRangesByChain: Record; }; type SuppliedMarketPositionsTableProps = { @@ -354,6 +358,8 @@ export function SuppliedMorphoBlueGroupedTable({ isEarningsLoading, actualBlockData, snapshotsByChain, + endSnapshotsByChain, + earningsRangesByChain, }: SuppliedMorphoBlueGroupedTableProps) { const { address } = useConnection(); const period = usePositionsFilters((s) => s.period); @@ -384,7 +390,14 @@ export function SuppliedMorphoBlueGroupedTable({ () => (hideClosedPositions ? supplyPositions.filter(hasActiveSupplyPosition) : supplyPositions), [supplyPositions, hideClosedPositions], ); - const groupedPositions = useMemo(() => groupPositionsByLoanAsset(visiblePositions, actualBlockData), [visiblePositions, actualBlockData]); + const endTimestampsByChain = useMemo( + () => Object.fromEntries(Object.entries(earningsRangesByChain).map(([chainId, range]) => [chainId, range.endTimestamp])), + [earningsRangesByChain], + ); + const groupedPositions = useMemo( + () => groupPositionsByLoanAsset(visiblePositions, actualBlockData, endTimestampsByChain), + [visiblePositions, actualBlockData, endTimestampsByChain], + ); const isOwner = useMemo(() => !!account && !!address && account.toLowerCase() === address.toLowerCase(), [account, address]); const processedPositions = useMemo(() => processCollaterals(groupedPositions), [groupedPositions]); @@ -711,10 +724,14 @@ export function SuppliedMorphoBlueGroupedTable({ groupedPosition={groupedPosition} account={account} snapshotsByChain={snapshotsByChain} + endSnapshotsByChain={endSnapshotsByChain} chainBlockData={actualBlockData} + endTimestamp={ + usesCompletedUtcDays(period) ? earningsRangesByChain[groupedPosition.chainId]?.endTimestamp : undefined + } isEarningsLoading={isEarningsLoading} isOwner={isOwner} - useDailyBuckets={period === 'all'} + useDailyBuckets={period === 'all' || usesCompletedUtcDays(period)} /> diff --git a/src/features/positions/components/user-positions-chart.tsx b/src/features/positions/components/user-positions-chart.tsx index 26b7df5f..a24db257 100644 --- a/src/features/positions/components/user-positions-chart.tsx +++ b/src/features/positions/components/user-positions-chart.tsx @@ -33,7 +33,6 @@ type GroupedPositionChartProps = BaseChartProps & { chainBlockData: Record; endSnapshotsByChain?: Record>; endTimestamp?: number; - requiresEndSnapshots?: boolean; }; // Props for standalone usage (history page) @@ -380,7 +379,7 @@ function useChartParams(props: UserPositionsChartProps) { collateralAddress: position.market.collateralAsset?.address ?? '', currentSupplyAssets: chainEndSnapshots?.get(position.market.uniqueKey.toLowerCase())?.supplyAssets ?? - (props.requiresEndSnapshots ? '0' : position.state.supplyAssets), + (props.endTimestamp === undefined ? position.state.supplyAssets : '0'), })); return { diff --git a/src/features/positions/positions-view.tsx b/src/features/positions/positions-view.tsx index 884ec47f..f633c25b 100644 --- a/src/features/positions/positions-view.tsx +++ b/src/features/positions/positions-view.tsx @@ -47,6 +47,7 @@ export default function Positions() { isEarningsLoading, actualBlockData, snapshotsByChain, + endSnapshotsByChain, earningsRangesByChain, } = useUserPositionsSummaryData(account, period, undefined, { enabled: shouldFetchNativeAccountData }); @@ -164,6 +165,8 @@ export default function Positions() { isEarningsLoading={isEarningsLoading} actualBlockData={actualBlockData} snapshotsByChain={snapshotsByChain} + endSnapshotsByChain={endSnapshotsByChain} + earningsRangesByChain={earningsRangesByChain} /> )} {!loading && hasBorrowPositions && ( diff --git a/src/features/vault/components/vault-adapter-position-overview.tsx b/src/features/vault/components/vault-adapter-position-overview.tsx index 9aa6c151..7277b34a 100644 --- a/src/features/vault/components/vault-adapter-position-overview.tsx +++ b/src/features/vault/components/vault-adapter-position-overview.tsx @@ -13,6 +13,7 @@ import type { MarketAllocation } from '@/types/vaultAllocations'; import type { PositionSnapshot } from '@/utils/positions'; import type { GroupedPosition, MarketPositionWithEarnings } from '@/utils/types'; import type { SupportedNetworks } from '@/utils/networks'; +import { usesCompletedUtcDays } from '@/utils/earnings-period'; const PERIOD_LABELS: Record = { day: '24h', @@ -31,6 +32,8 @@ type VaultAdapterPositionOverviewProps = { actualBlockData: Record; period: EarningsPeriod; snapshotsByChain: Record>; + endSnapshotsByChain: Record>; + endTimestamp?: number; marketAllocations: MarketAllocation[]; assetAddress?: Address; totalAssets?: bigint; @@ -98,6 +101,8 @@ export function VaultAdapterPositionOverview({ actualBlockData, period, snapshotsByChain, + endSnapshotsByChain, + endTimestamp, marketAllocations, assetAddress, totalAssets, @@ -112,7 +117,8 @@ export function VaultAdapterPositionOverview({ account: adapterAddress, groupedPosition, startTimestamp: actualBlockData[chainId]?.timestamp, - useDailyBuckets: period === 'all', + endTimestamp, + useDailyBuckets: period === 'all' || usesCompletedUtcDays(period), }); return ( @@ -137,7 +143,9 @@ export function VaultAdapterPositionOverview({ groupedPosition={groupedPosition} transactions={transactions} snapshotsByChain={snapshotsByChain} + endSnapshotsByChain={endSnapshotsByChain} chainBlockData={actualBlockData} + endTimestamp={endTimestamp} height={220} /> )} diff --git a/src/features/vault/vault-view.tsx b/src/features/vault/vault-view.tsx index 2eb31660..9fd79c12 100644 --- a/src/features/vault/vault-view.tsx +++ b/src/features/vault/vault-view.tsx @@ -39,6 +39,7 @@ import { getSlicedAddress } from '@/utils/address'; import { getVaultURL, supportsMorphoAppLinks } from '@/utils/external'; import { parseCapIdParams } from '@/utils/morpho'; import { groupPositionsByLoanAsset, processCollaterals } from '@/utils/positions'; +import { usesCompletedUtcDays } from '@/utils/earnings-period'; import { convertAprToApy, formatRateAsPercentage, toDisplayRateFromApy } from '@/utils/rateMath'; import { ALL_SUPPORTED_NETWORKS, getNetworkConfig, SupportedNetworks } from '@/utils/networks'; import { formatVaultAdapterType } from '@/utils/vaults'; @@ -124,21 +125,25 @@ function VaultAdapterPositionDetail({ [marketAllocations], ); - const { positions, isPositionsLoading, isEarningsLoading, actualBlockData, snapshotsByChain } = useUserPositionsSummaryData( - adapterAddress, - period, - [chainId], - { - enabled: hasAdapterPositionTarget && marketHints.length > 0, - marketHints, - showEmpty: true, - }, - ); + const { + positions, + isPositionsLoading, + isEarningsLoading, + actualBlockData, + snapshotsByChain, + endSnapshotsByChain, + earningsRangesByChain, + } = useUserPositionsSummaryData(adapterAddress, period, [chainId], { + enabled: hasAdapterPositionTarget && marketHints.length > 0, + marketHints, + showEmpty: true, + }); const groupedPositions = useMemo(() => { - const grouped = groupPositionsByLoanAsset(positions ?? [], actualBlockData); + const endTimestamp = earningsRangesByChain[chainId]?.endTimestamp; + const grouped = groupPositionsByLoanAsset(positions ?? [], actualBlockData, endTimestamp ? { [chainId]: endTimestamp } : {}); return processCollaterals(grouped); - }, [positions, actualBlockData]); + }, [positions, actualBlockData, chainId, earningsRangesByChain]); const currentPosition = useMemo(() => { if (!assetAddress) return undefined; @@ -177,6 +182,8 @@ function VaultAdapterPositionDetail({ actualBlockData={actualBlockData} period={period} snapshotsByChain={snapshotsByChain} + endSnapshotsByChain={endSnapshotsByChain} + endTimestamp={usesCompletedUtcDays(period) ? earningsRangesByChain[chainId]?.endTimestamp : undefined} marketAllocations={marketAllocations} assetAddress={assetAddress} totalAssets={totalAssets} diff --git a/src/graphql/envio-queries.ts b/src/graphql/envio-queries.ts index b25fbca5..eb76fb71 100644 --- a/src/graphql/envio-queries.ts +++ b/src/graphql/envio-queries.ts @@ -83,15 +83,25 @@ export const envioUserPositionForMarketQuery = ` } `; -export const buildEnvioPositionDailyFlowsPageQuery = (afterCursor: boolean): string => ` +export const buildEnvioPositionDailyFlowsPageQuery = ({ + afterFlowCursor, + afterMarketCursor, + includeMarketSnapshots, +}: { + afterFlowCursor: boolean; + afterMarketCursor: boolean; + includeMarketSnapshots: boolean; +}): string => ` query EnvioPositionDailyFlowsPage( $user: String! $chainId: Int! $marketIds: [String!]! $startBucket: numeric! $endBucket: numeric! - $limit: Int! - ${afterCursor ? '$cursorBucket: numeric! $cursorId: String!' : ''} + $flowLimit: Int! + ${afterFlowCursor ? '$flowCursorBucket: numeric! $flowCursorId: String!' : ''} + ${includeMarketSnapshots ? '$marketStartBucket: numeric! $marketLimit: Int!' : ''} + ${afterMarketCursor ? '$marketCursorBucket: numeric! $marketCursorId: String!' : ''} ) { PositionDailyFlow( where: { @@ -100,22 +110,54 @@ export const buildEnvioPositionDailyFlowsPageQuery = (afterCursor: boolean): str marketId: { _in: $marketIds } bucketStart: { _gte: $startBucket, _lt: $endBucket } ${ - afterCursor + afterFlowCursor ? `_or: [ - { bucketStart: { _gt: $cursorBucket } } - { _and: [{ bucketStart: { _eq: $cursorBucket } }, { id: { _gt: $cursorId } }] } + { bucketStart: { _gt: $flowCursorBucket } } + { _and: [{ bucketStart: { _eq: $flowCursorBucket } }, { id: { _gt: $flowCursorId } }] } ]` : '' } } - limit: $limit + limit: $flowLimit order_by: [{ bucketStart: asc }, { id: asc }] ) { id marketId bucketStart lastActivityTimestamp + suppliedAssets + withdrawnAssets netSupplyAssets + closingSupplyShares + supplyWeightedSharesSeconds + supplyActiveSeconds + } + ${ + includeMarketSnapshots + ? `markets: MarketDailySnapshot( + where: { + chainId: { _eq: $chainId } + marketId: { _in: $marketIds } + bucketStart: { _gte: $marketStartBucket, _lt: $endBucket } + ${ + afterMarketCursor + ? `_or: [ + { bucketStart: { _gt: $marketCursorBucket } } + { _and: [{ bucketStart: { _eq: $marketCursorBucket } }, { id: { _gt: $marketCursorId } }] } + ]` + : '' + } + } + limit: $marketLimit + order_by: [{ bucketStart: asc }, { id: asc }] + ) { + id + marketId + bucketStart + totalSupplyAssets + totalSupplyShares + }` + : '' } } `; diff --git a/src/hooks/queries/useBlockTimestamps.ts b/src/hooks/queries/useBlockTimestamps.ts index 56b76297..d69397da 100644 --- a/src/hooks/queries/useBlockTimestamps.ts +++ b/src/hooks/queries/useBlockTimestamps.ts @@ -1,17 +1,22 @@ import { useQuery } from '@tanstack/react-query'; import { useCustomRpcContext } from '@/components/providers/CustomRpcProvider'; import type { SupportedNetworks } from '@/utils/networks'; +import { findBlockAtTimestamp } from '@/utils/blockEstimation'; import { getClient } from '@/utils/rpc'; /** * * @param snapshotBlocks { chainId: blockNumber } */ -export const useBlockTimestamps = (snapshotBlocks: Record) => { +export const useBlockTimestamps = ( + snapshotBlocks: Record, + targetTimestamp: number, + latestBlocks: Record | undefined, +) => { const { customRpcUrls } = useCustomRpcContext(); return useQuery({ - queryKey: ['block-timestamps', snapshotBlocks], + queryKey: ['block-timestamps', snapshotBlocks, targetTimestamp, latestBlocks], queryFn: async () => { const blockData: Record = {}; @@ -19,10 +24,15 @@ export const useBlockTimestamps = (snapshotBlocks: Record) => { Object.entries(snapshotBlocks).map(async ([chainId, blockNum]) => { try { const client = getClient(Number(chainId) as SupportedNetworks, customRpcUrls[Number(chainId) as SupportedNetworks]); - const block = await client.getBlock({ blockNumber: BigInt(blockNum) }); + const chainIdNumber = Number(chainId) as SupportedNetworks; + const latestBlock = latestBlocks?.[chainIdNumber]; + if (latestBlock === undefined) { + throw new Error(`Missing latest block for chain ${chainId}`); + } + const blockDataAtTimestamp = await findBlockAtTimestamp(client, chainIdNumber, blockNum, targetTimestamp, latestBlock); blockData[Number(chainId)] = { - block: blockNum, - timestamp: Number(block.timestamp), + block: blockDataAtTimestamp.blockNumber, + timestamp: blockDataAtTimestamp.timestamp, }; } catch (error) { console.error(`Failed to get block ${blockNum} on chain ${chainId}:`, error); diff --git a/src/hooks/queries/usePositionDailyAnalyticsQuery.ts b/src/hooks/queries/usePositionDailyAnalyticsQuery.ts new file mode 100644 index 00000000..447e6261 --- /dev/null +++ b/src/hooks/queries/usePositionDailyAnalyticsQuery.ts @@ -0,0 +1,55 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { fetchCompletedPositionDailyAnalytics, type PositionDailyAnalytics } from '@/data-sources/monarch-api'; +import type { EarningsTimeRange } from '@/utils/earnings-period'; +import type { MarketPosition } from '@/utils/types'; + +type UsePositionDailyAnalyticsQueryOptions = { + userAddress: string | undefined; + positions: MarketPosition[] | undefined; + range: EarningsTimeRange; + enabled: boolean; +}; + +export const usePositionDailyAnalyticsQuery = ({ userAddress, positions, range, enabled }: UsePositionDailyAnalyticsQueryOptions) => { + const marketsByChain = useMemo(() => { + const result = new Map>(); + + for (const position of positions ?? []) { + const chainId = position.market.morphoBlue.chain.id; + const marketIds = result.get(chainId) ?? new Set(); + marketIds.add(position.market.uniqueKey.toLowerCase()); + result.set(chainId, marketIds); + } + + return [...result.entries()] + .map(([chainId, marketIds]) => [chainId, [...marketIds].sort()] as const) + .sort(([leftChainId], [rightChainId]) => leftChainId - rightChainId); + }, [positions]); + + return useQuery>({ + queryKey: ['position-daily-analytics', userAddress?.toLowerCase(), marketsByChain, range.startTimestamp, range.endTimestamp], + queryFn: async () => { + if (!userAddress) return {}; + + const entries = await Promise.all( + marketsByChain.map(async ([chainId, marketIds]) => { + const analytics = await fetchCompletedPositionDailyAnalytics({ + userAddress, + chainId, + marketIds, + startTimestamp: range.startTimestamp, + endTimestamp: range.endTimestamp, + }); + return [chainId, analytics] as const; + }), + ); + + return Object.fromEntries(entries); + }, + enabled: enabled && Boolean(userAddress) && marketsByChain.length > 0, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + refetchOnWindowFocus: false, + }); +}; diff --git a/src/hooks/usePositionsWithEarnings.ts b/src/hooks/usePositionsWithEarnings.ts index 0837952b..f0281b3f 100644 --- a/src/hooks/usePositionsWithEarnings.ts +++ b/src/hooks/usePositionsWithEarnings.ts @@ -1,14 +1,12 @@ import { useMemo } from 'react'; -import { calculateEarningsFromSnapshot, calculateLifetimeEarningsFromHistory } from '@/utils/interest'; +import type { PositionDailyAnalytics } from '@/data-sources/monarch-api'; +import { calculateEarningsFromDailyAnalytics, calculateEarningsFromSnapshot, calculateLifetimeEarningsFromHistory } from '@/utils/interest'; import type { MarketPosition, UserTransaction, MarketPositionWithEarnings } from '@/utils/types'; import { hasActiveSupplyPosition, initializePositionWithEmptyEarnings, type PositionSnapshot } from '@/utils/positions'; import { isSupplyPositionTransaction } from '@/utils/transactionGrouping'; -import type { EarningsPeriod } from '@/stores/usePositionsFilters'; +import type { EarningsTimeRange } from '@/utils/earnings-period'; -export type EarningsTimeRange = { - startTimestamp: number; - endTimestamp: number; -}; +export type { EarningsTimeRange } from '@/utils/earnings-period'; type UsePositionsWithEarningsOptions = { endSnapshotsByChain?: Record>; @@ -16,26 +14,8 @@ type UsePositionsWithEarningsOptions = { fallbackEndTimestamp?: number; requiresEndSnapshots?: boolean; useLifetimeHistory?: boolean; -}; - -// Simple helper for the period timestamp calculation -export const getPeriodTimestamp = (period: EarningsPeriod, endTimestamp: number = Math.floor(Date.now() / 1000)): number => { - switch (period) { - case 'day': - return endTimestamp - 86_400; - case 'week': - return endTimestamp - 7 * 86_400; - case 'month': - return endTimestamp - 30 * 86_400; - case 'threemonth': - return endTimestamp - 90 * 86_400; - case 'sixmonth': - return endTimestamp - 180 * 86_400; - case 'all': - return 0; - default: - return endTimestamp - 86_400; - } + dailyAnalyticsByChain?: Record; + dailyRange?: EarningsTimeRange; }; export const usePositionsWithEarnings = ( @@ -71,8 +51,12 @@ export const usePositionsWithEarnings = ( return initializePositionWithEmptyEarnings(position, hasSupplyHistory); } - const startTimestamp = chainData.timestamp; - const endTimestamp = options.endBlockData?.[chainId]?.timestamp ?? defaultEndTimestamp; + const dailyAnalytics = options.dailyAnalyticsByChain?.[chainId]; + const startTimestamp = dailyAnalytics && options.dailyRange ? options.dailyRange.startTimestamp : chainData.timestamp; + const endTimestamp = + dailyAnalytics && options.dailyRange + ? options.dailyRange.endTimestamp + : (options.endBlockData?.[chainId]?.timestamp ?? defaultEndTimestamp); if (endTimestamp <= startTimestamp) { return initializePositionWithEmptyEarnings(position, hasSupplyHistory); } @@ -96,7 +80,18 @@ export const usePositionsWithEarnings = ( const earnings = options.useLifetimeHistory && position.supplyHistory ? calculateLifetimeEarningsFromHistory(endingBalance, position.supplyHistory, marketTxs, endTimestamp) - : calculateEarningsFromSnapshot(endingBalance, pastBalance, marketTxs, startTimestamp, endTimestamp); + : dailyAnalytics + ? calculateEarningsFromDailyAnalytics({ + marketId: position.market.uniqueKey, + startingBalance: pastBalance, + endingBalance, + startingShares: BigInt(pastSnapshot.supplyShares), + endingShares: BigInt(endSnapshot?.supplyShares ?? position.state.supplyShares), + startTimestamp, + endTimestamp, + analytics: dailyAnalytics, + }) + : calculateEarningsFromSnapshot(endingBalance, pastBalance, marketTxs, startTimestamp, endTimestamp); return { ...position, @@ -119,5 +114,7 @@ export const usePositionsWithEarnings = ( options.fallbackEndTimestamp, options.requiresEndSnapshots, options.useLifetimeHistory, + options.dailyAnalyticsByChain, + options.dailyRange, ]); }; diff --git a/src/hooks/useUserPositionsSummaryData.ts b/src/hooks/useUserPositionsSummaryData.ts index 2b4b05e0..f6dee803 100644 --- a/src/hooks/useUserPositionsSummaryData.ts +++ b/src/hooks/useUserPositionsSummaryData.ts @@ -6,12 +6,15 @@ import useUserPositions, { positionKeys, type UserPositionMarketHint } from './u import { useCurrentBlocks } from './queries/useCurrentBlocks'; import { useBlockTimestamps } from './queries/useBlockTimestamps'; import { usePositionSnapshots } from './queries/usePositionSnapshots'; +import { usePositionDailyAnalyticsQuery } from './queries/usePositionDailyAnalyticsQuery'; import { useUserTransactionsQuery } from './queries/useUserTransactionsQuery'; -import { usePositionsWithEarnings, getPeriodTimestamp, type EarningsTimeRange } from './usePositionsWithEarnings'; +import { usePositionsWithEarnings, type EarningsTimeRange } from './usePositionsWithEarnings'; import { mergeUserTransactionsWithRecentCache, reconcileUserTransactionHistoryCache } from '@/utils/user-transaction-history-cache'; import type { EarningsPeriod } from '@/stores/usePositionsFilters'; import { buildAllTimePositionBoundary } from '@/utils/position-boundary-snapshots'; import { hasActiveSupplyPosition } from '@/utils/positions'; +import { getEarningsTimeRange, isRollingEarningsPeriod } from '@/utils/earnings-period'; +import { supportsHistoricalStateRead } from '@/utils/networks'; export type { EarningsPeriod } from '@/stores/usePositionsFilters'; export type { EarningsTimeRange } from './usePositionsWithEarnings'; @@ -80,14 +83,13 @@ const useUserPositionsSummaryData = ( return validatedCustomRange; } - return { - startTimestamp: getPeriodTimestamp(period, nowTimestamp), - endTimestamp: nowTimestamp, - }; + return getEarningsTimeRange(period, nowTimestamp); }, [period, validatedCustomRange]); const hasCustomRange = Boolean(validatedCustomRange); const isAllTime = period === 'all' && !hasCustomRange; + // 24H stays rolling; 7D/30D/3M/6M use completed UTC daily aggregates. + const usesCompletedDailyAnalytics = !hasCustomRange && !isAllTime && !isRollingEarningsPeriod(period); const requiresFullAllTimeHistory = isAllTime && Boolean( @@ -110,7 +112,7 @@ const useUserPositionsSummaryData = ( }, [isAllTime, selectedRange.startTimestamp, uniqueChainIds, currentBlocks]); const endSnapshotBlocks = useMemo(() => { - if (!hasCustomRange || !currentBlocks) return {}; + if ((!hasCustomRange && !usesCompletedDailyAnalytics) || !currentBlocks) return {}; const blocks: Record = {}; @@ -122,19 +124,43 @@ const useUserPositionsSummaryData = ( }); return blocks; - }, [hasCustomRange, selectedRange.endTimestamp, uniqueChainIds, currentBlocks]); + }, [hasCustomRange, usesCompletedDailyAnalytics, selectedRange.endTimestamp, uniqueChainIds, currentBlocks]); const { data: actualBlockData, isLoading: isLoadingBlockTimestamps, isFetching: isFetchingBlockTimestamps, - } = useBlockTimestamps(snapshotBlocks); + } = useBlockTimestamps(snapshotBlocks, selectedRange.startTimestamp, currentBlocks); const { data: endBlockData, isLoading: isLoadingEndBlockTimestamps, isFetching: isFetchingEndBlockTimestamps, - } = useBlockTimestamps(endSnapshotBlocks); + } = useBlockTimestamps(endSnapshotBlocks, selectedRange.endTimestamp, currentBlocks); + + const resolvedSnapshotBlocks = useMemo( + () => Object.fromEntries(Object.entries(actualBlockData ?? {}).map(([chainId, data]) => [chainId, data.block])), + [actualBlockData], + ); + const resolvedEndSnapshotBlocks = useMemo( + () => Object.fromEntries(Object.entries(endBlockData ?? {}).map(([chainId, data]) => [chainId, data.block])), + [endBlockData], + ); + + const dailyAnalyticsQuery = usePositionDailyAnalyticsQuery({ + userAddress: activeUser, + positions, + range: selectedRange, + enabled: usesCompletedDailyAnalytics, + }); + + const transactionChainIds = useMemo(() => { + if (!usesCompletedDailyAnalytics || dailyAnalyticsQuery.isError) { + return uniqueChainIds; + } + + return uniqueChainIds.filter((chainId) => !supportsHistoricalStateRead(chainId)); + }, [dailyAnalyticsQuery.isError, uniqueChainIds, usesCompletedDailyAnalytics]); const transactionTimestampGte = useMemo(() => { if (isAllTime) { @@ -153,11 +179,11 @@ const useUserPositionsSummaryData = ( filters: { userAddress: activeUser ? [activeUser] : [], marketUniqueKeys: positions?.map((p) => p.market.uniqueKey) ?? [], - chainIds: uniqueChainIds, + chainIds: transactionChainIds, timestampGte: transactionTimestampGte, }, paginate: true, - enabled: !!positions && !!activeUser, + enabled: !!positions && !!activeUser && transactionChainIds.length > 0, }); const mergedTransactions = useMemo( @@ -198,7 +224,7 @@ const useUserPositionsSummaryData = ( } = usePositionSnapshots({ positions, user: activeUser, - snapshotBlocks, + snapshotBlocks: resolvedSnapshotBlocks, boundaryBlockData: actualBlockData ?? {}, transactions: mergedTransactions, }); @@ -213,7 +239,7 @@ const useUserPositionsSummaryData = ( } = usePositionSnapshots({ positions, user: activeUser, - snapshotBlocks: endSnapshotBlocks, + snapshotBlocks: resolvedEndSnapshotBlocks, boundaryBlockData: endBlockData ?? {}, transactions: mergedTransactions, }); @@ -222,14 +248,21 @@ const useUserPositionsSummaryData = ( endSnapshotsByChain: endSnapshots ?? {}, endBlockData: endBlockData ?? {}, fallbackEndTimestamp: selectedRange.endTimestamp, - requiresEndSnapshots: hasCustomRange, + requiresEndSnapshots: hasCustomRange || usesCompletedDailyAnalytics, useLifetimeHistory: isAllTime, + dailyAnalyticsByChain: usesCompletedDailyAnalytics ? dailyAnalyticsQuery.data : undefined, + dailyRange: usesCompletedDailyAnalytics ? selectedRange : undefined, }); const earningsRangesByChain = useMemo(() => { const ranges: Record = {}; uniqueChainIds.forEach((chainId) => { + if (usesCompletedDailyAnalytics) { + ranges[chainId] = selectedRange; + return; + } + const startTimestamp = startBlockData[chainId]?.timestamp; if (!startTimestamp) return; @@ -240,7 +273,7 @@ const useUserPositionsSummaryData = ( }); return ranges; - }, [uniqueChainIds, startBlockData, endBlockData, selectedRange.endTimestamp]); + }, [uniqueChainIds, usesCompletedDailyAnalytics, selectedRange, startBlockData, endBlockData]); const refetch = async (onSuccess?: () => void) => { if (!activeUser) { @@ -261,6 +294,9 @@ const useUserPositionsSummaryData = ( await queryClient.invalidateQueries({ queryKey: ['user-transactions'], }); + await queryClient.invalidateQueries({ + queryKey: ['position-daily-analytics'], + }); await queryClient.invalidateQueries({ queryKey: ['current-blocks'], }); @@ -285,7 +321,9 @@ const useUserPositionsSummaryData = ( isLoadingBlockTimestamps || isFetchingBlockTimestamps || isLoadingEndBlockTimestamps || - isFetchingEndBlockTimestamps); + isFetchingEndBlockTimestamps || + dailyAnalyticsQuery.isLoading || + dailyAnalyticsQuery.isFetching); const loadingStates = { positions: positionsLoading, diff --git a/src/hooks/useVaultHistoricalApy.ts b/src/hooks/useVaultHistoricalApy.ts index 114ca189..9e9998f1 100644 --- a/src/hooks/useVaultHistoricalApy.ts +++ b/src/hooks/useVaultHistoricalApy.ts @@ -6,11 +6,11 @@ import { useCustomRpcContext } from '@/components/providers/CustomRpcProvider'; import type { UserVaultV2 } from '@/data-sources/monarch-api/vaults'; import type { EarningsPeriod } from '@/stores/usePositionsFilters'; import { estimateBlockAtTimestamp } from '@/utils/blockEstimation'; +import { getEarningsTimeRange, usesCompletedUtcDays } from '@/utils/earnings-period'; import { supportsHistoricalStateRead, type SupportedNetworks } from '@/utils/networks'; import { getClient } from '@/utils/rpc'; import { useCurrentBlocks } from './queries/useCurrentBlocks'; import { useBlockTimestamps } from './queries/useBlockTimestamps'; -import { getPeriodTimestamp } from './usePositionsWithEarnings'; const ONE_SHARE = 10n ** 18n; @@ -21,11 +21,12 @@ type VaultApyData = { }; /** - * Fetches historical APY for vaults by comparing share prices at current and past blocks. - * APY = (currentSharePrice / pastSharePrice) ^ (365 * 86400 / periodSeconds) - 1 + * Fetches historical APY for vaults by comparing share prices at the period boundaries. */ export const useVaultHistoricalApy = (vaults: UserVaultV2[], period: EarningsPeriod) => { const { customRpcUrls } = useCustomRpcContext(); + const range = useMemo(() => getEarningsTimeRange(period), [period]); + const requiresHistoricalEnd = usesCompletedUtcDays(period); // Get unique chain IDs from vaults const uniqueChainIds = useMemo(() => [...new Set(vaults.map((v) => v.networkId))], [vaults]); @@ -37,21 +38,34 @@ export const useVaultHistoricalApy = (vaults: UserVaultV2[], period: EarningsPer const snapshotBlocks = useMemo(() => { if (!currentBlocks) return {}; - const timestamp = getPeriodTimestamp(period); const blocks: Record = {}; for (const chainId of uniqueChainIds) { const currentBlock = currentBlocks[chainId]; if (currentBlock) { - blocks[chainId] = estimateBlockAtTimestamp(chainId, timestamp, currentBlock); + blocks[chainId] = estimateBlockAtTimestamp(chainId, range.startTimestamp, currentBlock); } } return blocks; - }, [period, uniqueChainIds, currentBlocks]); + }, [range.startTimestamp, uniqueChainIds, currentBlocks]); + + const endSnapshotBlocks = useMemo(() => { + if (!currentBlocks || !requiresHistoricalEnd) return {}; + + const blocks: Record = {}; + for (const chainId of uniqueChainIds) { + const currentBlock = currentBlocks[chainId]; + if (currentBlock) { + blocks[chainId] = estimateBlockAtTimestamp(chainId, range.endTimestamp, currentBlock); + } + } + return blocks; + }, [currentBlocks, range.endTimestamp, requiresHistoricalEnd, uniqueChainIds]); // Get actual timestamps for the snapshot blocks - const { data: actualBlockData } = useBlockTimestamps(snapshotBlocks); + const { data: actualBlockData } = useBlockTimestamps(snapshotBlocks, range.startTimestamp, currentBlocks); + const { data: endBlockData } = useBlockTimestamps(endSnapshotBlocks, range.endTimestamp, currentBlocks); // Create a stable key for the query const vaultAddresses = useMemo( @@ -64,14 +78,14 @@ export const useVaultHistoricalApy = (vaults: UserVaultV2[], period: EarningsPer ); return useQuery({ - queryKey: ['vault-historical-apy', vaultAddresses, period, actualBlockData], + queryKey: ['vault-historical-apy', vaultAddresses, range, actualBlockData, endBlockData], queryFn: async () => { if (!currentBlocks || !actualBlockData) { return new Map(); } const results = new Map(); - const endTimestamp = Math.floor(Date.now() / 1000); + const fallbackEndTimestamp = Math.floor(Date.now() / 1000); // Group vaults by network for efficient batching const vaultsByNetwork = vaults.reduce( @@ -94,14 +108,17 @@ export const useVaultHistoricalApy = (vaults: UserVaultV2[], period: EarningsPer } const client = getClient(networkId, customRpcUrls[networkId]); - const pastBlock = snapshotBlocks[networkId]; const blockData = actualBlockData[networkId]; + const historicalEndBlockData = endBlockData?.[networkId]; - if (!pastBlock || !blockData) { + if (!blockData) { return; } + const pastBlock = blockData.block; + const endBlock = historicalEndBlockData?.block; const startTimestamp = blockData.timestamp; + const endTimestamp = historicalEndBlockData?.timestamp ?? fallbackEndTimestamp; // Create multicall contracts for share price queries (same for current and past) const contracts = networkVaults.map((vault) => ({ @@ -114,7 +131,11 @@ export const useVaultHistoricalApy = (vaults: UserVaultV2[], period: EarningsPer try { // Fetch current and past share prices in parallel const [currentResults, pastResults] = await Promise.all([ - client.multicall({ contracts, allowFailure: true }), + client.multicall({ + contracts, + allowFailure: true, + blockNumber: endBlock ? BigInt(endBlock) : undefined, + }), client.multicall({ contracts, allowFailure: true, blockNumber: BigInt(pastBlock) }), ]); @@ -161,7 +182,7 @@ export const useVaultHistoricalApy = (vaults: UserVaultV2[], period: EarningsPer return results; }, - enabled: vaults.length > 0 && !!currentBlocks && !!actualBlockData, + enabled: vaults.length > 0 && !!currentBlocks && !!actualBlockData && (!requiresHistoricalEnd || !!endBlockData), staleTime: 2 * 60 * 1000, // 2 minutes gcTime: 5 * 60 * 1000, // 5 minutes refetchOnWindowFocus: false, diff --git a/src/utils/blockEstimation.ts b/src/utils/blockEstimation.ts index 559bbd6d..5227558a 100644 --- a/src/utils/blockEstimation.ts +++ b/src/utils/blockEstimation.ts @@ -41,6 +41,44 @@ export const estimateBlockAtTimestamp = ( return Math.max(0, currentBlock - blockDiff); }; +/** Refines an estimated block to the last block at or before the target timestamp. */ +export async function findBlockAtTimestamp( + client: PublicClient, + chainId: SupportedNetworks, + estimatedBlock: number, + targetTimestamp: number, + latestBlock: number, +): Promise { + const blockTime = getBlocktime(chainId); + let blockNumber = Math.min(Math.max(0, estimatedBlock), latestBlock); + + for (let attempt = 0; attempt < 12; attempt++) { + const block = await client.getBlock({ blockNumber: BigInt(blockNumber) }); + const timestamp = Number(block.timestamp); + const delta = targetTimestamp - timestamp; + + if ((blockNumber === 0 && delta < 0) || (blockNumber === latestBlock && delta >= 0)) { + return { blockNumber, timestamp, targetTimestamp }; + } + + if (delta >= 0 && delta < blockTime * 5) { + const nextBlock = await client.getBlock({ blockNumber: BigInt(blockNumber + 1) }); + if (Number(nextBlock.timestamp) > targetTimestamp) { + return { blockNumber, timestamp, targetTimestamp }; + } + + blockNumber++; + continue; + } + + const estimatedDelta = Math.trunc(delta / blockTime); + const step = estimatedDelta || (delta > 0 ? 1 : -1); + blockNumber = Math.min(Math.max(0, blockNumber + step), latestBlock); + } + + throw new Error(`Unable to find block at timestamp ${targetTimestamp} on chain ${chainId}`); +} + /** * Fetches real block timestamps for multiple estimated blocks in parallel. * Uses batched RPC calls for efficiency. diff --git a/src/utils/earnings-period.ts b/src/utils/earnings-period.ts new file mode 100644 index 00000000..1f96c30b --- /dev/null +++ b/src/utils/earnings-period.ts @@ -0,0 +1,64 @@ +import type { EarningsPeriod } from '@/stores/usePositionsFilters'; + +export type EarningsTimeRange = { + startTimestamp: number; + endTimestamp: number; +}; + +const SECONDS_PER_DAY = 86_400; + +const COMPLETED_PERIOD_DAYS: Partial> = { + week: 7, + month: 30, + threemonth: 90, + sixmonth: 180, +}; + +export const usesCompletedUtcDays = (period: EarningsPeriod): boolean => COMPLETED_PERIOD_DAYS[period] !== undefined; +export const isRollingEarningsPeriod = (period: EarningsPeriod): boolean => period === 'day'; + +export const getEarningsTimeRange = (period: EarningsPeriod, nowTimestamp: number = Math.floor(Date.now() / 1000)): EarningsTimeRange => { + // 24H is the only rolling preset. Every longer bounded preset uses completed UTC days. + if (isRollingEarningsPeriod(period)) { + return { startTimestamp: nowTimestamp - SECONDS_PER_DAY, endTimestamp: nowTimestamp }; + } + + const completedDays = COMPLETED_PERIOD_DAYS[period]; + if (completedDays !== undefined) { + const endTimestamp = Math.floor(nowTimestamp / SECONDS_PER_DAY) * SECONDS_PER_DAY; + return { + startTimestamp: endTimestamp - completedDays * SECONDS_PER_DAY, + endTimestamp, + }; + } + + return { + startTimestamp: 0, + endTimestamp: nowTimestamp, + }; +}; + +const formatUtcTimestamp = (timestamp: number): string => + new Intl.DateTimeFormat('en-US', { + timeZone: 'UTC', + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) + .format(new Date(timestamp * 1000)) + .replace('24:00', '00:00'); + +export const formatEarningsTimeRange = (period: EarningsPeriod, range: EarningsTimeRange): string => { + const end = `${formatUtcTimestamp(range.endTimestamp)} UTC`; + + if (period === 'all') { + return `All indexed activity through ${end}`; + } + + const start = `${formatUtcTimestamp(range.startTimestamp)} UTC`; + const prefix = isRollingEarningsPeriod(period) ? 'Rolling 24H' : 'Completed UTC days'; + return `${prefix}: ${start} – ${end}`; +}; diff --git a/src/utils/interest.ts b/src/utils/interest.ts index 7d6d18d8..8b2347b3 100644 --- a/src/utils/interest.ts +++ b/src/utils/interest.ts @@ -1,3 +1,5 @@ +import { SharesMath } from '@morpho-org/blue-sdk'; +import type { MarketDailySupplySnapshot, PositionDailyAnalytics, PositionDailyFlow } from '@/data-sources/monarch-api'; import { type SupplyPositionHistory, type UserTransaction, UserTxTypes } from './types'; export type EarningsCalculation = { @@ -9,7 +11,8 @@ export type EarningsCalculation = { apy: number; }; -const ONE_YEAR = 86_400 * 365; +const SECONDS_PER_DAY = 86_400; +const ONE_YEAR = SECONDS_PER_DAY * 365; const calculateApy = (earned: bigint, averageSuppliedAssets: bigint, effectiveTime: number): number => { if (earned <= 0n || averageSuppliedAssets <= 0n || effectiveTime <= 0) { @@ -95,6 +98,102 @@ export function calculateEarningsFromSnapshot( }; } +const getDailyExposure = ( + flow: PositionDailyFlow | undefined, + currentShares: bigint, + bucketEnd: number, +): { weightedSharesSeconds: bigint; activeSeconds: number; closingShares: bigint } => { + if (!flow) { + return { + weightedSharesSeconds: currentShares * BigInt(SECONDS_PER_DAY), + activeSeconds: currentShares > 0n ? SECONDS_PER_DAY : 0, + closingShares: currentShares, + }; + } + + const closingShares = BigInt(flow.closingSupplyShares); + const tailSeconds = Math.max(0, bucketEnd - flow.lastActivityTimestamp); + + return { + weightedSharesSeconds: BigInt(flow.supplyWeightedSharesSeconds) + closingShares * BigInt(tailSeconds), + activeSeconds: flow.supplyActiveSeconds + (closingShares > 0n ? tailSeconds : 0), + closingShares, + }; +}; + +const toWeightedAssets = (weightedSharesSeconds: bigint, snapshot: MarketDailySupplySnapshot): bigint => + SharesMath.toAssets(weightedSharesSeconds, BigInt(snapshot.totalSupplyAssets), BigInt(snapshot.totalSupplyShares), 'Down'); + +/** Completed presets use immutable daily flows; only 24H continues through the rolling event path. */ +export function calculateEarningsFromDailyAnalytics({ + marketId, + startingBalance, + endingBalance, + startingShares, + endingShares, + startTimestamp, + endTimestamp, + analytics, +}: { + marketId: string; + startingBalance: bigint; + endingBalance: bigint; + startingShares: bigint; + endingShares: bigint; + startTimestamp: number; + endTimestamp: number; + analytics: PositionDailyAnalytics; +}): EarningsCalculation { + const normalizedMarketId = marketId.toLowerCase(); + const flows = analytics.flows.filter((flow) => flow.marketId.toLowerCase() === normalizedMarketId); + const marketSnapshots = analytics.marketSnapshots + .filter((snapshot) => snapshot.marketId.toLowerCase() === normalizedMarketId) + .sort((left, right) => left.bucketStart - right.bucketStart); + const flowByBucket = new Map(flows.map((flow) => [flow.bucketStart, flow])); + const marketSnapshotByBucket = new Map(marketSnapshots.map((snapshot) => [snapshot.bucketStart, snapshot])); + const totalDeposits = flows.reduce((sum, flow) => sum + BigInt(flow.suppliedAssets), 0n); + const totalWithdraws = flows.reduce((sum, flow) => sum + BigInt(flow.withdrawnAssets), 0n); + const earned = endingBalance + totalWithdraws - (startingBalance + totalDeposits); + + let currentShares = startingShares; + let weightedSuppliedAssets = 0n; + let effectiveTime = 0; + let latestMarketSnapshot = marketSnapshots.filter((snapshot) => snapshot.bucketStart < startTimestamp).at(-1); + + for (let bucketStart = startTimestamp; bucketStart < endTimestamp; bucketStart += SECONDS_PER_DAY) { + const exposure = getDailyExposure(flowByBucket.get(bucketStart), currentShares, bucketStart + SECONDS_PER_DAY); + const endMarketSnapshot = marketSnapshotByBucket.get(bucketStart) ?? latestMarketSnapshot; + const startMarketSnapshot = latestMarketSnapshot ?? endMarketSnapshot; + + if (exposure.weightedSharesSeconds > 0n) { + if (startMarketSnapshot && endMarketSnapshot) { + const startWeightedAssets = toWeightedAssets(exposure.weightedSharesSeconds, startMarketSnapshot); + const endWeightedAssets = toWeightedAssets(exposure.weightedSharesSeconds, endMarketSnapshot); + weightedSuppliedAssets += (startWeightedAssets + endWeightedAssets) / 2n; + } else if (startingShares > 0n) { + weightedSuppliedAssets += (exposure.weightedSharesSeconds * startingBalance) / startingShares; + } else if (endingShares > 0n) { + weightedSuppliedAssets += (exposure.weightedSharesSeconds * endingBalance) / endingShares; + } + } + + effectiveTime += exposure.activeSeconds; + currentShares = exposure.closingShares; + latestMarketSnapshot = endMarketSnapshot; + } + + const averageSuppliedAssets = effectiveTime > 0 ? weightedSuppliedAssets / BigInt(effectiveTime) : 0n; + + return { + earned, + totalDeposits, + totalWithdraws, + avgCapital: averageSuppliedAssets, + effectiveTime, + apy: calculateApy(earned, averageSuppliedAssets, effectiveTime), + }; +} + export function calculateLifetimeEarningsFromHistory( endingBalance: bigint, history: SupplyPositionHistory,