Skip to content
Open
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
9 changes: 5 additions & 4 deletions docs/TECHNICAL_OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:**
Expand Down
1 change: 1 addition & 0 deletions docs/VALIDATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion src/data-sources/monarch-api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
153 changes: 104 additions & 49 deletions src/data-sources/monarch-api/position-daily-flows.ts
Original file line number Diff line number Diff line change
@@ -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<PositionDailyFlow, 'lastActivityTimestamp'> & {
export type MarketDailySupplySnapshot = {
id: string;
marketId: string;
bucketStart: number;
totalSupplyAssets: string;
totalSupplyShares: string;
};

export type PositionDailyAnalytics = {
flows: PositionDailyFlow[];
marketSnapshots: MarketDailySupplySnapshot[];
};

type PositionDailyFlowRow = Omit<PositionDailyFlow, 'bucketStart' | 'lastActivityTimestamp' | 'supplyActiveSeconds'> & {
bucketStart: string;
lastActivityTimestamp: string;
supplyActiveSeconds: string;
};

type MarketDailySupplySnapshotRow = Omit<MarketDailySupplySnapshot, 'bucketStart'> & { 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<PositionDailyFlow[]> {
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<PositionDailyAnalytics> => {
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<PositionDailyFlowsResponse>(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<PositionDailyFlowsResponse>(
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<PositionDailyFlow[]> =>
(await fetchPositionDailyFlowData(params, false)).flows;

export const fetchCompletedPositionDailyAnalytics = (params: PositionDailyFlowsParams): Promise<PositionDailyAnalytics> =>
fetchPositionDailyFlowData(params, true);
6 changes: 3 additions & 3 deletions src/features/position-detail/components/history-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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()), []);
Expand Down Expand Up @@ -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}
/>
)}
Expand Down
4 changes: 3 additions & 1 deletion src/features/position-detail/position-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -226,7 +228,7 @@ export default function PositionDetailContent({ chainId, loanAssetAddress, userA
endSnapshotsByChain={endSnapshotsByChain}
actualBlockData={actualBlockData}
reportRange={reportRange}
requiresEndSnapshots={hasCustomRange}
requiresEndSnapshots={requiresEndSnapshots}
period={period}
/>
</TabsContent>
Expand Down
15 changes: 13 additions & 2 deletions src/features/positions/components/adapter-managed-exposure.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -85,6 +94,8 @@ function AdapterExposure({ adapterAddress, adapterType, chainId, period }: Adapt
isEarningsLoading={isEarningsLoading}
actualBlockData={actualBlockData}
snapshotsByChain={snapshotsByChain}
endSnapshotsByChain={endSnapshotsByChain}
earningsRangesByChain={earningsRangesByChain}
/>
)}

Expand Down
25 changes: 17 additions & 8 deletions src/features/positions/components/positions-period-settings.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -39,15 +41,22 @@ export function PositionsPeriodSettingsButton({ period, onPeriodChange, classNam

return (
<>
<Button
variant="surface"
size="xs"
className={cn('h-5 min-w-0 px-1.5 text-[11px] font-normal leading-none text-secondary shadow-none', className)}
onClick={onOpen}
aria-label={`Analytics period settings, currently ${selectedLabel}`}
<Tooltip
content={formatEarningsTimeRange(period, getEarningsTimeRange(period))}
placement="bottom"
>
{selectedShortLabel}
</Button>
<span className="inline-flex">
<Button
variant="surface"
size="xs"
className={cn('h-5 min-w-0 px-1.5 text-[11px] font-normal leading-none text-secondary shadow-none', className)}
onClick={onOpen}
aria-label={`Analytics period settings, currently ${selectedLabel}`}
>
{selectedShortLabel}
</Button>
</span>
</Tooltip>
<Modal
isOpen={isOpen}
onOpenChange={onOpenChange}
Expand Down
Loading