From ff1a1bfde7c8c6e48beae7e14087fd88377bd386 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 27 Mar 2026 00:28:32 +0800 Subject: [PATCH] feat: pro data history --- AGENTS.md | 2 + src/components/shared/account-identity.tsx | 43 +- src/data-sources/monarch-api/index.ts | 7 + .../monarch-api/market-tx-contexts.ts | 1028 +++++++++++++++++ .../components/borrows-table.tsx | 82 +- .../components/liquidations-table.tsx | 26 +- .../components/pro-activities-table.tsx | 824 +++++++++++++ .../components/supplies-table.tsx | 73 +- src/features/market-detail/market-view.tsx | 68 +- .../components/supplied-markets-detail.tsx | 49 +- .../components/vault-allocation-detail.tsx | 28 +- src/graphql/envio-queries.ts | 273 +++++ src/hooks/useMarketTxContexts.ts | 63 + src/stores/useMarketDetailPreferences.ts | 14 +- 14 files changed, 2433 insertions(+), 147 deletions(-) create mode 100644 src/data-sources/monarch-api/market-tx-contexts.ts create mode 100644 src/features/market-detail/components/pro-activities-table.tsx create mode 100644 src/hooks/useMarketTxContexts.ts diff --git a/AGENTS.md b/AGENTS.md index f0c908c7..ecb79a21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,6 +194,8 @@ When touching transaction and position flows, validation MUST include all releva 56. **Market shell parity integrity**: single-market market-shell fetchers must apply the same canonical registry guards as list fetchers, so blocked or malformed markets cannot re-enter through detail-path reads. In per-chain provider fallback chains, treat empty non-authoritative results the same as provider failure and continue to the next provider instead of short-circuiting fallback for that chain. 57. **Liquidation-history source integrity**: market-list liquidation badges must resolve from authoritative indexed liquidation events keyed by canonical `chainId + market.uniqueKey`, not only from auxiliary metrics/monitoring feeds. A metrics outage, timeout, or stale response must not erase historical liquidation indicators for otherwise healthy market rows. 58. **Historical-rate loading signal integrity**: market-list 24h/7d/30d rate columns must distinguish “RPC/archive enrichment still loading” from “historical rate unavailable”. While shared historical-rate enrichment is unresolved, show a loading state; reserve `—` only for settled null/unsupported values after the enrichment boundary completes. +59. **Monarch transaction-context integrity**: any advanced market/account activity view that labels transaction intent from Monarch `TxContext` metadata must preserve that tx-level context at the ingestion chokepoint, render direct market legs and vault-context labels from the same normalized transaction object, and fail closed when Monarch context metadata is unavailable. Use `vaultTxType` together with the boolean context flags when normalizing user vault deposits/withdrawals, and when Monarch flags a user deposit/withdraw but omits the explicit vault-event relation, preserve the intermediary from the market-leg beneficiary instead of collapsing the row to a direct user action or showing the intermediary as the user. In market-scoped views, discover recent contexts from the indexed market event tables first (`Morpho_*`, `MetaMorphoVault_Reallocate*`, etc.) and hydrate `TxContext` by known ids/hashes; do not page market feeds through `TxContext(where: { _or: [nested relation filters...] })`, because large markets can time out even at `limit: 1`. Current-market borrow/repay legs must take precedence over tx-level mixed vault deposit/withdraw flags when choosing the primary action, but if the same current-market context contains mixed **loan** actions (for example supply + borrow or withdraw + repay), classify it as `Batched` instead of collapsing it to `Borrow`/`Repay`; only collateral-assist legs may remain folded into the simple borrow/repay label. For those mixed loan rows, the primary row account must still resolve from the current-market borrow/repay participant when present, rather than being blank or inheriting the vault-side actor. Do not treat `hasVaultRebalance` or `vaultTxType` alone as sufficient to label a row `Rebalance`: a vault rebalance must be backed by actual Morpho market `withdraw` and `supply` legs in the same tx context across different markets, while single-kind Morpho `supply`-only or `withdraw`-only contexts must remain on the supply/withdraw or vault deposit/withdraw path. Pure collateral-only current-market contexts with no loan legs must not be labeled `Batched`; classify them as low-priority `Others` instead. Do not silently relabel Morpho/subgraph fallback events as contextualized vault deposits, withdrawals, or rebalances. +60. **Advanced-activity signal integrity**: contextual activity rows must expose exactly one primary user-facing action label per transaction row and must not surface redundant provider/agent metadata when that does not change the user decision. Flow rendering must match the transaction shape: market-to-market only for rebalances, while non-rebalance market activities should surface the normalized processed-event list with the triggering actor, optional intermediary, action, and token amount instead of a synthetic linear path that hides mixed behavior. Summary `FLOW` math and rebalance `From / To` totals must aggregate **Morpho market loan legs only** (`supply`, `withdraw`, `borrow`, `repay`) and must ignore vault-layer events such as vault deposits, withdrawals, or legacy reallocate summaries, even when Monarch exposes both layers for the same transaction. In expanded processed-event lists, prefer the richer vault/reallocate row over a duplicate underlying Morpho market `supply`/`withdraw` row when both describe the same current-market move, keep the market label there minimal (for example `This Market`) instead of repeating full market identity chrome, style that current-market chip as the same neutral peer badge used for account/address labels rather than a highlighted status badge, and keep the action badge on the core market action (`Supply`/`Withdraw`) rather than transport-layer names like `Reallocate Supply`. In row-level `FLOW`, batched rows with opposing current-market loan flows must compress to one figure: show the signed net amount when non-zero, and show neutral absolute volume when the opposing flows net to zero, rather than stacking cancelling `+/-` amounts that make a high-volume row look insignificant. For `Basic | Pro` mode selection, show the Pro-specific source/reward cue on the toggle itself using the same shared spark affordance used by APY reward cells, and do not duplicate that source signal inside the pro table header. When a contextualized transaction has no market-to-market loan flow (for example collateral-only or other non-loan processed legs), the expanded view must fall back to the normalized processed-event list instead of rendering empty `From / To` groups. When the current market has multiple relevant processed legs in one transaction, keep the compact top-level flow summary but also show the normalized processed-event list in the expansion so mixed supply/borrow/collateral actions are not hidden behind one summary line. Keep expansion content focused on that flow, not internal classifier details. ### REQUIRED: Regression Rule Capture diff --git a/src/components/shared/account-identity.tsx b/src/components/shared/account-identity.tsx index fcbf603e..ce3fe7e7 100644 --- a/src/components/shared/account-identity.tsx +++ b/src/components/shared/account-identity.tsx @@ -19,6 +19,8 @@ import { getExplorerURL } from '@/utils/external'; import { SupportedNetworks } from '@/utils/networks'; import type { Address } from 'viem'; +const ACCOUNT_IDENTITY_LABEL_MAX_WIDTH_CLASS = 'max-w-[22rem]'; + type AccountIdentityProps = { address: Address; chainId: number; @@ -96,11 +98,25 @@ export function AccountIdentity({ } }, [address, toast]); + const labelClasses = clsx('min-w-0 truncate', ACCOUNT_IDENTITY_LABEL_MAX_WIDTH_CLASS); + // Badge variant - minimal inline badge (no avatar) if (variant === 'badge') { const content = ( <> - {vaultName ? {vaultName} : } + {vaultName ? ( + + {vaultName} + + ) : ( + + )} {linkTo === 'explorer' && } {showCopy && ( - - {vaultName ? {vaultName} : } + + {vaultName ? ( + + {vaultName} + + ) : ( + + )} {linkTo === 'explorer' && } {showCopy && ( @@ -301,7 +329,14 @@ export function AccountIdentity({ {/* Vault name badge (if it's a vault) */} {vaultName && ( - {vaultName} + + + {vaultName} + + )} {/* Explorer link */} diff --git a/src/data-sources/monarch-api/index.ts b/src/data-sources/monarch-api/index.ts index d90a6c06..52e309ee 100644 --- a/src/data-sources/monarch-api/index.ts +++ b/src/data-sources/monarch-api/index.ts @@ -13,6 +13,13 @@ export { fetchMonarchMarketSuppliers, fetchMonarchMarketSupplies, } from './market-detail'; +export { + fetchMonarchMarketTxContexts, + type MarketProActivity, + type MarketProActivityKind, + type MarketProActivityLeg, + type PaginatedMarketProActivities, +} from './market-tx-contexts'; export { fetchMonarchTransactions, type MonarchSupplyTransaction, diff --git a/src/data-sources/monarch-api/market-tx-contexts.ts b/src/data-sources/monarch-api/market-tx-contexts.ts new file mode 100644 index 00000000..15dd69ea --- /dev/null +++ b/src/data-sources/monarch-api/market-tx-contexts.ts @@ -0,0 +1,1028 @@ +import { isAddress } from 'viem'; +import { envioMarketTxContextSeedsQuery, envioMarketTxContextsByIdsQuery } from '@/graphql/envio-queries'; +import { monarchGraphqlFetcher } from './fetchers'; + +const MONARCH_MARKET_TX_CONTEXTS_TIMEOUT_MS = 15_000; +const MARKET_TX_CONTEXT_DISCOVERY_BATCH_SIZE_FLOOR = 24; +const MARKET_TX_CONTEXT_DISCOVERY_BATCH_SIZE_MULTIPLIER = 4; +const MARKET_TX_CONTEXT_DISCOVERY_EXTRA_ROUNDS = 2; + +type MorphoMarketLegKind = 'supply' | 'withdraw' | 'borrow' | 'repay' | 'supplyCollateral' | 'withdrawCollateral'; +type VaultLegKind = 'vaultDeposit' | 'vaultWithdraw'; +type VaultRebalanceLegKind = + | 'vaultAllocate' + | 'vaultDeallocate' + | 'vaultForceDeallocate' + | 'legacyVaultReallocateSupply' + | 'legacyVaultReallocateWithdraw'; +type MarketProActivityLegKind = MorphoMarketLegKind | VaultLegKind | VaultRebalanceLegKind; + +type MonarchMorphoMarketLegRow = { + market_id: string; + assets: string; + onBehalf: string; + caller: string; + receiver?: string; + isMonarch: boolean; +}; + +type MonarchVaultDepositRow = { + id: string; + vault_id: string; + onBehalf: string; + sender: string; + assets: string; + shares: string; + isMonarch: boolean; +}; + +type MonarchVaultWithdrawRow = { + id: string; + vault_id: string; + onBehalf: string; + sender: string; + receiver: string; + assets: string; + shares: string; + isMonarch: boolean; +}; + +type MonarchLegacyVaultDepositRow = { + id: string; + vaultAddress: string; + owner: string; + sender: string; + assets: string; + shares: string; + isMonarch: boolean; +}; + +type MonarchLegacyVaultWithdrawRow = { + id: string; + vaultAddress: string; + owner: string; + sender: string; + receiver: string; + assets: string; + shares: string; + isMonarch: boolean; +}; + +type MonarchVaultRebalanceRow = { + id: string; + vault_id: string; + sender: string; + assets: string; + change: string; + isMonarch: boolean; +}; + +type MonarchVaultForceDeallocateRow = { + id: string; + vault_id: string; + onBehalf: string; + sender: string; + assets: string; + penaltyAssets: string; + isMonarch: boolean; +}; + +type MonarchLegacyVaultReallocateSupplyRow = { + id: string; + vaultAddress: string; + market_id: string; + suppliedAssets: string; + suppliedShares: string; + caller: string; + isMonarch: boolean; +}; + +type MonarchLegacyVaultReallocateWithdrawRow = { + id: string; + vaultAddress: string; + market_id: string; + withdrawnAssets: string; + withdrawnShares: string; + caller: string; + isMonarch: boolean; +}; + +type MonarchTxContextRow = { + id: string; + chainId: number; + timestamp: string | number; + txHash: string; + vaultTxType: string; + hasVaultUserDeposit: boolean; + hasVaultUserWithdraw: boolean; + hasVaultRebalance: boolean; + morphoSupplies: MonarchMorphoMarketLegRow[]; + morphoWithdraws: MonarchMorphoMarketLegRow[]; + morphoBorrows: MonarchMorphoMarketLegRow[]; + morphoRepays: MonarchMorphoMarketLegRow[]; + morphoSupplyCollaterals: MonarchMorphoMarketLegRow[]; + morphoWithdrawCollaterals: MonarchMorphoMarketLegRow[]; + vaultDeposits: MonarchVaultDepositRow[]; + vaultWithdrawals: MonarchVaultWithdrawRow[]; + vaultAllocations: MonarchVaultRebalanceRow[]; + vaultDeallocations: MonarchVaultRebalanceRow[]; + vaultForceDeallocations: MonarchVaultForceDeallocateRow[]; + legacyVaultDeposits: MonarchLegacyVaultDepositRow[]; + legacyVaultWithdrawals: MonarchLegacyVaultWithdrawRow[]; + legacyVaultReallocateSupplies: MonarchLegacyVaultReallocateSupplyRow[]; + legacyVaultReallocateWithdrawals: MonarchLegacyVaultReallocateWithdrawRow[]; +}; + +type MonarchTxContextRefRow = { + id: string; + txHash: string; + timestamp: string | number; +}; + +type MonarchMarketTxContextSeedRow = { + id: string; + txHash: string; + timestamp: string | number; + txContext?: MonarchTxContextRefRow | null; +}; + +type MonarchMarketTxContextSeedsPageResponse = { + data?: { + supplies?: MonarchMarketTxContextSeedRow[]; + withdraws?: MonarchMarketTxContextSeedRow[]; + borrows?: MonarchMarketTxContextSeedRow[]; + repays?: MonarchMarketTxContextSeedRow[]; + supplyCollaterals?: MonarchMarketTxContextSeedRow[]; + withdrawCollaterals?: MonarchMarketTxContextSeedRow[]; + legacyReallocateSupplies?: MonarchMarketTxContextSeedRow[]; + legacyReallocateWithdrawals?: MonarchMarketTxContextSeedRow[]; + }; +}; + +type MonarchMarketTxContextsPageResponse = { + data?: { + TxContext?: MonarchTxContextRow[]; + }; +}; + +type MarketTxContextSeed = { + contextId: string; + hash: string; + timestamp: number; +}; + +export type MarketProActivityKind = + | 'directSupply' + | 'directWithdraw' + | 'directBorrow' + | 'directRepay' + | 'vaultDeposit' + | 'vaultWithdraw' + | 'vaultRebalance' + | 'monarchTx' + | 'others' + | 'batched'; + +type MarketProImpactKind = 'supply' | 'withdraw' | 'borrow' | 'repay' | 'supplyCollateral' | 'withdrawCollateral'; + +export type MarketProActivityLeg = { + id: string; + kind: MarketProActivityLegKind; + source: 'morpho' | 'vault-v2' | 'legacy-vault'; + marketId?: string; + amount: string; + assetType: 'loan' | 'collateral'; + isMonarch: boolean; + positionAddress?: string; + actorAddress?: string; + receiverAddress?: string; + vaultAddress?: string; + isCurrentMarket: boolean; +}; + +export type MarketProActivity = { + id: string; + hash: string; + chainId: number; + timestamp: number; + kind: MarketProActivityKind; + isMonarch: boolean; + actorAddress?: string; + vaultAddress?: string; + amount: string | null; + amountAssetType: 'loan' | 'collateral' | null; + primaryLegKind: MarketProImpactKind | null; + touchedMarketIds: string[]; + fromMarketIds: string[]; + toMarketIds: string[]; + currentMarketLegCount: number; + legs: MarketProActivityLeg[]; +}; + +export type PaginatedMarketProActivities = { + items: MarketProActivity[]; + totalCount: number; + hasNextPage: boolean; +}; + +const toTimestamp = (value: string | number): number => (typeof value === 'number' ? value : Number.parseInt(value, 10)); + +const sameMarket = (left: string | undefined, right: string): boolean => left?.toLowerCase() === right.toLowerCase(); + +const isMorphoMarketLegKind = (kind: MarketProActivityLegKind): kind is MorphoMarketLegKind => { + return ( + kind === 'supply' || + kind === 'withdraw' || + kind === 'borrow' || + kind === 'repay' || + kind === 'supplyCollateral' || + kind === 'withdrawCollateral' + ); +}; + +const isSupplyLikeLeg = (leg: MarketProActivityLeg): boolean => leg.kind === 'supply' || leg.kind === 'legacyVaultReallocateSupply'; + +const isWithdrawLikeLeg = (leg: MarketProActivityLeg): boolean => + leg.kind === 'withdraw' || leg.kind === 'legacyVaultReallocateWithdraw'; + +const hasDistinctMarketPair = (fromMarketIds: Set, toMarketIds: Set): boolean => { + for (const fromMarketId of fromMarketIds) { + for (const toMarketId of toMarketIds) { + if (fromMarketId !== toMarketId) { + return true; + } + } + } + + return false; +}; + +const isTrueVaultRebalanceContext = (context: MonarchTxContextRow): boolean => { + const hasVaultContext = + context.hasVaultRebalance || + context.vaultTxType === 'rebalance' || + context.vaultTxType === 'mixed' || + context.hasVaultUserDeposit || + context.hasVaultUserWithdraw || + context.vaultDeposits.length > 0 || + context.vaultWithdrawals.length > 0 || + context.vaultAllocations.length > 0 || + context.vaultDeallocations.length > 0 || + context.vaultForceDeallocations.length > 0 || + context.legacyVaultDeposits.length > 0 || + context.legacyVaultWithdrawals.length > 0 || + context.legacyVaultReallocateSupplies.length > 0 || + context.legacyVaultReallocateWithdrawals.length > 0; + + if (!hasVaultContext) { + return false; + } + + const supplyMarketIds = new Set(context.morphoSupplies.map((leg) => leg.market_id.toLowerCase())); + const withdrawMarketIds = new Set(context.morphoWithdraws.map((leg) => leg.market_id.toLowerCase())); + + return supplyMarketIds.size > 0 && withdrawMarketIds.size > 0 && hasDistinctMarketPair(withdrawMarketIds, supplyMarketIds); +}; + +const uniqueMarketIds = (legs: MarketProActivityLeg[]): string[] => { + const seen = new Set(); + const marketIds: string[] = []; + + for (const leg of legs) { + if (!leg.marketId) { + continue; + } + + const normalizedMarketId = leg.marketId.toLowerCase(); + if (seen.has(normalizedMarketId)) { + continue; + } + + seen.add(normalizedMarketId); + marketIds.push(leg.marketId); + } + + return marketIds; +}; + +const sumAssets = (legs: MarketProActivityLeg[]): bigint => { + let total = 0n; + + for (const leg of legs) { + total += BigInt(leg.amount); + } + + return total; +}; + +const toOptionalAddress = (value: string | undefined | null): string | undefined => { + if (!value || !isAddress(value)) { + return undefined; + } + + return value; +}; + +const parseVaultIdAddress = (vaultId: string | undefined): string | undefined => { + if (!vaultId) { + return undefined; + } + + const [, address] = vaultId.split('_'); + return toOptionalAddress(address); +}; + +const mapMorphoLegs = ( + rows: MonarchMorphoMarketLegRow[] | undefined, + kind: MorphoMarketLegKind, + assetType: 'loan' | 'collateral', + currentMarketId: string, +): MarketProActivityLeg[] => { + return (rows ?? []).map((row, index) => ({ + id: `${kind}-${row.market_id}-${row.assets}-${row.caller}-${index}`, + kind, + source: 'morpho', + marketId: row.market_id, + amount: row.assets, + assetType, + isMonarch: row.isMonarch, + positionAddress: toOptionalAddress(row.onBehalf), + actorAddress: toOptionalAddress(row.caller) ?? toOptionalAddress(row.onBehalf), + receiverAddress: toOptionalAddress(row.receiver), + isCurrentMarket: sameMarket(row.market_id, currentMarketId), + })); +}; + +const mapVaultDepositLegs = ( + rows: MonarchVaultDepositRow[] | undefined, + source: 'vault-v2' | 'legacy-vault', +): MarketProActivityLeg[] => { + return (rows ?? []).map((row) => ({ + id: row.id, + kind: 'vaultDeposit', + source, + amount: row.assets, + assetType: 'loan', + isMonarch: row.isMonarch, + actorAddress: toOptionalAddress(row.sender) ?? toOptionalAddress(row.onBehalf), + positionAddress: toOptionalAddress(row.onBehalf), + vaultAddress: parseVaultIdAddress(row.vault_id), + isCurrentMarket: false, + })); +}; + +const mapVaultWithdrawLegs = ( + rows: MonarchVaultWithdrawRow[] | undefined, + source: 'vault-v2' | 'legacy-vault', +): MarketProActivityLeg[] => { + return (rows ?? []).map((row) => ({ + id: row.id, + kind: 'vaultWithdraw', + source, + amount: row.assets, + assetType: 'loan', + isMonarch: row.isMonarch, + actorAddress: toOptionalAddress(row.sender) ?? toOptionalAddress(row.onBehalf), + positionAddress: toOptionalAddress(row.onBehalf), + receiverAddress: toOptionalAddress(row.receiver), + vaultAddress: parseVaultIdAddress(row.vault_id), + isCurrentMarket: false, + })); +}; + +const mapLegacyVaultDepositLegs = (rows: MonarchLegacyVaultDepositRow[] | undefined): MarketProActivityLeg[] => { + return (rows ?? []).map((row) => ({ + id: row.id, + kind: 'vaultDeposit', + source: 'legacy-vault', + amount: row.assets, + assetType: 'loan', + isMonarch: row.isMonarch, + actorAddress: toOptionalAddress(row.sender) ?? toOptionalAddress(row.owner), + positionAddress: toOptionalAddress(row.owner), + vaultAddress: toOptionalAddress(row.vaultAddress), + isCurrentMarket: false, + })); +}; + +const mapLegacyVaultWithdrawLegs = (rows: MonarchLegacyVaultWithdrawRow[] | undefined): MarketProActivityLeg[] => { + return (rows ?? []).map((row) => ({ + id: row.id, + kind: 'vaultWithdraw', + source: 'legacy-vault', + amount: row.assets, + assetType: 'loan', + isMonarch: row.isMonarch, + actorAddress: toOptionalAddress(row.sender) ?? toOptionalAddress(row.owner), + positionAddress: toOptionalAddress(row.owner), + receiverAddress: toOptionalAddress(row.receiver), + vaultAddress: toOptionalAddress(row.vaultAddress), + isCurrentMarket: false, + })); +}; + +const mapVaultAllocationLegs = ( + rows: MonarchVaultRebalanceRow[] | undefined, + kind: 'vaultAllocate' | 'vaultDeallocate', +): MarketProActivityLeg[] => { + return (rows ?? []).map((row) => ({ + id: row.id, + kind, + source: 'vault-v2', + amount: row.assets, + assetType: 'loan', + isMonarch: row.isMonarch, + actorAddress: toOptionalAddress(row.sender), + vaultAddress: parseVaultIdAddress(row.vault_id), + isCurrentMarket: false, + })); +}; + +const mapVaultForceDeallocateLegs = (rows: MonarchVaultForceDeallocateRow[] | undefined): MarketProActivityLeg[] => { + return (rows ?? []).map((row) => ({ + id: row.id, + kind: 'vaultForceDeallocate', + source: 'vault-v2', + amount: row.assets, + assetType: 'loan', + isMonarch: row.isMonarch, + actorAddress: toOptionalAddress(row.sender) ?? toOptionalAddress(row.onBehalf), + positionAddress: toOptionalAddress(row.onBehalf), + vaultAddress: parseVaultIdAddress(row.vault_id), + isCurrentMarket: false, + })); +}; + +const mapLegacyVaultReallocateSupplyLegs = ( + rows: MonarchLegacyVaultReallocateSupplyRow[] | undefined, + currentMarketId: string, +): MarketProActivityLeg[] => { + return (rows ?? []).map((row) => ({ + id: row.id, + kind: 'legacyVaultReallocateSupply', + source: 'legacy-vault', + marketId: row.market_id, + amount: row.suppliedAssets, + assetType: 'loan', + isMonarch: row.isMonarch, + actorAddress: toOptionalAddress(row.caller), + vaultAddress: toOptionalAddress(row.vaultAddress), + isCurrentMarket: sameMarket(row.market_id, currentMarketId), + })); +}; + +const mapLegacyVaultReallocateWithdrawLegs = ( + rows: MonarchLegacyVaultReallocateWithdrawRow[] | undefined, + currentMarketId: string, +): MarketProActivityLeg[] => { + return (rows ?? []).map((row) => ({ + id: row.id, + kind: 'legacyVaultReallocateWithdraw', + source: 'legacy-vault', + marketId: row.market_id, + amount: row.withdrawnAssets, + assetType: 'loan', + isMonarch: row.isMonarch, + actorAddress: toOptionalAddress(row.caller), + vaultAddress: toOptionalAddress(row.vaultAddress), + isCurrentMarket: sameMarket(row.market_id, currentMarketId), + })); +}; + +const getCurrentMarketImpactLegs = (legs: MarketProActivityLeg[]): MarketProActivityLeg[] => { + return legs.filter((leg) => { + if (!leg.isCurrentMarket) { + return false; + } + + return isMorphoMarketLegKind(leg.kind) || leg.kind === 'legacyVaultReallocateSupply' || leg.kind === 'legacyVaultReallocateWithdraw'; + }); +}; + +const selectLargestLeg = (legs: MarketProActivityLeg[]): MarketProActivityLeg | null => { + let largest: MarketProActivityLeg | null = null; + + for (const leg of legs) { + if (!largest || BigInt(leg.amount) > BigInt(largest.amount)) { + largest = leg; + } + } + + return largest; +}; + +const selectPrimaryLegForKind = ( + kind: MarketProActivityKind, + currentMarketLegs: MarketProActivityLeg[], +): MarketProActivityLeg | undefined => { + if (kind === 'directBorrow') { + return currentMarketLegs.find((leg) => leg.kind === 'borrow'); + } + + if (kind === 'directRepay') { + return currentMarketLegs.find((leg) => leg.kind === 'repay'); + } + + if (kind === 'directSupply' || kind === 'vaultDeposit') { + return currentMarketLegs.find((leg) => leg.kind === 'supply'); + } + + if (kind === 'directWithdraw' || kind === 'vaultWithdraw') { + return currentMarketLegs.find((leg) => leg.kind === 'withdraw'); + } + + return undefined; +}; + +const deriveKind = ( + context: MonarchTxContextRow, + currentMarketLegs: MarketProActivityLeg[], + isMonarch: boolean, +): MarketProActivityKind => { + const isVaultUserDepositContext = context.hasVaultUserDeposit || context.vaultTxType === 'user_deposit'; + const isVaultUserWithdrawContext = context.hasVaultUserWithdraw || context.vaultTxType === 'user_withdraw'; + const isVaultRebalanceContext = isTrueVaultRebalanceContext(context); + const currentActionKinds = new Set(currentMarketLegs.map((leg) => leg.kind)); + const hasSupply = currentMarketLegs.some((leg) => leg.kind === 'supply'); + const hasWithdraw = currentMarketLegs.some((leg) => leg.kind === 'withdraw'); + const hasBorrow = currentMarketLegs.some((leg) => leg.kind === 'borrow'); + const hasRepay = currentMarketLegs.some((leg) => leg.kind === 'repay'); + const hasSupplyCollateral = currentMarketLegs.some((leg) => leg.kind === 'supplyCollateral'); + const hasWithdrawCollateral = currentMarketLegs.some((leg) => leg.kind === 'withdrawCollateral'); + const loanActionKinds = new Set(); + + if (hasSupply) { + loanActionKinds.add('supply'); + } + + if (hasWithdraw) { + loanActionKinds.add('withdraw'); + } + + if (hasBorrow) { + loanActionKinds.add('borrow'); + } + + if (hasRepay) { + loanActionKinds.add('repay'); + } + + const hasMixedLoanActions = loanActionKinds.size > 1; + + if (hasMixedLoanActions) { + return 'batched'; + } + + if (hasBorrow && !hasRepay && !hasSupply && !hasWithdraw && !hasWithdrawCollateral) { + return 'directBorrow'; + } + + if (hasRepay && !hasBorrow && !hasSupply && !hasWithdraw && !hasSupplyCollateral) { + return 'directRepay'; + } + + if (isVaultUserDepositContext && !isVaultUserWithdrawContext && !isVaultRebalanceContext && currentActionKinds.size === 1 && hasSupply) { + return 'vaultDeposit'; + } + + if (isVaultUserWithdrawContext && !isVaultUserDepositContext && !isVaultRebalanceContext && currentActionKinds.size === 1 && hasWithdraw) { + return 'vaultWithdraw'; + } + + if (isVaultRebalanceContext) { + if (hasBorrow || hasRepay) { + return 'batched'; + } + + return isMonarch ? 'monarchTx' : 'vaultRebalance'; + } + + if (!hasSupply && !hasWithdraw && !hasBorrow && !hasRepay && (hasSupplyCollateral || hasWithdrawCollateral)) { + return 'others'; + } + + if (currentActionKinds.size === 1 && hasSupply && !hasWithdraw) { + return 'directSupply'; + } + + if (currentActionKinds.size === 1 && hasWithdraw && !hasSupply) { + return 'directWithdraw'; + } + + return 'batched'; +}; + +const deriveActorAddress = ( + context: MonarchTxContextRow, + currentMarketLegs: MarketProActivityLeg[], + kind: MarketProActivityKind, +): string | undefined => { + if (kind === 'batched') { + const borrowLeg = currentMarketLegs.find((leg) => leg.kind === 'borrow'); + if (borrowLeg) { + return borrowLeg.positionAddress ?? borrowLeg.actorAddress; + } + + const repayLeg = currentMarketLegs.find((leg) => leg.kind === 'repay'); + if (repayLeg) { + return repayLeg.positionAddress ?? repayLeg.actorAddress; + } + + return currentMarketLegs[0]?.positionAddress ?? currentMarketLegs[0]?.actorAddress; + } + + const primaryLeg = selectPrimaryLegForKind(kind, currentMarketLegs); + if (primaryLeg && (kind === 'directBorrow' || kind === 'directRepay' || kind === 'directSupply' || kind === 'directWithdraw')) { + return primaryLeg.positionAddress ?? primaryLeg.actorAddress; + } + + const isVaultUserDepositContext = context.hasVaultUserDeposit || context.vaultTxType === 'user_deposit'; + const isVaultUserWithdrawContext = context.hasVaultUserWithdraw || context.vaultTxType === 'user_withdraw'; + + if (kind === 'vaultDeposit' && isVaultUserDepositContext) { + return ( + toOptionalAddress(context.vaultDeposits[0]?.onBehalf) ?? + toOptionalAddress(context.legacyVaultDeposits[0]?.owner) ?? + toOptionalAddress(context.vaultDeposits[0]?.sender) ?? + toOptionalAddress(context.legacyVaultDeposits[0]?.sender) ?? + primaryLeg?.actorAddress + ); + } + + if (kind === 'vaultWithdraw' && isVaultUserWithdrawContext) { + return ( + toOptionalAddress(context.vaultWithdrawals[0]?.onBehalf) ?? + toOptionalAddress(context.legacyVaultWithdrawals[0]?.owner) ?? + toOptionalAddress(context.vaultWithdrawals[0]?.receiver) ?? + toOptionalAddress(context.legacyVaultWithdrawals[0]?.receiver) ?? + toOptionalAddress(context.vaultWithdrawals[0]?.sender) ?? + toOptionalAddress(context.legacyVaultWithdrawals[0]?.sender) ?? + primaryLeg?.actorAddress + ); + } + + if (context.hasVaultRebalance) { + return ( + toOptionalAddress(context.legacyVaultReallocateSupplies[0]?.caller) ?? + toOptionalAddress(context.legacyVaultReallocateWithdrawals[0]?.caller) ?? + toOptionalAddress(context.vaultAllocations[0]?.sender) ?? + toOptionalAddress(context.vaultDeallocations[0]?.sender) ?? + toOptionalAddress(context.vaultForceDeallocations[0]?.onBehalf) ?? + toOptionalAddress(context.vaultForceDeallocations[0]?.sender) ?? + currentMarketLegs[0]?.actorAddress ?? + currentMarketLegs[0]?.positionAddress + ); + } + + return currentMarketLegs[0]?.positionAddress ?? currentMarketLegs[0]?.actorAddress; +}; + +const deriveVaultAddress = ( + context: MonarchTxContextRow, + currentMarketLegs: MarketProActivityLeg[], + kind: MarketProActivityKind, +): string | undefined => { + const explicitVaultAddress = + parseVaultIdAddress(context.vaultDeposits[0]?.vault_id) ?? + parseVaultIdAddress(context.vaultWithdrawals[0]?.vault_id) ?? + parseVaultIdAddress(context.vaultAllocations[0]?.vault_id) ?? + parseVaultIdAddress(context.vaultDeallocations[0]?.vault_id) ?? + parseVaultIdAddress(context.vaultForceDeallocations[0]?.vault_id) ?? + toOptionalAddress(context.legacyVaultDeposits[0]?.vaultAddress) ?? + toOptionalAddress(context.legacyVaultWithdrawals[0]?.vaultAddress) ?? + toOptionalAddress(context.legacyVaultReallocateSupplies[0]?.vaultAddress) ?? + toOptionalAddress(context.legacyVaultReallocateWithdrawals[0]?.vaultAddress); + + if (explicitVaultAddress) { + return explicitVaultAddress; + } + + const isVaultUserDepositContext = context.hasVaultUserDeposit || context.vaultTxType === 'user_deposit'; + const isVaultUserWithdrawContext = context.hasVaultUserWithdraw || context.vaultTxType === 'user_withdraw'; + + if (kind === 'vaultDeposit' && isVaultUserDepositContext) { + return selectPrimaryLegForKind(kind, currentMarketLegs)?.positionAddress ?? currentMarketLegs[0]?.positionAddress ?? currentMarketLegs[0]?.actorAddress; + } + + if (kind === 'vaultWithdraw' && isVaultUserWithdrawContext) { + return selectPrimaryLegForKind(kind, currentMarketLegs)?.positionAddress ?? currentMarketLegs[0]?.positionAddress ?? currentMarketLegs[0]?.actorAddress; + } + + return undefined; +}; + +const derivePrimaryImpact = ( + kind: MarketProActivityKind, + currentMarketLegs: MarketProActivityLeg[], +): { amount: string | null; amountAssetType: 'loan' | 'collateral' | null; primaryLegKind: MarketProImpactKind | null } => { + const supplyLegs = currentMarketLegs.filter((leg) => isSupplyLikeLeg(leg)); + const withdrawLegs = currentMarketLegs.filter((leg) => isWithdrawLikeLeg(leg)); + const borrowLegs = currentMarketLegs.filter((leg) => leg.kind === 'borrow'); + const repayLegs = currentMarketLegs.filter((leg) => leg.kind === 'repay'); + const supplyCollateralLegs = currentMarketLegs.filter((leg) => leg.kind === 'supplyCollateral'); + const withdrawCollateralLegs = currentMarketLegs.filter((leg) => leg.kind === 'withdrawCollateral'); + + if (kind === 'vaultDeposit' || kind === 'directSupply') { + const amount = sumAssets(supplyLegs); + return amount > 0n ? { amount: amount.toString(), amountAssetType: 'loan', primaryLegKind: 'supply' } : { amount: null, amountAssetType: null, primaryLegKind: null }; + } + + if (kind === 'vaultWithdraw' || kind === 'directWithdraw') { + const amount = sumAssets(withdrawLegs); + return amount > 0n ? { amount: amount.toString(), amountAssetType: 'loan', primaryLegKind: 'withdraw' } : { amount: null, amountAssetType: null, primaryLegKind: null }; + } + + if (kind === 'directBorrow') { + const amount = sumAssets(borrowLegs); + return amount > 0n ? { amount: amount.toString(), amountAssetType: 'loan', primaryLegKind: 'borrow' } : { amount: null, amountAssetType: null, primaryLegKind: null }; + } + + if (kind === 'directRepay') { + const amount = sumAssets(repayLegs); + return amount > 0n ? { amount: amount.toString(), amountAssetType: 'loan', primaryLegKind: 'repay' } : { amount: null, amountAssetType: null, primaryLegKind: null }; + } + + if (kind === 'vaultRebalance' || kind === 'monarchTx') { + const netSupply = sumAssets(supplyLegs); + const netWithdraw = sumAssets(withdrawLegs); + + if (netSupply > netWithdraw && netSupply > 0n) { + return { amount: netSupply.toString(), amountAssetType: 'loan', primaryLegKind: 'supply' }; + } + + if (netWithdraw > 0n) { + return { amount: netWithdraw.toString(), amountAssetType: 'loan', primaryLegKind: 'withdraw' }; + } + } + + if (supplyCollateralLegs.length > 0 && withdrawCollateralLegs.length === 0) { + const amount = sumAssets(supplyCollateralLegs); + return amount > 0n + ? { amount: amount.toString(), amountAssetType: 'collateral', primaryLegKind: 'supplyCollateral' } + : { amount: null, amountAssetType: null, primaryLegKind: null }; + } + + if (withdrawCollateralLegs.length > 0 && supplyCollateralLegs.length === 0) { + const amount = sumAssets(withdrawCollateralLegs); + return amount > 0n + ? { amount: amount.toString(), amountAssetType: 'collateral', primaryLegKind: 'withdrawCollateral' } + : { amount: null, amountAssetType: null, primaryLegKind: null }; + } + + const fallbackLeg = selectLargestLeg(currentMarketLegs); + + return { + amount: fallbackLeg?.amount ?? null, + amountAssetType: fallbackLeg?.assetType ?? null, + primaryLegKind: fallbackLeg && isMorphoMarketLegKind(fallbackLeg.kind) ? fallbackLeg.kind : null, + }; +}; + +const normalizeTxContext = (context: MonarchTxContextRow, marketId: string): MarketProActivity | null => { + const morphoLegs = [ + ...mapMorphoLegs(context.morphoSupplies, 'supply', 'loan', marketId), + ...mapMorphoLegs(context.morphoWithdraws, 'withdraw', 'loan', marketId), + ...mapMorphoLegs(context.morphoBorrows, 'borrow', 'loan', marketId), + ...mapMorphoLegs(context.morphoRepays, 'repay', 'loan', marketId), + ...mapMorphoLegs(context.morphoSupplyCollaterals, 'supplyCollateral', 'collateral', marketId), + ...mapMorphoLegs(context.morphoWithdrawCollaterals, 'withdrawCollateral', 'collateral', marketId), + ]; + const vaultLegs = [ + ...mapVaultDepositLegs(context.vaultDeposits, 'vault-v2'), + ...mapVaultWithdrawLegs(context.vaultWithdrawals, 'vault-v2'), + ...mapLegacyVaultDepositLegs(context.legacyVaultDeposits), + ...mapLegacyVaultWithdrawLegs(context.legacyVaultWithdrawals), + ...mapVaultAllocationLegs(context.vaultAllocations, 'vaultAllocate'), + ...mapVaultAllocationLegs(context.vaultDeallocations, 'vaultDeallocate'), + ...mapVaultForceDeallocateLegs(context.vaultForceDeallocations), + ...mapLegacyVaultReallocateSupplyLegs(context.legacyVaultReallocateSupplies, marketId), + ...mapLegacyVaultReallocateWithdrawLegs(context.legacyVaultReallocateWithdrawals, marketId), + ]; + const legs = [...morphoLegs, ...vaultLegs]; + const currentMarketLegs = getCurrentMarketImpactLegs(legs); + + if (currentMarketLegs.length === 0) { + return null; + } + + const isMonarch = legs.some((leg) => leg.isMonarch); + const kind = deriveKind(context, currentMarketLegs, isMonarch); + const actorAddress = deriveActorAddress(context, currentMarketLegs, kind); + const vaultAddress = deriveVaultAddress(context, currentMarketLegs, kind); + const impact = derivePrimaryImpact(kind, currentMarketLegs); + const marketLegs = legs.filter((leg) => leg.marketId); + const fromMarketIds = uniqueMarketIds(marketLegs.filter((leg) => isWithdrawLikeLeg(leg))); + const toMarketIds = uniqueMarketIds(marketLegs.filter((leg) => isSupplyLikeLeg(leg))); + const touchedMarketIds = uniqueMarketIds(marketLegs); + + return { + id: context.id, + hash: context.txHash, + chainId: context.chainId, + timestamp: toTimestamp(context.timestamp), + kind, + isMonarch, + actorAddress, + vaultAddress, + amount: impact.amount, + amountAssetType: impact.amountAssetType, + primaryLegKind: impact.primaryLegKind, + touchedMarketIds, + fromMarketIds, + toMarketIds, + currentMarketLegCount: currentMarketLegs.length, + legs, + }; +}; + +const compareMarketTxContextSeeds = (left: MarketTxContextSeed, right: MarketTxContextSeed): number => { + if (left.timestamp !== right.timestamp) { + return right.timestamp > left.timestamp ? 1 : -1; + } + + const hashCompare = right.hash.localeCompare(left.hash); + if (hashCompare !== 0) { + return hashCompare; + } + + return right.contextId.localeCompare(left.contextId); +}; + +const extractMarketTxContextSeeds = (response: MonarchMarketTxContextSeedsPageResponse): MarketTxContextSeed[] => { + const seedRows = [ + ...(response.data?.supplies ?? []), + ...(response.data?.withdraws ?? []), + ...(response.data?.borrows ?? []), + ...(response.data?.repays ?? []), + ...(response.data?.supplyCollaterals ?? []), + ...(response.data?.withdrawCollaterals ?? []), + ...(response.data?.legacyReallocateSupplies ?? []), + ...(response.data?.legacyReallocateWithdrawals ?? []), + ]; + + return seedRows.flatMap((row) => { + if (!row.txContext?.id) { + return []; + } + + return [ + { + contextId: row.txContext.id, + hash: row.txContext.txHash, + timestamp: toTimestamp(row.txContext.timestamp), + }, + ]; + }); +}; + +const getSeedPageHasMore = (response: MonarchMarketTxContextSeedsPageResponse, batchSize: number): boolean => { + const counts = [ + response.data?.supplies?.length ?? 0, + response.data?.withdraws?.length ?? 0, + response.data?.borrows?.length ?? 0, + response.data?.repays?.length ?? 0, + response.data?.supplyCollaterals?.length ?? 0, + response.data?.withdrawCollaterals?.length ?? 0, + response.data?.legacyReallocateSupplies?.length ?? 0, + response.data?.legacyReallocateWithdrawals?.length ?? 0, + ]; + + return counts.some((count) => count === batchSize); +}; + +const fetchMarketTxContextSeedsPage = async ( + marketId: string, + chainId: number, + limit: number, + offset: number, +): Promise => { + const controller = new AbortController(); + const timeoutId = globalThis.setTimeout(() => { + controller.abort(); + }, MONARCH_MARKET_TX_CONTEXTS_TIMEOUT_MS); + + try { + return await monarchGraphqlFetcher( + envioMarketTxContextSeedsQuery, + { + chainId, + marketId, + limit, + offset, + }, + { + signal: controller.signal, + }, + ); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Monarch market pro activity request timed out after ${MONARCH_MARKET_TX_CONTEXTS_TIMEOUT_MS}ms`); + } + + throw error; + } finally { + globalThis.clearTimeout(timeoutId); + } +}; + +const fetchMarketTxContextsByIds = async (ids: string[]): Promise => { + if (ids.length === 0) { + return []; + } + + const controller = new AbortController(); + const timeoutId = globalThis.setTimeout(() => { + controller.abort(); + }, MONARCH_MARKET_TX_CONTEXTS_TIMEOUT_MS); + + try { + const response = await monarchGraphqlFetcher( + envioMarketTxContextsByIdsQuery, + { + ids, + }, + { + signal: controller.signal, + }, + ); + + return response.data?.TxContext ?? []; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Monarch market pro activity request timed out after ${MONARCH_MARKET_TX_CONTEXTS_TIMEOUT_MS}ms`); + } + + throw error; + } finally { + globalThis.clearTimeout(timeoutId); + } +}; + +const discoverMarketTxContextSeeds = async ( + marketId: string, + chainId: number, + targetCount: number, + batchSize: number, + maxRounds: number, +): Promise<{ orderedSeeds: MarketTxContextSeed[]; hasMore: boolean }> => { + const seedsById = new Map(); + let offset = 0; + let hasMore = false; + + for (let round = 0; round < maxRounds; round += 1) { + const response = await fetchMarketTxContextSeedsPage(marketId, chainId, batchSize, offset); + const pageSeeds = extractMarketTxContextSeeds(response); + + for (const seed of pageSeeds) { + if (!seedsById.has(seed.contextId)) { + seedsById.set(seed.contextId, seed); + } + } + + hasMore = getSeedPageHasMore(response, batchSize); + + if (seedsById.size >= targetCount || !hasMore) { + break; + } + + offset += batchSize; + } + + return { + orderedSeeds: [...seedsById.values()].sort(compareMarketTxContextSeeds), + hasMore, + }; +}; + +export const fetchMonarchMarketTxContexts = async ( + marketId: string, + chainId: number, + first = 8, + skip = 0, +): Promise => { + const targetCount = skip + first + 1; + const batchSize = Math.max(MARKET_TX_CONTEXT_DISCOVERY_BATCH_SIZE_FLOOR, first * MARKET_TX_CONTEXT_DISCOVERY_BATCH_SIZE_MULTIPLIER); + const maxRounds = Math.max(3, Math.ceil(targetCount / batchSize) + MARKET_TX_CONTEXT_DISCOVERY_EXTRA_ROUNDS); + const { orderedSeeds, hasMore: discoveryHasMore } = await discoverMarketTxContextSeeds(marketId, chainId, targetCount, batchSize, maxRounds); + const targetSeedWindow = orderedSeeds.slice(0, targetCount); + const hydratedContexts = await fetchMarketTxContextsByIds(targetSeedWindow.map((seed) => seed.contextId)); + const normalizedById = new Map( + hydratedContexts + .map((context) => normalizeTxContext(context, marketId)) + .filter((context): context is MarketProActivity => context !== null) + .map((context) => [context.id, context] as const), + ); + const orderedActivities = targetSeedWindow.flatMap((seed) => { + const activity = normalizedById.get(seed.contextId); + return activity ? [activity] : []; + }); + const pageItems = orderedActivities.slice(skip, skip + first); + const hasNextPage = orderedActivities.length > skip + first || discoveryHasMore; + const totalCount = skip + pageItems.length + Number(hasNextPage); + + return { + items: pageItems, + totalCount, + hasNextPage, + }; +}; diff --git a/src/features/market-detail/components/borrows-table.tsx b/src/features/market-detail/components/borrows-table.tsx index 0fe18d9b..a6567187 100644 --- a/src/features/market-detail/components/borrows-table.tsx +++ b/src/features/market-detail/components/borrows-table.tsx @@ -13,6 +13,7 @@ import { TablePagination } from '@/components/shared/table-pagination'; import { TransactionIdentity } from '@/components/shared/transaction-identity'; import { TokenIcon } from '@/components/shared/token-icon'; import { TooltipContent } from '@/components/shared/tooltip-content'; +import { TableContainerWithHeader } from '@/components/common/table-container-with-header'; import { MONARCH_PRIMARY } from '@/constants/chartColors'; import { useMarketBorrows } from '@/hooks/useMarketBorrows'; import { formatSimple } from '@/utils/balance'; @@ -49,50 +50,55 @@ export function BorrowsTable({ chainId, market, minAssets, onOpenFiltersModal }: const hasActiveFilter = minAssets !== '0'; const tableKey = `borrows-table-${currentPage}`; + const headerActions = ( + } + /> + } + > + + + ); if (error) { - return

Error loading borrows: {error instanceof Error ? error.message : 'Unknown error'}

; + return ( + +
Error loading borrows: {error instanceof Error ? error.message : 'Unknown error'}
+
+ ); } return (
-
-

Borrow & Repay

-
- } - /> - } - > - - -
-
- -
- {/* Loading overlay */} - {isFetching && ( -
- -
- )} + +
+ {isFetching && ( +
+ +
+ )} -
-
+
{totalCount > 0 && ( Error loading liquidations: {error instanceof Error ? error.message : 'Unknown error'}

; + return ( + +
Error loading liquidations: {error instanceof Error ? error.message : 'Unknown error'}
+
+ ); } return (
-

Liquidations

+ +
+ {isFetching && ( +
+ +
+ )} -
- {/* Loading overlay */} - {isFetching && ( -
- -
- )} - -
-
+ {totalCount > 0 && ( = { + directSupply: { label: 'Supply', variant: 'success' }, + directWithdraw: { label: 'Withdraw', variant: 'danger' }, + directBorrow: { label: 'Borrow', variant: 'danger' }, + directRepay: { label: 'Repay', variant: 'success' }, + vaultDeposit: { label: 'Vault Deposit', variant: 'primary' }, + vaultWithdraw: { label: 'Vault Withdraw', variant: 'warning' }, + vaultRebalance: { label: 'Rebalance', variant: 'default' }, + monarchTx: { label: 'Monarch', variant: 'primary' }, + others: { label: 'Others', variant: 'default' }, + batched: { label: 'Batched', variant: 'default' }, +}; + +const legKindMeta: Record< + MarketProActivityLeg['kind'], + { label: string; variant: 'default' | 'primary' | 'success' | 'warning' | 'danger' } +> = { + supply: { label: 'Supply', variant: 'success' }, + withdraw: { label: 'Withdraw', variant: 'danger' }, + borrow: { label: 'Borrow', variant: 'danger' }, + repay: { label: 'Repay', variant: 'success' }, + supplyCollateral: { label: 'Supply Collateral', variant: 'primary' }, + withdrawCollateral: { label: 'Withdraw Collateral', variant: 'warning' }, + vaultDeposit: { label: 'Vault Deposit', variant: 'primary' }, + vaultWithdraw: { label: 'Vault Withdraw', variant: 'warning' }, + vaultAllocate: { label: 'Allocate', variant: 'default' }, + vaultDeallocate: { label: 'Deallocate', variant: 'default' }, + vaultForceDeallocate: { label: 'Force Deallocate', variant: 'danger' }, + legacyVaultReallocateSupply: { label: 'Supply', variant: 'success' }, + legacyVaultReallocateWithdraw: { label: 'Withdraw', variant: 'danger' }, +}; + +type MarketFlowDirection = 'in' | 'out'; + +type MarketFlowEntry = { + marketId: string; + direction: MarketFlowDirection; + amount: string; + assetType: 'loan' | 'collateral'; +}; + +const getAssetMetadata = (market: Market, assetType: 'loan' | 'collateral') => { + return assetType === 'loan' ? market.loanAsset : market.collateralAsset; +}; + +const formatAmount = (amount: string, decimals: number): string => { + return formatSimple(Number(formatUnits(BigInt(amount), decimals))); +}; + +const getMarketMapKey = (marketId: string): string => marketId.toLowerCase(); +const isSameMarketId = (left: string, right: string): boolean => getMarketMapKey(left) === getMarketMapKey(right); +const sameAddress = (left: string | undefined, right: string | undefined): boolean => + left !== undefined && right !== undefined && left.toLowerCase() === right.toLowerCase(); +const isRebalanceLikeKind = (kind: MarketProActivityKind): boolean => kind === 'vaultRebalance' || kind === 'monarchTx'; + +type ProActivitiesTableProps = { + chainId: number; + market: Market; + onSwitchToBasic: () => void; +}; + +export function ProActivitiesTable({ chainId, market, onSwitchToBasic }: ProActivitiesTableProps) { + const [currentPage, setCurrentPage] = useState(1); + const [expandedRows, setExpandedRows] = useState>(new Set()); + const { allMarkets } = useProcessedMarkets(); + + const { data, isLoading, isFetching, error, refetch } = useMarketTxContexts(market.uniqueKey, market.morphoBlue.chain.id, currentPage, PAGE_SIZE); + + const activities = data?.items ?? []; + const totalCount = data?.totalCount ?? 0; + const marketMap = useMemo(() => { + const nextMap = new Map(); + + nextMap.set(getMarketMapKey(market.uniqueKey), market); + for (const candidate of allMarkets) { + if (candidate.morphoBlue.chain.id !== market.morphoBlue.chain.id) { + continue; + } + + nextMap.set(getMarketMapKey(candidate.uniqueKey), candidate); + } + + return nextMap; + }, [allMarkets, market]); + + const renderMarketReference = (marketId: string, key: string): ReactNode => { + const referencedMarket = marketMap.get(getMarketMapKey(marketId)); + if (referencedMarket) { + return ( + + ); + } + + return ( + + ); + }; + + const renderCurrentMarketTag = (): ReactNode => { + return ( + + This Market + + ); + }; + + const getLegDirection = (leg: MarketProActivityLeg): MarketFlowDirection | null => { + if (leg.kind === 'supply' || leg.kind === 'repay' || leg.kind === 'legacyVaultReallocateSupply') { + return 'in'; + } + + if (leg.kind === 'withdraw' || leg.kind === 'borrow' || leg.kind === 'legacyVaultReallocateWithdraw') { + return 'out'; + } + + return null; + }; + + const isMorphoMarketLoanFlowLeg = (leg: MarketProActivityLeg): boolean => { + if (leg.source !== 'morpho' || leg.assetType !== 'loan') { + return false; + } + + return leg.kind === 'supply' || leg.kind === 'withdraw' || leg.kind === 'borrow' || leg.kind === 'repay'; + }; + + const getMarketFlowEntries = (activity: (typeof activities)[number]): MarketFlowEntry[] => { + const flowMap = new Map(); + + for (const leg of activity.legs) { + if (!leg.marketId || !isMorphoMarketLoanFlowLeg(leg)) { + continue; + } + + const direction = getLegDirection(leg); + if (!direction) { + continue; + } + + const key = `${getMarketMapKey(leg.marketId)}:${direction}:${leg.assetType}`; + const existing = flowMap.get(key); + + if (existing) { + existing.amount += BigInt(leg.amount); + continue; + } + + flowMap.set(key, { + marketId: leg.marketId, + direction, + amount: BigInt(leg.amount), + assetType: leg.assetType, + }); + } + + return [...flowMap.values()] + .map((entry) => ({ + marketId: entry.marketId, + direction: entry.direction, + amount: entry.amount.toString(), + assetType: entry.assetType, + })) + .sort((left, right) => { + if (left.direction !== right.direction) { + return left.direction === 'out' ? -1 : 1; + } + + const leftAmount = BigInt(left.amount); + const rightAmount = BigInt(right.amount); + + if (rightAmount === leftAmount) { + return 0; + } + + return rightAmount > leftAmount ? 1 : -1; + }); + }; + + const formatFlowAmount = (entry: MarketFlowEntry): string => { + const entryMarket = marketMap.get(getMarketMapKey(entry.marketId)) ?? market; + const asset = getAssetMetadata(entryMarket, entry.assetType); + return `${formatAmount(entry.amount, asset.decimals)} ${asset.symbol}`; + }; + + const getLegAsset = (leg: MarketProActivityLeg) => { + const legMarket = leg.marketId ? (marketMap.get(getMarketMapKey(leg.marketId)) ?? market) : market; + return getAssetMetadata(legMarket, leg.assetType); + }; + + const getRowLoanFlows = (activity: (typeof activities)[number]) => { + let inflow = 0n; + let outflow = 0n; + + for (const leg of activity.legs) { + if (!leg.isCurrentMarket || !isMorphoMarketLoanFlowLeg(leg)) { + continue; + } + + const direction = getLegDirection(leg); + if (direction === 'in') { + inflow += BigInt(leg.amount); + } else if (direction === 'out') { + outflow += BigInt(leg.amount); + } + } + + const flows: Array<{ direction: MarketFlowDirection; amount: string }> = []; + if (inflow > 0n) { + flows.push({ direction: 'in', amount: inflow.toString() }); + } + if (outflow > 0n) { + flows.push({ direction: 'out', amount: outflow.toString() }); + } + + return flows; + }; + + const getSupportingVaultLeg = ( + activity: (typeof activities)[number], + leg: MarketProActivityLeg, + ): MarketProActivityLeg | undefined => { + const supportingKind = leg.kind === 'supply' ? 'vaultDeposit' : leg.kind === 'withdraw' ? 'vaultWithdraw' : null; + if (!supportingKind) { + return undefined; + } + + const candidates = activity.legs.filter((candidate) => candidate.kind === supportingKind && candidate.vaultAddress); + if (candidates.length === 0) { + return undefined; + } + + return ( + candidates.find((candidate) => { + return ( + sameAddress(candidate.vaultAddress, leg.positionAddress) || + sameAddress(candidate.vaultAddress, leg.vaultAddress) || + sameAddress(candidate.vaultAddress, activity.vaultAddress) + ); + }) ?? candidates[0] + ); + }; + + const getProcessedEventActorAddress = ( + activity: (typeof activities)[number], + leg: MarketProActivityLeg, + ): Address | undefined => { + const supportingVaultLeg = getSupportingVaultLeg(activity, leg); + if (supportingVaultLeg) { + const vaultActor = + leg.kind === 'withdraw' + ? supportingVaultLeg.positionAddress ?? supportingVaultLeg.receiverAddress ?? supportingVaultLeg.actorAddress + : supportingVaultLeg.positionAddress ?? supportingVaultLeg.actorAddress; + + return vaultActor as Address | undefined; + } + + return (leg.positionAddress ?? leg.receiverAddress ?? leg.actorAddress) as Address | undefined; + }; + + const getProcessedEventIntermediaryAddress = ( + activity: (typeof activities)[number], + leg: MarketProActivityLeg, + ): Address | undefined => { + const actorAddress = getProcessedEventActorAddress(activity, leg); + const supportingVaultLeg = getSupportingVaultLeg(activity, leg); + const intermediaryAddress = supportingVaultLeg?.vaultAddress ?? leg.vaultAddress; + + if (!intermediaryAddress || sameAddress(intermediaryAddress, actorAddress)) { + return undefined; + } + + return intermediaryAddress as Address; + }; + + const hasMatchingLegacyReallocateLeg = ( + eventLegs: MarketProActivityLeg[], + leg: MarketProActivityLeg, + ): boolean => { + if (leg.kind !== 'supply' && leg.kind !== 'withdraw') { + return false; + } + + const matchingKind = leg.kind === 'supply' ? 'legacyVaultReallocateSupply' : 'legacyVaultReallocateWithdraw'; + + return eventLegs.some((candidate) => { + return ( + candidate.kind === matchingKind && + candidate.marketId !== undefined && + leg.marketId !== undefined && + isSameMarketId(candidate.marketId, leg.marketId) && + candidate.amount === leg.amount && + sameAddress(candidate.vaultAddress, leg.positionAddress) + ); + }); + }; + + const getProcessedEventLegs = (activity: (typeof activities)[number]): MarketProActivityLeg[] => { + const currentMarketLegs = activity.legs.filter((leg) => leg.isCurrentMarket); + const eventLegs = currentMarketLegs.length > 0 ? currentMarketLegs : activity.legs; + + return eventLegs.filter((leg) => { + return !hasMatchingLegacyReallocateLeg(eventLegs, leg); + }); + }; + + const renderProcessedEventMarket = (leg: MarketProActivityLeg, key: string): ReactNode => { + if (!leg.marketId) { + return null; + } + + if (leg.isCurrentMarket) { + return ( + + {renderCurrentMarketTag()} + + ); + } + + return ( + + ); + }; + + const getLegAmountClassName = (leg: MarketProActivityLeg): string => { + const direction = getLegDirection(leg); + + if (direction === 'in') { + return 'text-green-600 dark:text-green-400'; + } + + if (direction === 'out') { + return 'text-red-600 dark:text-red-400'; + } + + if (leg.kind === 'supplyCollateral') { + return 'text-green-600 dark:text-green-400'; + } + + if (leg.kind === 'withdrawCollateral') { + return 'text-red-600 dark:text-red-400'; + } + + return 'text-secondary'; + }; + + const renderFlowMarketLabel = (entry: MarketFlowEntry, key: string): ReactNode => { + const marketReference = renderMarketReference(entry.marketId, key); + + if (!isSameMarketId(entry.marketId, market.uniqueKey)) { + return marketReference; + } + + return ( +
+ {marketReference} + {renderCurrentMarketTag()} +
+ ); + }; + + const renderProcessedEvents = (activity: (typeof activities)[number]): ReactNode => { + const eventLegs = getProcessedEventLegs(activity); + + return ( +
+

Processed Events

+
+ {eventLegs.map((leg) => { + const legMeta = legKindMeta[leg.kind]; + const actorAddress = getProcessedEventActorAddress(activity, leg); + const intermediaryAddress = getProcessedEventIntermediaryAddress(activity, leg); + const asset = getLegAsset(leg); + + return ( +
+
+ {actorAddress ? ( + + ) : null} + {intermediaryAddress ? ( + <> + via + + + ) : null} + {legMeta.label} + {renderProcessedEventMarket(leg, `processed-${leg.id}`)} +
+
+ {formatAmount(leg.amount, asset.decimals)} + + {asset.symbol} +
+
+ ); + })} +
+
+ ); + }; + + const renderExpandedFlows = (activity: (typeof activities)[number]): ReactNode => { + const flowEntries = getMarketFlowEntries(activity); + const outflows = flowEntries.filter((entry) => entry.direction === 'out'); + const inflows = flowEntries.filter((entry) => entry.direction === 'in'); + + if (flowEntries.length === 0) { + return renderProcessedEvents(activity); + } + + const renderFlowList = (entries: MarketFlowEntry[], direction: MarketFlowDirection, prefix: string) => { + if (entries.length === 0) { + return

-

; + } + + return ( +
+ {entries.map((entry) => ( +
+
{renderFlowMarketLabel(entry, `${prefix}-${entry.marketId}`)}
+ + {direction === 'in' ? '+' : '-'} + {formatFlowAmount(entry)} + +
+ ))} +
+ ); + }; + + return ( +
+
+
+

From

+ {renderFlowList(outflows, 'out', `out-${activity.id}`)} +
+
+

To

+ {renderFlowList(inflows, 'in', `in-${activity.id}`)} +
+
+ {activity.currentMarketLegCount > 1 && renderProcessedEvents(activity)} +
+ ); + }; + + const getIntermediaryAddress = (activity: (typeof activities)[number]): Address | undefined => { + if (activity.kind === 'monarchTx' || !activity.vaultAddress) { + const fallbackVaultAddress = activity.legs.find((leg) => leg.vaultAddress)?.vaultAddress; + return fallbackVaultAddress && activity.kind !== 'monarchTx' ? (fallbackVaultAddress as Address) : undefined; + } + + return activity.vaultAddress as Address; + }; + + const renderRowFlow = (activity: (typeof activities)[number]): ReactNode => { + const rowLoanFlows = getRowLoanFlows(activity); + if (rowLoanFlows.length === 0) { + return -; + } + + const renderFlowAmount = (flow: { direction: MarketFlowDirection; amount: string }) => { + const amountClassName = flow.direction === 'in' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'; + + return ( +
+ + {flow.direction === 'in' ? '+' : '-'} + {formatAmount(flow.amount, market.loanAsset.decimals)} + + +
+ ); + }; + + if (activity.kind === 'batched' && rowLoanFlows.length > 1) { + const inflow = rowLoanFlows.find((flow) => flow.direction === 'in'); + const outflow = rowLoanFlows.find((flow) => flow.direction === 'out'); + + if (inflow && outflow) { + const inflowAmount = BigInt(inflow.amount); + const outflowAmount = BigInt(outflow.amount); + const netAmount = inflowAmount - outflowAmount; + + if (netAmount === 0n) { + const volumeAmount = inflowAmount >= outflowAmount ? inflowAmount : outflowAmount; + + return ( +
+ {formatAmount(volumeAmount.toString(), market.loanAsset.decimals)} + +
+ ); + } + + return renderFlowAmount({ + direction: netAmount > 0n ? 'in' : 'out', + amount: (netAmount > 0n ? netAmount : -netAmount).toString(), + }); + } + } + + return renderFlowAmount(rowLoanFlows[0]); + }; + + const headerActions = ( + + } + > + + + + + ); + + const toggleRow = (rowId: string) => { + setExpandedRows((previous) => { + const next = new Set(previous); + if (next.has(rowId)) { + next.delete(rowId); + } else { + next.add(rowId); + } + return next; + }); + }; + + if (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown Monarch error'; + + return ( + +
+ + +
+ + +
+
+
+ ); + } + + return ( +
+ +
+ {isFetching && !isLoading && ( +
+ +
+ )} + + + + + ACCOUNT + ACTION + INTERMEDIARY + FLOW + TIME + TRANSACTION + + + + {isLoading ? ( + + +
+ + Loading pro activity +
+
+
+ ) : activities.length === 0 ? ( + + + No pro activities found for this market + + + ) : ( + activities.map((activity) => { + const activityMeta = activityKindMeta[activity.kind]; + const detailRowId = `${activity.id}-detail`; + const isExpanded = expandedRows.has(activity.id); + const intermediaryAddress = getIntermediaryAddress(activity); + + return ( + + toggleRow(activity.id)} + onKeyDown={(event) => { + if (event.target !== event.currentTarget) { + return; + } + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + toggleRow(activity.id); + } + }} + > + + {!activity.isMonarch && activity.actorAddress ? ( + + ) : ( + - + )} + + + + + {activityMeta.label} + + + + + {intermediaryAddress ? ( + + ) : null} + + + + {renderRowFlow(activity)} + + + {moment.unix(activity.timestamp).fromNow()} + + event.stopPropagation()} + > + + + + + +
+ {isRebalanceLikeKind(activity.kind) ? renderExpandedFlows(activity) : renderProcessedEvents(activity)} +
+
+
+ ); + }) + )} +
+
+
+
+ + {totalCount > 0 && ( + + )} +
+ ); +} diff --git a/src/features/market-detail/components/supplies-table.tsx b/src/features/market-detail/components/supplies-table.tsx index 7a538f58..0c6cf622 100644 --- a/src/features/market-detail/components/supplies-table.tsx +++ b/src/features/market-detail/components/supplies-table.tsx @@ -13,6 +13,7 @@ import { TablePagination } from '@/components/shared/table-pagination'; import { TransactionIdentity } from '@/components/shared/transaction-identity'; import { TokenIcon } from '@/components/shared/token-icon'; import { TooltipContent } from '@/components/shared/tooltip-content'; +import { TableContainerWithHeader } from '@/components/common/table-container-with-header'; import { MONARCH_PRIMARY } from '@/constants/chartColors'; import useMarketSupplies from '@/hooks/useMarketSupplies'; import { formatSimple } from '@/utils/balance'; @@ -48,46 +49,44 @@ export function SuppliesTable({ chainId, market, minAssets, onOpenFiltersModal } const hasActiveFilter = minAssets !== '0'; const tableKey = `supplies-table-${currentPage}`; + const headerActions = ( + } + /> + } + > + + + ); return (
-
-

Supply & Withdraw

-
- } - /> - } - > - - -
-
- -
- {/* Loading overlay */} - {isFetching && ( -
- -
- )} + +
+ {isFetching && ( +
+ +
+ )} -
-
+
{totalCount > 0 && ( + Pro + + + ), + value: 'pro', + }, +]; function MarketContent() { // 1. Get URL params first @@ -48,7 +68,9 @@ function MarketContent() { // 3. Consolidated state const { open: openModal } = useModal(); const selectedTab = useMarketDetailPreferences((s) => s.selectedTab); + const activitiesView = useMarketDetailPreferences((s) => s.activitiesView); const setSelectedTab = useMarketDetailPreferences((s) => s.setSelectedTab); + const setActivitiesView = useMarketDetailPreferences((s) => s.setActivitiesView); const [showTransactionFiltersModal, setShowTransactionFiltersModal] = useState(false); const [showSupplierFiltersModal, setShowSupplierFiltersModal] = useState(false); const [minSupplierShares, setMinSupplierShares] = useState(''); @@ -437,20 +459,40 @@ function MarketContent() { - setShowTransactionFiltersModal(true)} - /> -
- + setActivitiesView(value as MarketDetailActivitiesView)} + size="sm" + /> +
+ + {activitiesView === 'basic' ? ( + <> + setShowTransactionFiltersModal(true)} + /> +
+ setShowTransactionFiltersModal(true)} + /> +
+ + ) : ( + setShowTransactionFiltersModal(true)} + onSwitchToBasic={() => setActivitiesView('basic')} /> -
+ )} +
-
- {/* Position History Chart with synchronized pie */} +
- {/* Markets Table - Always visible */} - - - - Market - {rateLabel} - Allocation - Risk Tiers - Actions - - - - {sortedMarkets.map((position) => ( - - ))} - -
+ + + + + Market + {rateLabel} + Allocation + Risk Tiers + Actions + + + + {sortedMarkets.map((position) => ( + + ))} + +
+
); diff --git a/src/features/positions/components/vault-allocation-detail.tsx b/src/features/positions/components/vault-allocation-detail.tsx index 0cc4bd78..59278664 100644 --- a/src/features/positions/components/vault-allocation-detail.tsx +++ b/src/features/positions/components/vault-allocation-detail.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import type { Address } from 'viem'; import { motion } from 'framer-motion'; +import { TableContainerWithHeader } from '@/components/common/table-container-with-header'; import { Button } from '@/components/ui/button'; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; import { Spinner } from '@/components/ui/spinner'; @@ -19,6 +20,7 @@ type VaultAllocationDetailProps = { export function VaultAllocationDetail({ vault }: VaultAllocationDetailProps) { const { short: rateLabel } = useRateLabel(); + const title = 'Market Allocations'; // Fetch actual allocations - useVaultAllocations pulls caps internally const { marketAllocations, loading } = useVaultAllocations({ @@ -45,9 +47,11 @@ export function VaultAllocationDetail({ vault }: VaultAllocationDetailProps) { transition={{ duration: 0.2 }} className="overflow-hidden" > -
- -
+ +
+ +
+
); } @@ -61,9 +65,9 @@ export function VaultAllocationDetail({ vault }: VaultAllocationDetailProps) { transition={{ duration: 0.2 }} className="overflow-hidden" > -
- No market allocations configured for this vault. -
+ +
No market allocations configured for this vault.
+
); } @@ -76,7 +80,7 @@ export function VaultAllocationDetail({ vault }: VaultAllocationDetailProps) { transition={{ duration: 0.2 }} className="overflow-hidden" > -
+ @@ -89,10 +93,7 @@ export function VaultAllocationDetail({ vault }: VaultAllocationDetailProps) { {marketAllocations.map((allocation) => { - // Calculate allocated amount const allocatedAmount = Number(formatBalance(allocation.allocation, vaultAssetDecimals)); - - // Calculate percentage const percentage = totalAllocation > 0n ? (allocatedAmount / Number(formatBalance(totalAllocation, vaultAssetDecimals))) * 100 : 0; @@ -101,7 +102,6 @@ export function VaultAllocationDetail({ vault }: VaultAllocationDetailProps) { key={allocation.market.uniqueKey} className="gap-1" > - {/* Market */} - {/* APY/APR */} - {/* Allocation */} - {/* Risk Tiers */} - {/* Actions */}
-
+ ); } diff --git a/src/graphql/envio-queries.ts b/src/graphql/envio-queries.ts index 5a1935b7..7a8c490c 100644 --- a/src/graphql/envio-queries.ts +++ b/src/graphql/envio-queries.ts @@ -411,3 +411,276 @@ export const envioLiquidationsPageQuery = ` } } `; + +const marketTxContextFields = ` + id + chainId + timestamp + txHash + vaultTxType + hasVaultUserDeposit + hasVaultUserWithdraw + hasVaultRebalance + morphoSupplies { + market_id + assets + onBehalf + caller + isMonarch + } + morphoWithdraws { + market_id + assets + onBehalf + caller + receiver + isMonarch + } + morphoBorrows { + market_id + assets + onBehalf + caller + receiver + isMonarch + } + morphoRepays { + market_id + assets + onBehalf + caller + isMonarch + } + morphoSupplyCollaterals { + market_id + assets + onBehalf + caller + isMonarch + } + morphoWithdrawCollaterals { + market_id + assets + onBehalf + caller + receiver + isMonarch + } + vaultDeposits { + id + vault_id + onBehalf + sender + assets + shares + isMonarch + } + vaultWithdrawals { + id + vault_id + onBehalf + sender + receiver + assets + shares + isMonarch + } + vaultAllocations { + id + vault_id + sender + assets + change + isMonarch + } + vaultDeallocations { + id + vault_id + sender + assets + change + isMonarch + } + vaultForceDeallocations { + id + vault_id + onBehalf + sender + assets + penaltyAssets + isMonarch + } + legacyVaultDeposits { + id + vaultAddress + owner + sender + assets + shares + isMonarch + } + legacyVaultWithdrawals { + id + vaultAddress + owner + sender + receiver + assets + shares + isMonarch + } + legacyVaultReallocateSupplies { + id + vaultAddress + market_id + suppliedAssets + suppliedShares + caller + isMonarch + } + legacyVaultReallocateWithdrawals { + id + vaultAddress + market_id + withdrawnAssets + withdrawnShares + caller + isMonarch + } +`; + +export const envioMarketTxContextSeedsQuery = ` + query EnvioMarketTxContextSeedsPage($chainId: Int!, $marketId: String!, $limit: Int!, $offset: Int!) { + supplies: Morpho_Supply( + where: { chainId: { _eq: $chainId }, market_id: { _eq: $marketId } } + limit: $limit + offset: $offset + order_by: [{ timestamp: desc }, { txHash: desc }, { id: desc }] + ) { + id + txHash + timestamp + txContext { + id + txHash + timestamp + } + } + withdraws: Morpho_Withdraw( + where: { chainId: { _eq: $chainId }, market_id: { _eq: $marketId } } + limit: $limit + offset: $offset + order_by: [{ timestamp: desc }, { txHash: desc }, { id: desc }] + ) { + id + txHash + timestamp + txContext { + id + txHash + timestamp + } + } + borrows: Morpho_Borrow( + where: { chainId: { _eq: $chainId }, market_id: { _eq: $marketId } } + limit: $limit + offset: $offset + order_by: [{ timestamp: desc }, { txHash: desc }, { id: desc }] + ) { + id + txHash + timestamp + txContext { + id + txHash + timestamp + } + } + repays: Morpho_Repay( + where: { chainId: { _eq: $chainId }, market_id: { _eq: $marketId } } + limit: $limit + offset: $offset + order_by: [{ timestamp: desc }, { txHash: desc }, { id: desc }] + ) { + id + txHash + timestamp + txContext { + id + txHash + timestamp + } + } + supplyCollaterals: Morpho_SupplyCollateral( + where: { chainId: { _eq: $chainId }, market_id: { _eq: $marketId } } + limit: $limit + offset: $offset + order_by: [{ timestamp: desc }, { txHash: desc }, { id: desc }] + ) { + id + txHash + timestamp + txContext { + id + txHash + timestamp + } + } + withdrawCollaterals: Morpho_WithdrawCollateral( + where: { chainId: { _eq: $chainId }, market_id: { _eq: $marketId } } + limit: $limit + offset: $offset + order_by: [{ timestamp: desc }, { txHash: desc }, { id: desc }] + ) { + id + txHash + timestamp + txContext { + id + txHash + timestamp + } + } + legacyReallocateSupplies: MetaMorphoVault_ReallocateSupply( + where: { chainId: { _eq: $chainId }, market_id: { _eq: $marketId } } + limit: $limit + offset: $offset + order_by: [{ timestamp: desc }, { txHash: desc }, { id: desc }] + ) { + id + txHash + timestamp + txContext { + id + txHash + timestamp + } + } + legacyReallocateWithdrawals: MetaMorphoVault_ReallocateWithdraw( + where: { chainId: { _eq: $chainId }, market_id: { _eq: $marketId } } + limit: $limit + offset: $offset + order_by: [{ timestamp: desc }, { txHash: desc }, { id: desc }] + ) { + id + txHash + timestamp + txContext { + id + txHash + timestamp + } + } + } +`; + +export const envioMarketTxContextsByIdsQuery = ` + query EnvioMarketTxContextsByIds($ids: [String!]!) { + TxContext( + where: { id: { _in: $ids } } + order_by: [{ timestamp: desc }, { txHash: desc }, { id: desc }] + ) { +${marketTxContextFields} + } + } +`; diff --git a/src/hooks/useMarketTxContexts.ts b/src/hooks/useMarketTxContexts.ts new file mode 100644 index 00000000..5e3b0349 --- /dev/null +++ b/src/hooks/useMarketTxContexts.ts @@ -0,0 +1,63 @@ +import { useEffect } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { fetchMonarchMarketTxContexts, type PaginatedMarketProActivities } from '@/data-sources/monarch-api'; +import type { SupportedNetworks } from '@/utils/networks'; + +export const useMarketTxContexts = ( + marketId: string | undefined, + network: SupportedNetworks | undefined, + page = 1, + pageSize = 8, +) => { + const queryClient = useQueryClient(); + + const queryKey = ['marketTxContexts', marketId, network, page, pageSize]; + + const queryFn = async (targetPage: number): Promise => { + if (!marketId || !network) { + return null; + } + + const targetSkip = (targetPage - 1) * pageSize; + return fetchMonarchMarketTxContexts(marketId, Number(network), pageSize, targetSkip); + }; + + const { data, isLoading, isFetching, error, refetch } = useQuery({ + queryKey, + queryFn: async () => queryFn(page), + enabled: !!marketId && !!network, + staleTime: 1000 * 60 * 5, + placeholderData: () => null, + retry: 1, + }); + + useEffect(() => { + if (!marketId || !network || !data) { + return; + } + + if (page > 1) { + void queryClient.prefetchQuery({ + queryKey: ['marketTxContexts', marketId, network, page - 1, pageSize], + queryFn: async () => queryFn(page - 1), + staleTime: 1000 * 60 * 5, + }); + } + + if (data.hasNextPage) { + void queryClient.prefetchQuery({ + queryKey: ['marketTxContexts', marketId, network, page + 1, pageSize], + queryFn: async () => queryFn(page + 1), + staleTime: 1000 * 60 * 5, + }); + } + }, [data, marketId, network, page, pageSize, queryClient]); + + return { + data, + isLoading, + isFetching, + error, + refetch, + }; +}; diff --git a/src/stores/useMarketDetailPreferences.ts b/src/stores/useMarketDetailPreferences.ts index 828d0aef..39375bf9 100644 --- a/src/stores/useMarketDetailPreferences.ts +++ b/src/stores/useMarketDetailPreferences.ts @@ -6,14 +6,17 @@ import { } from '@/features/market-detail/components/borrower-table-column-visibility'; type MarketDetailTab = 'trend' | 'activities' | 'positions' | 'analysis'; +type MarketDetailActivitiesView = 'basic' | 'pro'; type MarketDetailPreferencesState = { selectedTab: MarketDetailTab; + activitiesView: MarketDetailActivitiesView; borrowerTableColumnVisibility: BorrowerTableColumnVisibility; }; type MarketDetailPreferencesActions = { setSelectedTab: (tab: MarketDetailTab) => void; + setActivitiesView: (view: MarketDetailActivitiesView) => void; setBorrowerTableColumnVisibility: ( visibilityOrUpdater: BorrowerTableColumnVisibility | ((prev: BorrowerTableColumnVisibility) => BorrowerTableColumnVisibility), ) => void; @@ -26,8 +29,10 @@ export const useMarketDetailPreferences = create() persist( (set) => ({ selectedTab: 'trend', + activitiesView: 'basic', borrowerTableColumnVisibility: DEFAULT_BORROWER_TABLE_COLUMN_VISIBILITY, setSelectedTab: (tab) => set({ selectedTab: tab }), + setActivitiesView: (view) => set({ activitiesView: view }), setBorrowerTableColumnVisibility: (visibilityOrUpdater) => set((state) => ({ borrowerTableColumnVisibility: @@ -37,21 +42,23 @@ export const useMarketDetailPreferences = create() }), { name: 'monarch_store_marketDetailPreferences', - version: 2, + version: 3, migrate: (state, version) => { if (!state || typeof state !== 'object') { return { selectedTab: 'trend', + activitiesView: 'basic', borrowerTableColumnVisibility: DEFAULT_BORROWER_TABLE_COLUMN_VISIBILITY, } as MarketDetailPreferencesState; } const persisted = state as Partial; - if (version < 2) { + if (version < 3) { return { ...persisted, selectedTab: persisted.selectedTab ?? 'trend', + activitiesView: persisted.activitiesView ?? 'basic', borrowerTableColumnVisibility: { ...DEFAULT_BORROWER_TABLE_COLUMN_VISIBILITY, ...(persisted.borrowerTableColumnVisibility ?? {}), @@ -62,6 +69,7 @@ export const useMarketDetailPreferences = create() return { ...persisted, selectedTab: persisted.selectedTab ?? 'trend', + activitiesView: persisted.activitiesView ?? 'basic', borrowerTableColumnVisibility: { ...DEFAULT_BORROWER_TABLE_COLUMN_VISIBILITY, ...(persisted.borrowerTableColumnVisibility ?? {}), @@ -72,4 +80,4 @@ export const useMarketDetailPreferences = create() ), ); -export type { MarketDetailTab }; +export type { MarketDetailActivitiesView, MarketDetailTab };