From 461c173b49a7da403b591c770b01a25099e4fadd Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 7 Oct 2025 11:34:43 +0800 Subject: [PATCH 01/29] chore: styling detail on onboarding flow --- AGENTS.md | 27 +++++++ app/api/balances/route.ts | 1 + app/autovault/components/AutovaultContent.tsx | 9 ++- .../components/deployment/DeploymentModal.tsx | 81 +++++++++++++++++-- .../components/deployment/TokenSelection.tsx | 45 +++++++++-- src/hooks/useUserBalances.ts | 45 ++++++++--- 6 files changed, 184 insertions(+), 24 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..df9516c5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,27 @@ +# Repository Guidelines + +## Project Structure & Module Organization +Next.js routes live in `app/`. Shared logic sits in `src/` with UI in `src/components/`, hooks in `src/hooks/`. Keep constants inside `src/constants/`, configuration in `src/config/`, and reusable utilities in `src/utils/`. Static assets stay in `public/`; design primitives reside in `src/fonts/` and `src/imgs/`. Scripts that generate on-chain artifacts live under `scripts/`, and longer form references or RFCs belong in `docs/`. + +## Build, Test, and Development Commands +- `pnpm install` — install dependencies; stick with pnpm for lockfile parity. +- `pnpm dev` — start the hot-reloading Next.js dev server. +- `pnpm build` — create a clean production bundle after wiping `.next`. +- `pnpm start` — run the production build locally when validating releases. +- `pnpm check` — run formatting, ESLint, and Stylelint fixers as a bundle. +- `pnpm lint` / `pnpm stylelint` — target React or CSS changes without the full suite. + +## Coding Style & Naming Conventions +Run `pnpm format` to apply the Prettier profile (100-char width, 2-space indent, single quotes, trailing commas, Tailwind-aware ordering). ESLint (Airbnb + Next.js) enforces hook safety and import hygiene; Stylelint keeps CSS utilities consistent. Use PascalCase for React components (`VaultBanner.tsx`), camelCase for helpers (`formatApr`), and SCREAMING_SNAKE_CASE for shared constants. Keep Tailwind classlists purposeful and lean; consolidate patterns with `tailwind-merge` helpers when they repeat. + +## Styling Discipline +Consult `docs/Styling.md` before touching UI. Always follow the documented design tokens, Tailwind composition patterns, and variant rules—no exceptions. Mirror the examples in that guide for component structure, prop naming, and class ordering so the design system stays coherent. + +## Implementation Mindset +Default to the simplest viable implementation first. Reach for straightforward data flows, avoid premature abstractions, and only layer on complexity when the trivial approach no longer meets requirements. + +## Git Ownership +Never run git commits, pushes, or other history-altering commands—leave all git operations to the maintainers. + +## Commit & Pull Request Guidelines +Mirror the Conventional Commits style in history (`feat:`, `fix:`, `chore:`), keeping messages imperative and scoped. Sync with `main`, run `pnpm check`, and capture UI evidence (screenshots or short clips) for anything user-facing. Reference the relevant Linear/Jira ticket with closing keywords, call out risk areas, and flag required follow-ups. Tag reviewers who understand the touched protocol surfaces to speed feedback. diff --git a/app/api/balances/route.ts b/app/api/balances/route.ts index e9a3e28e..6ba866f8 100644 --- a/app/api/balances/route.ts +++ b/app/api/balances/route.ts @@ -49,6 +49,7 @@ export async function GET(req: NextRequest) { }); if (!balancesResponse.ok) { + console.error(`Failed to fetch balances: ${balancesResponse.status} ${balancesResponse.statusText}`); throw new Error(`HTTP error! status: ${balancesResponse.status}`); } diff --git a/app/autovault/components/AutovaultContent.tsx b/app/autovault/components/AutovaultContent.tsx index 6e7d1f6e..8824a91c 100644 --- a/app/autovault/components/AutovaultContent.tsx +++ b/app/autovault/components/AutovaultContent.tsx @@ -15,6 +15,7 @@ export default function AutovaultContent() { const [showDeploymentModal, setShowDeploymentModal] = useState(false); const { vaults, loading: vaultsLoading } = useUserVaultsV2(); + const hasExistingVaults = vaults.length > 0; const handleCreateVault = () => { setShowDeploymentModal(true); @@ -64,7 +65,12 @@ export default function AutovaultContent() {

- @@ -79,6 +85,7 @@ export default function AutovaultContent() { setShowDeploymentModal(false)} + existingVaults={vaults} />
diff --git a/app/autovault/components/deployment/DeploymentModal.tsx b/app/autovault/components/deployment/DeploymentModal.tsx index d088f7bb..721547f4 100644 --- a/app/autovault/components/deployment/DeploymentModal.tsx +++ b/app/autovault/components/deployment/DeploymentModal.tsx @@ -1,20 +1,56 @@ -import { Modal, ModalContent, ModalHeader } from '@heroui/react'; +import { useEffect, useMemo, useState } from 'react'; +import { Checkbox, Modal, ModalContent, ModalHeader } from '@heroui/react'; import { RxCross2 } from 'react-icons/rx'; import { Button } from '@/components/common'; import { Spinner } from '@/components/common/Spinner'; import { useMarkets } from '@/contexts/MarketsContext'; +import { UserVaultV2 } from '@/data-sources/subgraph/v2-vaults'; import { useUserBalances } from '@/hooks/useUserBalances'; -import { getNetworkName } from '@/utils/networks'; +import { getNetworkName, ALL_SUPPORTED_NETWORKS, isAgentAvailable, SupportedNetworks } from '@/utils/networks'; import { DeploymentProvider, useDeployment } from './DeploymentContext'; import { TokenSelection } from './TokenSelection'; -function DeploymentModalContent({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) { +const VAULT_SUPPORTED_NETWORKS: SupportedNetworks[] = ALL_SUPPORTED_NETWORKS.filter((network) => + isAgentAvailable(network), +); + +type DeploymentModalContentProps = { + isOpen: boolean; + onClose: () => void; + existingVaults: UserVaultV2[]; +}; + +function DeploymentModalContent({ isOpen, onClose, existingVaults }: DeploymentModalContentProps) { const { selectedTokenAndNetwork, needSwitchChain, switchToNetwork, createVault, isDeploying } = useDeployment(); // Load balances and tokens at modal level - const { balances, loading: balancesLoading } = useUserBalances(); + const { balances, loading: balancesLoading } = useUserBalances({ + networkIds: VAULT_SUPPORTED_NETWORKS, + }); const { whitelistedMarkets, loading: marketsLoading } = useMarkets(); + const [ackExistingVault, setAckExistingVault] = useState(false); + + const userAlreadyHasVault = useMemo(() => { + if (!selectedTokenAndNetwork) return false; + + return existingVaults.some( + (vault) => + vault.networkId === selectedTokenAndNetwork.networkId && + vault.asset.toLowerCase() === selectedTokenAndNetwork.token.address.toLowerCase(), + ); + }, [existingVaults, selectedTokenAndNetwork]); + + useEffect(() => { + setAckExistingVault(false); + }, [selectedTokenAndNetwork]); + + useEffect(() => { + if (!isOpen) { + setAckExistingVault(false); + } + }, [isOpen]); + return ( @@ -58,11 +95,33 @@ function DeploymentModalContent({ isOpen, onClose }: { isOpen: boolean; onClose: )} + {userAlreadyHasVault && selectedTokenAndNetwork && ( +
+ + + I understand I already deployed an autovault for this token on{' '} + {getNetworkName(selectedTokenAndNetwork.networkId)}. + + +
+ )} +
+ {tokenNetwork.hasExistingVault && ( + } + title="Vault deployed" + detail={`You already deployed this token on ${getNetworkName(tokenNetwork.networkId)}.`} + /> + } + > + + + + + )} + {tokenNetwork.marketCount} market{tokenNetwork.marketCount !== 1 ? 's' : ''} diff --git a/src/hooks/useUserBalances.ts b/src/hooks/useUserBalances.ts index 9633c5a2..cdcfd30a 100644 --- a/src/hooks/useUserBalances.ts +++ b/src/hooks/useUserBalances.ts @@ -41,12 +41,16 @@ export function useUserBalances(options: UseUserBalancesOptions = {}) { try { const response = await fetch(`/api/balances?address=${address}&chainId=${chainId}`); if (!response.ok) { - throw new Error('Failed to fetch balances'); + const errorMessage = await response + .json() + .then((data) => (data?.error as string | undefined) ?? 'Failed to fetch balances') + .catch(() => 'Failed to fetch balances'); + throw new Error(errorMessage); } const data = (await response.json()) as TokenResponse; return data.tokens; } catch (err) { - console.error('Error fetching balances:', err); + console.error(`Error fetching balances for chain ${chainId}:`, err); throw err instanceof Error ? err : new Error('Unknown error occurred'); } }, @@ -71,11 +75,25 @@ export function useUserBalances(options: UseUserBalancesOptions = {}) { try { // Fetch balances from specified networks only - const balancePromises = networksToFetch.map(async (chainId) => fetchBalances(chainId)); - const networkBalances = await Promise.all(balancePromises); + const balancePromises = networksToFetch.map(async (chainId) => { + try { + const tokens = await fetchBalances(chainId); + return { chainId, tokens }; + } catch (err) { + return { + chainId, + tokens: [], + error: err instanceof Error ? err : new Error('Unknown error occurred'), + }; + } + }); + + const networkResults = await Promise.all(balancePromises); // Process and filter tokens const processedBalances: TokenBalance[] = []; + const failedChainIds: number[] = []; + const errorMessages: string[] = []; const processTokens = (tokens: TokenResponse['tokens'], chainId: number) => { tokens.forEach((token) => { @@ -92,15 +110,24 @@ export function useUserBalances(options: UseUserBalancesOptions = {}) { }); }; - // Process each network's results - networkBalances.forEach((tokens, index) => { - const chainId = networksToFetch[index]; - if (chainId) { - processTokens(tokens, chainId); + networkResults.forEach((result) => { + processTokens(result.tokens, result.chainId); + + if (result.error) { + failedChainIds.push(result.chainId); + if (result.error.message) { + errorMessages.push(result.error.message); + } } }); setBalances(processedBalances); + + if (failedChainIds.length > 0) { + const fallbackMessage = `Failed to fetch balances for chains: ${failedChainIds.join(', ')}`; + const aggregatedMessage = errorMessages.length > 0 ? [...new Set(errorMessages)].join(' | ') : fallbackMessage; + setError(new Error(aggregatedMessage)); + } } catch (err) { setError(err instanceof Error ? err : new Error('Unknown error occurred')); console.error('Error fetching balances:', err); From c2c7c2e86548037497ac138d02b17bea0fdc09a2 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 7 Oct 2025 17:28:21 +0800 Subject: [PATCH 02/29] chore: setup modal --- AGENTS.md | 3 + .../components/VaultAgentSummary.tsx | 70 +++ .../components/VaultApyHistory.tsx | 30 ++ .../components/VaultAssetMovements.tsx | 77 +++ .../components/VaultInitializationModal.tsx | 264 ++++++++++ .../components/VaultMarketAllocations.tsx | 87 ++++ .../components/VaultRolesModal.tsx | 32 ++ .../components/VaultRolesOverview.tsx | 109 +++++ .../components/VaultSettings.tsx | 179 +------ .../components/VaultSummaryMetrics.tsx | 37 ++ app/autovault/[vaultAddress]/content.tsx | 451 +++++++++++------- app/autovault/components/VaultListV2.tsx | 16 +- docs/Styling.md | 5 + src/abis/morpho-market-v1-adapter-factory.ts | 3 + src/abis/vaultv2.ts | 3 + .../subgraph/morpho-market-v1-adapters.ts | 49 ++ src/data-sources/subgraph/v2-vaults.ts | 4 +- .../morpho-market-v1-adapter-queries.ts | 10 + src/hooks/useAutovaultData.ts | 15 + src/hooks/useDeployMorphoMarketV1Adapter.ts | 74 +++ src/hooks/useMorphoMarketV1Adapters.ts | 65 +++ src/hooks/useVaultV2.ts | 50 ++ src/utils/networks.ts | 12 +- 23 files changed, 1312 insertions(+), 333 deletions(-) create mode 100644 app/autovault/[vaultAddress]/components/VaultAgentSummary.tsx create mode 100644 app/autovault/[vaultAddress]/components/VaultApyHistory.tsx create mode 100644 app/autovault/[vaultAddress]/components/VaultAssetMovements.tsx create mode 100644 app/autovault/[vaultAddress]/components/VaultInitializationModal.tsx create mode 100644 app/autovault/[vaultAddress]/components/VaultMarketAllocations.tsx create mode 100644 app/autovault/[vaultAddress]/components/VaultRolesModal.tsx create mode 100644 app/autovault/[vaultAddress]/components/VaultRolesOverview.tsx create mode 100644 app/autovault/[vaultAddress]/components/VaultSummaryMetrics.tsx create mode 100644 src/abis/morpho-market-v1-adapter-factory.ts create mode 100644 src/abis/vaultv2.ts create mode 100644 src/data-sources/subgraph/morpho-market-v1-adapters.ts create mode 100644 src/graphql/morpho-market-v1-adapter-queries.ts create mode 100644 src/hooks/useDeployMorphoMarketV1Adapter.ts create mode 100644 src/hooks/useMorphoMarketV1Adapters.ts create mode 100644 src/hooks/useVaultV2.ts diff --git a/AGENTS.md b/AGENTS.md index df9516c5..06e749af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,5 +23,8 @@ Default to the simplest viable implementation first. Reach for straightforward d ## Git Ownership Never run git commits, pushes, or other history-altering commands—leave all git operations to the maintainers. +## Contract Interaction TL;DR +When writing new on-chain hooks, mirror the structure in `src/hooks/useERC20Approval.ts` and `src/hooks/useTransactionWithToast.tsx`: compute chain/address context up front, reuse `useTransactionWithToast` for consistent toast + confirmation handling, and expose a minimal hook surface (`{ action, isLoading }`) with refetch callbacks for follow-up reads. + ## Commit & Pull Request Guidelines Mirror the Conventional Commits style in history (`feat:`, `fix:`, `chore:`), keeping messages imperative and scoped. Sync with `main`, run `pnpm check`, and capture UI evidence (screenshots or short clips) for anything user-facing. Reference the relevant Linear/Jira ticket with closing keywords, call out risk areas, and flag required follow-ups. Tag reviewers who understand the touched protocol surfaces to speed feedback. diff --git a/app/autovault/[vaultAddress]/components/VaultAgentSummary.tsx b/app/autovault/[vaultAddress]/components/VaultAgentSummary.tsx new file mode 100644 index 00000000..d69c197b --- /dev/null +++ b/app/autovault/[vaultAddress]/components/VaultAgentSummary.tsx @@ -0,0 +1,70 @@ +import { Button } from '@/components/common'; +import { Tooltip } from '@heroui/react'; +import clsx from 'clsx'; +import { GrStatusGood } from 'react-icons/gr'; +import { TooltipContent } from '@/components/TooltipContent'; + +type VaultAgentSummaryProps = { + isActive: boolean; + activeAgents: number; + description: string; + onManageAgents: () => void; + onViewRoles: () => void; + roleStatusText: string; +}; + +export function VaultAgentSummary({ + isActive, + activeAgents, + description, + onManageAgents, + onViewRoles, + roleStatusText, +}: VaultAgentSummaryProps) { + return ( +
+
+
+ + + {isActive ? 'Automation agents executing strategy' : 'Automation paused'} + + } + title="Automation status" + detail={description} + /> + } + > + Details + +
+

+ {activeAgents > 0 + ? `${activeAgents} allocator${activeAgents > 1 ? 's' : ''} authorized to rebalance.` + : 'No allocators authorized yet—add one to enable automation.'} +

+

{roleStatusText}

+
+
+ + +
+
+ ); +} diff --git a/app/autovault/[vaultAddress]/components/VaultApyHistory.tsx b/app/autovault/[vaultAddress]/components/VaultApyHistory.tsx new file mode 100644 index 00000000..2cee5246 --- /dev/null +++ b/app/autovault/[vaultAddress]/components/VaultApyHistory.tsx @@ -0,0 +1,30 @@ +import { Button } from '@/components/common'; + +type VaultApyHistoryProps = { + timeframes: string[]; +}; + +export function VaultApyHistory({ timeframes }: VaultApyHistoryProps) { + return ( +
+
+
+

Historical APY

+

Performance data updates every epoch.

+
+
+ {timeframes.map((frame) => ( + + ))} +
+
+
+
+ APY chart coming soon +
+
+
+ ); +} diff --git a/app/autovault/[vaultAddress]/components/VaultAssetMovements.tsx b/app/autovault/[vaultAddress]/components/VaultAssetMovements.tsx new file mode 100644 index 00000000..85da332b --- /dev/null +++ b/app/autovault/[vaultAddress]/components/VaultAssetMovements.tsx @@ -0,0 +1,77 @@ +import { Button } from '@/components/common'; + +export type VaultAssetMovement = { + timestamp: string; + action: 'allocate' | 'deallocate'; + from?: string; + to?: string; + amount: string; + hash?: string; +}; + +type VaultAssetMovementsProps = { + history: VaultAssetMovement[]; +}; + +export function VaultAssetMovements({ history }: VaultAssetMovementsProps) { + return ( +
+
+
+

Asset Movements

+

Track how the allocator rebalanced capital.

+
+ +
+ {history.length === 0 ? ( +
+ No rebalances recorded yet. +
+ ) : ( +
+ + + + + + + + + + + + {history.map((event, index) => ( + + + + + + + + ))} + +
WhenActionRouteAmountTx
{event.timestamp} + {event.action} + + {event.from && event.to ? `${event.from} → ${event.to}` : '—'} + {event.amount} + {event.hash ? ( + + View + + ) : ( + 'Pending' + )} +
+
+ )} +
+ ); +} diff --git a/app/autovault/[vaultAddress]/components/VaultInitializationModal.tsx b/app/autovault/[vaultAddress]/components/VaultInitializationModal.tsx new file mode 100644 index 00000000..84fd3af8 --- /dev/null +++ b/app/autovault/[vaultAddress]/components/VaultInitializationModal.tsx @@ -0,0 +1,264 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Address, zeroAddress } from 'viem'; +import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@heroui/react'; +import { Button } from '@/components/common'; +import { Spinner } from '@/components/common/Spinner'; +import { AddressDisplay } from '@/components/common/AddressDisplay'; +import { useDeployMorphoMarketV1Adapter } from '@/hooks/useDeployMorphoMarketV1Adapter'; +import { useMorphoMarketV1Adapters } from '@/hooks/useMorphoMarketV1Adapters'; +import { useVaultV2 } from '@/hooks/useVaultV2'; +import { SupportedNetworks, getNetworkConfig } from '@/utils/networks'; +import { getMorphoAddress } from '@/utils/morpho'; + +const ZERO_ADDRESS = zeroAddress; +const shortenAddress = (value: Address | string) => + value === ZERO_ADDRESS ? '0x0000…0000' : `${value.slice(0, 6)}…${value.slice(-4)}`; + +const STEP_SEQUENCE = ['deploy', 'finalize'] as const; +type StepId = (typeof STEP_SEQUENCE)[number]; + +function StepIndicator({ currentStep }: { currentStep: StepId }) { + const currentIndex = STEP_SEQUENCE.findIndex((s) => s === currentStep); + + return ( +
+ {STEP_SEQUENCE.map((step, index) => { + const isPast = index < currentIndex; + const isCurrent = index === currentIndex; + return ( +
+
+
+ ); + })} +
+ ); +} + +function DeployAdapterStep({ + loading, + adapterDetected, + adapterAddress, +}: { + loading: boolean; + adapterDetected: boolean; + adapterAddress: Address; +}) { + return ( +
+

+ Deploy a Morpho Market adapter so this vault can allocate assets into Morpho Blue markets. +

+
+ {loading && } + + {adapterDetected + ? `Adapter detected: ${shortenAddress(adapterAddress)}` + : 'Adapter not detected yet.'} + +
+
+ ); +} + +function FinalizeSetupStep({ + chainId, + adapter, +}: { + chainId: SupportedNetworks; + adapter: Address; +}) { + const registryAddress = getNetworkConfig(chainId).vaultConfig?.morphoRegistry ?? ZERO_ADDRESS; + const adapterIsReady = adapter !== ZERO_ADDRESS; + + return ( +
+

+ Finalize setup to link the vault to the adapter and commit to the Morpho registry. This permanently + opts the vault into Morpho-approved adapters. +

+
+
+ Adapter + {adapterIsReady ? ( + + ) : ( + Adapter not detected yet. + )} +
+
+ Morpho registry + +
+
    +
  • Only Morpho-approved adapters can be enabled after this step.
  • +
  • Registry configuration is abdicated and cannot be reversed.
  • +
  • This step also registers the adapter on the vault.
  • +
+
+
+ ); +} + +export function VaultInitializationModal({ + isOpen, + onClose, + vaultAddress, + chainId, + onAdapterConfigured, +}: { + isOpen: boolean; + onClose: () => void; + vaultAddress: Address; + chainId: SupportedNetworks; + onAdapterConfigured: () => void; +}) { + const [stepIndex, setStepIndex] = useState(0); + const currentStep = STEP_SEQUENCE[stepIndex]; + + const morphoAddress = useMemo(() => getMorphoAddress(chainId), [chainId]); + const { + adapters, + loading: adaptersLoading, + refetch: refetchAdapters, + } = useMorphoMarketV1Adapters({ vaultAddress, chainId }); + const subgraphAdapter = adapters[0]?.adapter ?? ZERO_ADDRESS; + + const { adapter: onChainAdapter, refetch: refetchVault } = useVaultV2({ + vaultAddress, + chainId, + }); + + const unifiedAdapter = useMemo(() => { + if (subgraphAdapter !== ZERO_ADDRESS) return subgraphAdapter; + if (onChainAdapter && onChainAdapter !== ZERO_ADDRESS) return onChainAdapter; + return ZERO_ADDRESS; + }, [onChainAdapter, subgraphAdapter]); + + const adapterDetected = unifiedAdapter !== ZERO_ADDRESS; + + const { deploy, isDeploying, canDeploy } = useDeployMorphoMarketV1Adapter({ + vaultAddress, + chainId, + morphoAddress, + }); + + const [statusVisible, setStatusVisible] = useState(false); + + const handleAdapterDetected = useCallback(async () => { + await refetchVault(); + onAdapterConfigured(); + }, [onAdapterConfigured, refetchVault]); + + const handleDeploy = useCallback(async () => { + setStatusVisible(true); + await deploy(); + await refetchAdapters(); + }, [deploy, refetchAdapters]); + + useEffect(() => { + if (!isOpen) { + setStepIndex(0); + setStatusVisible(false); + } + }, [isOpen]); + + useEffect(() => { + if (adapterDetected && stepIndex === 0) { + setStepIndex(1); + void handleAdapterDetected(); + } + }, [adapterDetected, handleAdapterDetected, stepIndex]); + + const stepTitle = useMemo(() => { + switch (currentStep) { + case 'deploy': + return 'Deploy Morpho Market adapter'; + case 'finalize': + return 'Finalize setup'; + default: + return ''; + } + }, [currentStep]); + + const showLoading = statusVisible && (isDeploying || adaptersLoading); + const showBackButton = stepIndex > 0; + const renderCta = () => { + if (stepIndex === 0) { + return ( + + ); + } + + return ( + + ); + }; + + return ( + + + +
+

{stepTitle}

+

Initialize this vault once before configuring strategies.

+
+
+ + + {currentStep === 'deploy' && ( + + )} + {currentStep === 'finalize' && ( + + )} + + + + {showBackButton && ( + + )} + {renderCta()} + +
+ +
+
+
+ ); +} diff --git a/app/autovault/[vaultAddress]/components/VaultMarketAllocations.tsx b/app/autovault/[vaultAddress]/components/VaultMarketAllocations.tsx new file mode 100644 index 00000000..351a4da4 --- /dev/null +++ b/app/autovault/[vaultAddress]/components/VaultMarketAllocations.tsx @@ -0,0 +1,87 @@ +import { TokenIcon } from '@/components/TokenIcon'; +import OracleVendorBadge from '@/components/OracleVendorBadge'; +import { VaultAllocation } from '@/hooks/useAutovaultData'; + +const formatPercent = (value: number | null | undefined) => + typeof value === 'number' && Number.isFinite(value) ? `${value.toFixed(2)}%` : '--'; + +const formatApy = formatPercent; + +type VaultMarketAllocationsProps = { + allocations: VaultAllocation[]; + vaultAssetSymbol: string; +}; + +export function VaultMarketAllocations({ allocations, vaultAssetSymbol }: VaultMarketAllocationsProps) { + if (allocations.length === 0) { + return ( +
+ No markets supplied yet. Configure your strategy to start allocating assets. +
+ ); + } + + return ( +
+
+
+

Active Markets

+

Supply allocations managed by this vault.

+
+
+ Vault asset: {vaultAssetSymbol} +
+
+ +
+ + + + + + + + + + + + {allocations.map((allocation) => ( + + + + + + + + ))} + +
IDAllocationAPYRiskVault Share
+
+ + + {allocation.marketId} + +
+
+ {allocation.allocationFormatted ?? `-- ${vaultAssetSymbol}`} + {formatApy(allocation.apy)} +
+ + LLTV {formatPercent(allocation.lltv)} + + +
+
{formatPercent(allocation.allocationPercent)}
+
+
+ ); +} diff --git a/app/autovault/[vaultAddress]/components/VaultRolesModal.tsx b/app/autovault/[vaultAddress]/components/VaultRolesModal.tsx new file mode 100644 index 00000000..4c0e6ced --- /dev/null +++ b/app/autovault/[vaultAddress]/components/VaultRolesModal.tsx @@ -0,0 +1,32 @@ +import { Modal, ModalBody, ModalContent, ModalHeader } from '@heroui/react'; +import { VaultRole } from './VaultRolesOverview'; +import { VaultRolesOverview } from './VaultRolesOverview'; + +type VaultRolesModalProps = { + isOpen: boolean; + onClose: () => void; + roles: VaultRole[]; +}; + +export function VaultRolesModal({ isOpen, onClose, roles }: VaultRolesModalProps) { + return ( + + + Vault roles & safeguards + + + + + + ); +} diff --git a/app/autovault/[vaultAddress]/components/VaultRolesOverview.tsx b/app/autovault/[vaultAddress]/components/VaultRolesOverview.tsx new file mode 100644 index 00000000..4be95b39 --- /dev/null +++ b/app/autovault/[vaultAddress]/components/VaultRolesOverview.tsx @@ -0,0 +1,109 @@ +import { Tooltip } from '@heroui/react'; +import { FiShield, FiUsers } from 'react-icons/fi'; +import { TooltipContent } from '@/components/TooltipContent'; + +export type VaultRole = { + key: 'owner' | 'curator' | 'allocator' | 'sentinel'; + label: string; + description: string; + addresses: string[]; + status: 'configured' | 'pending'; + guidance: string; + capabilities: string[]; +}; + +type VaultRolesOverviewProps = { + roles: VaultRole[]; +}; + +const ROLE_COLORS: Record = { + owner: 'bg-purple-500', + curator: 'bg-sky-500', + allocator: 'bg-emerald-500', + sentinel: 'bg-amber-500', +}; + +export function VaultRolesOverview({ roles }: VaultRolesOverviewProps) { + return ( +
+
+
+

Roles & Governance

+

+ Assign independent keys so administration, risk, and execution stay separated. +

+
+ } + title="Role Separation" + detail="Owner administers, Curator steers risk, Allocator executes, Sentinel reacts. Keep keys split." + /> + } + > + + +
+ +
+ {roles.map((role) => ( +
+
+
+ +

{role.label}

+
+ + {role.status === 'configured' ? 'Configured' : 'Needs attention'} + +
+

{role.description}

+ +
+ Assigned + {role.addresses.length === 0 ? ( +
+ No address assigned yet. +
+ ) : ( +
    + {role.addresses.map((address) => ( +
  • + {address} +
  • + ))} +
+ )} +
+ +
+ Capabilities +
    + {role.capabilities.map((item) => ( +
  • • {item}
  • + ))} +
+
+ + {role.status === 'pending' && ( +
+ {role.guidance} +
+ )} +
+ ))} +
+
+ ); +} diff --git a/app/autovault/[vaultAddress]/components/VaultSettings.tsx b/app/autovault/[vaultAddress]/components/VaultSettings.tsx index 5d6353b0..4e61134d 100644 --- a/app/autovault/[vaultAddress]/components/VaultSettings.tsx +++ b/app/autovault/[vaultAddress]/components/VaultSettings.tsx @@ -1,4 +1,3 @@ -import { Card, CardHeader, CardBody } from '@heroui/react'; import { Button } from '@/components/common/Button'; import { AutovaultData } from '@/hooks/useAutovaultData'; @@ -7,168 +6,36 @@ type VaultSettingsProps = { onClose: () => void; }; -export function VaultSettings({ onClose }: VaultSettingsProps) { +export function VaultSettings({ onClose, vault }: VaultSettingsProps) { + const allocatorCount = vault.agents.length; + return ( -
+
-

Vault Settings

-

Configure your autovault automation and strategies

+

Vault Controls

+

Assign automation roles and manage the optimization agent.

- {/* Agent Configuration */} - - -

Agent Configuration

-
- -
-
-
-

Rebalancing Agent

-

- Automatically rebalance funds between markets based on yield opportunities -

-
- -
-
-
-

Risk Management Agent

-

- Monitor and manage risk exposure across markets -

-
- -
-
-
-

Yield Optimization Agent

-

- Optimize yield by finding the best opportunities -

-
- -
-
-
-
- - {/* Rebalancing Rules */} - - -

Rebalancing Rules

-
- -
-
- Minimum APY Difference -
- 2.0% - -
-
-
- Maximum Position per Market -
- 50% - -
-
-
- Rebalance Frequency -
- Daily - -
-
-
-
-
- - {/* Risk Parameters */} - - -

Risk Parameters

-
- -
-
- Maximum Utilization -
- 80% - -
-
-
- Emergency Stop Loss -
- Enabled - -
-
-
- Minimum Liquidity -
- $100K - -
-
-
-
-
- - {/* Vault Management */} - - -

Vault Management

-
- -
-
-
-

Pause Vault

-

Temporarily stop all automated activities

-
- -
-
-
-

Emergency Withdrawal

-

- Withdraw all funds and stop vault operations -

-
- -
-
-
-
+
+

Optimization agent

+

+ Authorize the allocator address that executes deposits and withdrawals between enabled adapters. +

+
+ + {allocatorCount === 0 + ? 'No allocator assigned' + : `${allocatorCount} allocator${allocatorCount > 1 ? 's' : ''} authorized`} + + +
+
diff --git a/app/autovault/[vaultAddress]/components/VaultSummaryMetrics.tsx b/app/autovault/[vaultAddress]/components/VaultSummaryMetrics.tsx new file mode 100644 index 00000000..20eb599b --- /dev/null +++ b/app/autovault/[vaultAddress]/components/VaultSummaryMetrics.tsx @@ -0,0 +1,37 @@ +import { ReactNode } from 'react'; + +export type VaultMetric = { + label: string; + value: string; + helper?: string; + trendLabel?: string; + trendValue?: string; + icon?: ReactNode; +}; + +type VaultSummaryMetricsProps = { + metrics: VaultMetric[]; +}; + +export function VaultSummaryMetrics({ metrics }: VaultSummaryMetricsProps) { + return ( +
+ {metrics.map((metric) => ( +
+
+ {metric.label} + {metric.icon && {metric.icon}} +
+
{metric.value}
+ {metric.helper &&
{metric.helper}
} + {metric.trendLabel && metric.trendValue && ( +
+ {metric.trendLabel} + {metric.trendValue} +
+ )} +
+ ))} +
+ ); +} diff --git a/app/autovault/[vaultAddress]/content.tsx b/app/autovault/[vaultAddress]/content.tsx index 970ab88e..3f5189fe 100644 --- a/app/autovault/[vaultAddress]/content.tsx +++ b/app/autovault/[vaultAddress]/content.tsx @@ -1,218 +1,327 @@ 'use client'; import { useMemo, useState } from 'react'; -import { Card, CardHeader, CardBody } from '@heroui/react'; -import { ChevronLeftIcon, GearIcon } from '@radix-ui/react-icons'; +import { Card } from '@heroui/react'; +import { GearIcon } from '@radix-ui/react-icons'; import Link from 'next/link'; -import { useParams, useRouter } from 'next/navigation'; +import { useParams } from 'next/navigation'; import { Address } from 'viem'; -import { useAccount } from 'wagmi'; +import { useAccount, useChainId } from 'wagmi'; import { Button } from '@/components/common'; import { AddressDisplay } from '@/components/common/AddressDisplay'; import Header from '@/components/layout/header/Header'; import LoadingScreen from '@/components/Status/LoadingScreen'; -import { useVaultDetails } from '@/hooks/useAutovaultData'; +import { AutovaultData, VaultAllocation, useVaultDetails } from '@/hooks/useAutovaultData'; +import { VaultApyHistory } from './components/VaultApyHistory'; +import { VaultAssetMovements, VaultAssetMovement } from './components/VaultAssetMovements'; +import { VaultMarketAllocations } from './components/VaultMarketAllocations'; +import { VaultRole } from './components/VaultRolesOverview'; import { VaultSettings } from './components/VaultSettings'; +import { VaultSummaryMetrics, VaultMetric } from './components/VaultSummaryMetrics'; +import { VaultAgentSummary } from './components/VaultAgentSummary'; +import { VaultRolesModal } from './components/VaultRolesModal'; +import { useVaultV2 } from '@/hooks/useVaultV2'; +import { ALL_SUPPORTED_NETWORKS, SupportedNetworks, getNetworkConfig } from '@/utils/networks'; +import { VaultInitializationModal } from './components/VaultInitializationModal'; + +function formatUsd(value: number | bigint): string { + const numeric = typeof value === 'bigint' ? Number(value) : value; + if (!numeric || Number.isNaN(numeric)) return '--'; + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: numeric >= 1 ? 2 : 4, + }).format(numeric); +} export default function VaultContent() { - const router = useRouter(); const { vaultAddress } = useParams<{ vaultAddress: string }>(); + const vaultAddressValue = vaultAddress as Address; const { address } = useAccount(); - const [showSettings, setShowSettings] = useState(false); + const chainId = useChainId(); + const supportedChainId = useMemo(() => { + const maybe = chainId as SupportedNetworks; + return ALL_SUPPORTED_NETWORKS.includes(maybe) ? maybe : SupportedNetworks.Base; + }, [chainId]); - const { vault, isLoading, isError } = useVaultDetails(vaultAddress as Address); + const networkConfig = useMemo(() => { + try { + return getNetworkConfig(supportedChainId); + } catch (error) { + return null; + } + }, [supportedChainId]); - const isOwner = useMemo(() => { - if (!vault || !address) return false; - return vault.owner.toLowerCase() === address.toLowerCase(); - }, [vault, address]); + const { adapter, needsSetup, isLoading: adapterLoading, refetch: refetchAdapter } = useVaultV2({ + vaultAddress: vaultAddressValue, + chainId: supportedChainId, + }); + + const [showSettings, setShowSettings] = useState(false); + const [showRolesModal, setShowRolesModal] = useState(false); + const [showInitializationModal, setShowInitializationModal] = useState(false); + + const { vault, isLoading, isError } = useVaultDetails(vaultAddressValue); if (isLoading) { return ( -
+
- +
+ +
); } - if (isError || !vault) { + if (isError) { return ( -
+
-
-
-
-

Vault Not Found

-

- The requested vault could not be found or does not exist. -

- - - -
+
+
+

Vault data unavailable

+

+ We could not load this autovault right now. Please retry in a few minutes. +

+ + +
); } + const now = new Date(); + const placeholderVault: AutovaultData = { + id: 'placeholder', + address: vaultAddress as Address, + name: 'Autovault strategy overview', + description: 'Track how your automation is performing and what still needs configuration.', + totalValue: BigInt(2_100_000_000), + currentApy: 7.4, + agents: [ + { + id: '0xallocator-bot', + name: 'Allocator Bot', + description: 'Automated allocator executing the base strategy.', + status: 'active', + performance: { + totalValue: BigInt(1_500_000_000), + apr: 6.9, + totalReturns: BigInt(120_000_000), + }, + }, + ], + status: 'active', + owner: (address ?? '0x0000000000000000000000000000000000000000') as Address, + createdAt: now, + lastActivity: now, + rebalanceHistory: [], + allocations: [], + }; + + const displayVault = vault ?? placeholderVault; + const isPlaceholder = !vault; + + const isOwner = Boolean( + displayVault?.owner && + address && + displayVault.owner.toLowerCase() === (address ?? '').toLowerCase(), + ); + + const metrics: VaultMetric[] = [ + { + label: 'Total Assets', + value: formatUsd(Number(displayVault.totalValue) / 1e6), + helper: 'Assets currently automated across adapters', + }, + { + label: 'Current APY', + value: displayVault.currentApy ? `${displayVault.currentApy.toFixed(2)}%` : '--', + helper: 'Net of performance and management fees', + }, + { + label: '24h Earnings', + value: isPlaceholder ? '$12,420' : '--', + helper: 'Based on adapter reports (coming soon)', + }, + { + label: 'Last Activity', + value: displayVault.lastActivity.toLocaleDateString(), + helper: 'Most recent automation event', + }, + ]; + + const marketAllocations: VaultAllocation[] = displayVault.allocations ?? []; + const vaultAssetSymbol = marketAllocations[0]?.assetSymbol ?? '—'; + + const assetMovements: VaultAssetMovement[] = isPlaceholder + ? [] + : displayVault.rebalanceHistory.map((rebalance) => ({ + timestamp: rebalance.timestamp.toLocaleString(), + action: 'allocate', + from: rebalance.fromMarket, + to: rebalance.toMarket, + amount: `${Number(rebalance.amount ?? 0n) / 1e6} tokens`, + })); + + const allocatorAddresses = displayVault.agents.map((agent) => agent.id); + + const roles: VaultRole[] = [ + { + key: 'owner', + label: 'Owner', + description: 'Appoints other roles and manages high-level governance.', + addresses: displayVault.owner ? [displayVault.owner] : [], + status: displayVault.owner ? 'configured' : 'pending', + guidance: 'Assign a secure multisig (4-of-6 recommended) responsible for curators and sentinels.', + capabilities: ['Transfer ownership', 'Appoint curator', 'Add/remove sentinels', 'Update vault metadata'], + }, + { + key: 'curator', + label: 'Risk Curator', + description: 'Defines adapters, caps, and fees via timelocked actions.', + addresses: [], + status: 'pending', + guidance: 'Nominate a curator multisig so the strategy can evolve under controlled timelocks.', + capabilities: ['Enable/disable adapters (timelocked)', 'Tune caps and rates', 'Manage allocators & compliance gates'], + }, + { + key: 'allocator', + label: 'Allocator(s)', + description: 'Executes the strategy within the guardrails the curator sets.', + addresses: allocatorAddresses, + status: allocatorAddresses.length > 0 ? 'configured' : 'pending', + guidance: 'Authorize your automation agent or desk wallet so it can move liquidity between adapters.', + capabilities: ['Allocate idle assets', 'Deallocate when liquidity is needed', 'Operate liquidity adapter for deposits'], + }, + { + key: 'sentinel', + label: 'Sentinel', + description: 'Emergency responder that can unwind or veto risky actions.', + addresses: [], + status: 'pending', + guidance: 'Add a sentinel key (bot or DAO) that can revoke unsafe curator actions and unwind adapters fast.', + capabilities: ['Instantly lower caps', 'Deallocate from adapters', 'Revoke timelocked actions before execution'], + }, + ]; + return ( -
+
-
- {/* Header Section */} -
- -
-

{vault.name}

-

{vault.description}

+
+
+
+
+

{displayVault.name}

+

{displayVault.description}

+
Automation service overview • vault analytics
+
+ {isOwner && ( + + )}
- {isOwner && ( - + + {isPlaceholder && ( +
+ No on-chain telemetry yet—this layout shows where we will surface performance, allocations, and role status once data is wired in. +
)} -
- {/* Vault Address */} -
- -
+ - {/* Main Content Grid */} -
- {/* Basic Info Card */} - - -

Vault Overview

-
- -
-
- Status - - {vault.status.charAt(0).toUpperCase() + vault.status.slice(1)} - -
-
- Active Agents - - {vault.agents.filter((agent) => agent.status === 'active').length} - -
-
- Created - {vault.createdAt.toLocaleDateString()} -
-
-
-
- - {/* Performance Card */} - - -

Performance

-
- -
-
-

- {vault.currentApy.toFixed(2)}% -

-

Current APY

-
+ {needsSetup && networkConfig?.vaultConfig?.marketV1AdapterFactory && ( +
+
+

Adapter not configured

+

+ Finish the initialization process to begin configuring strategies for this vault. +

- - - - {/* Agents Card */} - - -

Active Agents

-
- - {vault.agents.length === 0 ? ( -

No agents configured

- ) : ( -
- {vault.agents.map((agent) => ( -
-
-

{agent.name}

-

{agent.description}

-
- - {agent.status} - -
- ))} -
- )} -
-
-
+ +
+ )} - {/* Settings Panel */} - {showSettings && ( -
- - - setShowSettings(false)} /> - - -
- )} + + + role.status !== 'configured').length > 0 + ? `Pending roles: ${roles + .filter((role) => role.status !== 'configured') + .map((role) => role.label) + .join(', ')}` + : 'All critical roles are assigned to safe wallets.' + } + onManageAgents={() => + needsSetup && networkConfig?.vaultConfig?.marketV1AdapterFactory + ? setShowInitializationModal(true) + : setShowSettings(true) + } + onViewRoles={() => setShowRolesModal(true)} + /> + + + + - {/* TODO: Add charts and more detailed analytics */} - {!showSettings && ( -
+ {showSettings && ( - -

Analytics & Charts

-
- -
-

Performance charts and analytics coming soon...

-
-
+ setShowSettings(false)} />
-
- )} + )} +
+ + setShowRolesModal(false)} + roles={roles} + /> + {networkConfig?.vaultConfig?.marketV1AdapterFactory && ( + setShowInitializationModal(false)} + vaultAddress={vaultAddressValue} + chainId={supportedChainId} + onAdapterConfigured={() => void refetchAdapter()} + /> + )}
); } diff --git a/app/autovault/components/VaultListV2.tsx b/app/autovault/components/VaultListV2.tsx index 324d7c8d..956d1930 100644 --- a/app/autovault/components/VaultListV2.tsx +++ b/app/autovault/components/VaultListV2.tsx @@ -1,5 +1,7 @@ import Image from 'next/image'; +import Link from 'next/link'; import { formatUnits } from 'viem'; +import { Button } from '@/components/common'; import { Spinner } from '@/components/common/Spinner'; import { useTokens } from '@/components/providers/TokenProvider'; import { TokenIcon } from '@/components/TokenIcon'; @@ -53,6 +55,7 @@ export function VaultListV2({ vaults, loading }: VaultListV2Props) { APY Agents Collaterals + Action @@ -102,6 +105,17 @@ export function VaultListV2({ vaults, loading }: VaultListV2Props) { -- + + {/* Action */} + +
+ + + +
+ ); })} @@ -110,4 +124,4 @@ export function VaultListV2({ vaults, loading }: VaultListV2Props) {
); -} \ No newline at end of file +} diff --git a/docs/Styling.md b/docs/Styling.md index f48336cc..6c5f9ccb 100644 --- a/docs/Styling.md +++ b/docs/Styling.md @@ -114,6 +114,11 @@ Use the nextui tooltip with component for consistent styling. A **Important:** The `classNames` configuration removes HeroUI's default padding, background, and borders to prevent double-wrapper styling issues. This ensures only your `TooltipContent` component handles the visual styling. +## Shared UI Elements + +- Render token avatars with `TokenIcon` (`@/components/TokenIcon`) so chain-specific fallbacks, glyph sizing, and tooltips stay consistent. +- Display oracle provenance data with `OracleVendorBadge` (`@/components/OracleVendorBadge`) instead of plain text to benefit from vendor icons, warnings, and tooltips. + ## Input Components The codebase uses two different input approaches depending on the use case: diff --git a/src/abis/morpho-market-v1-adapter-factory.ts b/src/abis/morpho-market-v1-adapter-factory.ts new file mode 100644 index 00000000..688c2eac --- /dev/null +++ b/src/abis/morpho-market-v1-adapter-factory.ts @@ -0,0 +1,3 @@ +import { Abi } from "viem"; + +export const adapterFactoryAbi = [{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"parentVault","type":"address"},{"indexed":true,"internalType":"address","name":"morpho","type":"address"},{"indexed":true,"internalType":"address","name":"morphoMarketV1Adapter","type":"address"}],"name":"CreateMorphoMarketV1Adapter","type":"event"},{"inputs":[{"internalType":"address","name":"parentVault","type":"address"},{"internalType":"address","name":"morpho","type":"address"}],"name":"createMorphoMarketV1Adapter","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"isMorphoMarketV1Adapter","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"parentVault","type":"address"},{"internalType":"address","name":"morpho","type":"address"}],"name":"morphoMarketV1Adapter","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"}] as const satisfies Abi; \ No newline at end of file diff --git a/src/abis/vaultv2.ts b/src/abis/vaultv2.ts new file mode 100644 index 00000000..5b798bc0 --- /dev/null +++ b/src/abis/vaultv2.ts @@ -0,0 +1,3 @@ +import { Abi } from "viem"; + +export const vaultv2Abi = [{"inputs":[{"internalType":"address","name":"_owner","type":"address"},{"internalType":"address","name":"_asset","type":"address"}],"stateMutability":"nonpayable","type":"constructor"},{"inputs":[],"name":"Abdicated","type":"error"},{"inputs":[],"name":"AbsoluteCapExceeded","type":"error"},{"inputs":[],"name":"AbsoluteCapNotDecreasing","type":"error"},{"inputs":[],"name":"AbsoluteCapNotIncreasing","type":"error"},{"inputs":[],"name":"AutomaticallyTimelocked","type":"error"},{"inputs":[],"name":"CannotReceiveAssets","type":"error"},{"inputs":[],"name":"CannotReceiveShares","type":"error"},{"inputs":[],"name":"CannotSendAssets","type":"error"},{"inputs":[],"name":"CannotSendShares","type":"error"},{"inputs":[],"name":"CastOverflow","type":"error"},{"inputs":[],"name":"DataAlreadyPending","type":"error"},{"inputs":[],"name":"DataNotTimelocked","type":"error"},{"inputs":[],"name":"FeeInvariantBroken","type":"error"},{"inputs":[],"name":"FeeTooHigh","type":"error"},{"inputs":[],"name":"InvalidSigner","type":"error"},{"inputs":[],"name":"MaxRateTooHigh","type":"error"},{"inputs":[],"name":"NoCode","type":"error"},{"inputs":[],"name":"NotAdapter","type":"error"},{"inputs":[],"name":"NotInAdapterRegistry","type":"error"},{"inputs":[],"name":"PenaltyTooHigh","type":"error"},{"inputs":[],"name":"PermitDeadlineExpired","type":"error"},{"inputs":[],"name":"RelativeCapAboveOne","type":"error"},{"inputs":[],"name":"RelativeCapExceeded","type":"error"},{"inputs":[],"name":"RelativeCapNotDecreasing","type":"error"},{"inputs":[],"name":"RelativeCapNotIncreasing","type":"error"},{"inputs":[],"name":"TimelockNotDecreasing","type":"error"},{"inputs":[],"name":"TimelockNotExpired","type":"error"},{"inputs":[],"name":"TimelockNotIncreasing","type":"error"},{"inputs":[],"name":"TransferFromReturnedFalse","type":"error"},{"inputs":[],"name":"TransferFromReverted","type":"error"},{"inputs":[],"name":"TransferReturnedFalse","type":"error"},{"inputs":[],"name":"TransferReverted","type":"error"},{"inputs":[],"name":"Unauthorized","type":"error"},{"inputs":[],"name":"ZeroAbsoluteCap","type":"error"},{"inputs":[],"name":"ZeroAddress","type":"error"},{"inputs":[],"name":"ZeroAllocation","type":"error"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes4","name":"selector","type":"bytes4"}],"name":"Abdicate","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes4","name":"selector","type":"bytes4"},{"indexed":false,"internalType":"bytes","name":"data","type":"bytes"}],"name":"Accept","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"uint256","name":"previousTotalAssets","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"newTotalAssets","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"performanceFeeShares","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"managementFeeShares","type":"uint256"}],"name":"AccrueInterest","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"account","type":"address"}],"name":"AddAdapter","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"sender","type":"address"},{"indexed":true,"internalType":"address","name":"adapter","type":"address"},{"indexed":false,"internalType":"uint256","name":"assets","type":"uint256"},{"indexed":false,"internalType":"bytes32[]","name":"ids","type":"bytes32[]"},{"indexed":false,"internalType":"int256","name":"change","type":"int256"}],"name":"Allocate","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"owner","type":"address"},{"indexed":true,"internalType":"address","name":"spender","type":"address"},{"indexed":false,"internalType":"uint256","name":"shares","type":"uint256"}],"name":"AllowanceUpdatedByTransferFrom","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"owner","type":"address"},{"indexed":true,"internalType":"address","name":"spender","type":"address"},{"indexed":false,"internalType":"uint256","name":"shares","type":"uint256"}],"name":"Approval","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"owner","type":"address"},{"indexed":true,"internalType":"address","name":"asset","type":"address"}],"name":"Constructor","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"sender","type":"address"},{"indexed":true,"internalType":"address","name":"adapter","type":"address"},{"indexed":false,"internalType":"uint256","name":"assets","type":"uint256"},{"indexed":false,"internalType":"bytes32[]","name":"ids","type":"bytes32[]"},{"indexed":false,"internalType":"int256","name":"change","type":"int256"}],"name":"Deallocate","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"sender","type":"address"},{"indexed":true,"internalType":"bytes32","name":"id","type":"bytes32"},{"indexed":false,"internalType":"bytes","name":"idData","type":"bytes"},{"indexed":false,"internalType":"uint256","name":"newAbsoluteCap","type":"uint256"}],"name":"DecreaseAbsoluteCap","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"sender","type":"address"},{"indexed":true,"internalType":"bytes32","name":"id","type":"bytes32"},{"indexed":false,"internalType":"bytes","name":"idData","type":"bytes"},{"indexed":false,"internalType":"uint256","name":"newRelativeCap","type":"uint256"}],"name":"DecreaseRelativeCap","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes4","name":"selector","type":"bytes4"},{"indexed":false,"internalType":"uint256","name":"newDuration","type":"uint256"}],"name":"DecreaseTimelock","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"sender","type":"address"},{"indexed":true,"internalType":"address","name":"onBehalf","type":"address"},{"indexed":false,"internalType":"uint256","name":"assets","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"shares","type":"uint256"}],"name":"Deposit","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"sender","type":"address"},{"indexed":false,"internalType":"address","name":"adapter","type":"address"},{"indexed":false,"internalType":"uint256","name":"assets","type":"uint256"},{"indexed":true,"internalType":"address","name":"onBehalf","type":"address"},{"indexed":false,"internalType":"bytes32[]","name":"ids","type":"bytes32[]"},{"indexed":false,"internalType":"uint256","name":"penaltyAssets","type":"uint256"}],"name":"ForceDeallocate","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes32","name":"id","type":"bytes32"},{"indexed":false,"internalType":"bytes","name":"idData","type":"bytes"},{"indexed":false,"internalType":"uint256","name":"newAbsoluteCap","type":"uint256"}],"name":"IncreaseAbsoluteCap","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes32","name":"id","type":"bytes32"},{"indexed":false,"internalType":"bytes","name":"idData","type":"bytes"},{"indexed":false,"internalType":"uint256","name":"newRelativeCap","type":"uint256"}],"name":"IncreaseRelativeCap","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes4","name":"selector","type":"bytes4"},{"indexed":false,"internalType":"uint256","name":"newDuration","type":"uint256"}],"name":"IncreaseTimelock","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"owner","type":"address"},{"indexed":true,"internalType":"address","name":"spender","type":"address"},{"indexed":false,"internalType":"uint256","name":"shares","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"nonce","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"deadline","type":"uint256"}],"name":"Permit","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"account","type":"address"}],"name":"RemoveAdapter","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"sender","type":"address"},{"indexed":true,"internalType":"bytes4","name":"selector","type":"bytes4"},{"indexed":false,"internalType":"bytes","name":"data","type":"bytes"}],"name":"Revoke","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"newAdapterRegistry","type":"address"}],"name":"SetAdapterRegistry","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"newCurator","type":"address"}],"name":"SetCurator","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"adapter","type":"address"},{"indexed":false,"internalType":"uint256","name":"forceDeallocatePenalty","type":"uint256"}],"name":"SetForceDeallocatePenalty","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"account","type":"address"},{"indexed":false,"internalType":"bool","name":"newIsAllocator","type":"bool"}],"name":"SetIsAllocator","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"account","type":"address"},{"indexed":false,"internalType":"bool","name":"newIsSentinel","type":"bool"}],"name":"SetIsSentinel","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"sender","type":"address"},{"indexed":true,"internalType":"address","name":"newLiquidityAdapter","type":"address"},{"indexed":true,"internalType":"bytes","name":"newLiquidityData","type":"bytes"}],"name":"SetLiquidityAdapterAndData","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"uint256","name":"newManagementFee","type":"uint256"}],"name":"SetManagementFee","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"newManagementFeeRecipient","type":"address"}],"name":"SetManagementFeeRecipient","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"uint256","name":"newMaxRate","type":"uint256"}],"name":"SetMaxRate","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"string","name":"newName","type":"string"}],"name":"SetName","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"newOwner","type":"address"}],"name":"SetOwner","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"uint256","name":"newPerformanceFee","type":"uint256"}],"name":"SetPerformanceFee","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"newPerformanceFeeRecipient","type":"address"}],"name":"SetPerformanceFeeRecipient","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"newReceiveAssetsGate","type":"address"}],"name":"SetReceiveAssetsGate","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"newReceiveSharesGate","type":"address"}],"name":"SetReceiveSharesGate","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"newSendAssetsGate","type":"address"}],"name":"SetSendAssetsGate","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"newSendSharesGate","type":"address"}],"name":"SetSendSharesGate","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"string","name":"newSymbol","type":"string"}],"name":"SetSymbol","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes4","name":"selector","type":"bytes4"},{"indexed":false,"internalType":"bytes","name":"data","type":"bytes"},{"indexed":false,"internalType":"uint256","name":"executableAt","type":"uint256"}],"name":"Submit","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":false,"internalType":"uint256","name":"shares","type":"uint256"}],"name":"Transfer","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"sender","type":"address"},{"indexed":true,"internalType":"address","name":"receiver","type":"address"},{"indexed":true,"internalType":"address","name":"onBehalf","type":"address"},{"indexed":false,"internalType":"uint256","name":"assets","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"shares","type":"uint256"}],"name":"Withdraw","type":"event"},{"inputs":[],"name":"DOMAIN_SEPARATOR","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"_totalAssets","outputs":[{"internalType":"uint128","name":"","type":"uint128"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes4","name":"selector","type":"bytes4"}],"name":"abdicate","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes4","name":"selector","type":"bytes4"}],"name":"abdicated","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes32","name":"id","type":"bytes32"}],"name":"absoluteCap","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"accrueInterest","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"accrueInterestView","outputs":[{"internalType":"uint256","name":"","type":"uint256"},{"internalType":"uint256","name":"","type":"uint256"},{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"adapterRegistry","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"","type":"uint256"}],"name":"adapters","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"adaptersLength","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"addAdapter","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"adapter","type":"address"},{"internalType":"bytes","name":"data","type":"bytes"},{"internalType":"uint256","name":"assets","type":"uint256"}],"name":"allocate","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes32","name":"id","type":"bytes32"}],"name":"allocation","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"owner","type":"address"},{"internalType":"address","name":"spender","type":"address"}],"name":"allowance","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"shares","type":"uint256"}],"name":"approve","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"asset","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"balanceOf","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"canReceiveAssets","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"canReceiveShares","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"canSendAssets","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"canSendShares","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"shares","type":"uint256"}],"name":"convertToAssets","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"assets","type":"uint256"}],"name":"convertToShares","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"curator","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"adapter","type":"address"},{"internalType":"bytes","name":"data","type":"bytes"},{"internalType":"uint256","name":"assets","type":"uint256"}],"name":"deallocate","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"decimals","outputs":[{"internalType":"uint8","name":"","type":"uint8"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes","name":"idData","type":"bytes"},{"internalType":"uint256","name":"newAbsoluteCap","type":"uint256"}],"name":"decreaseAbsoluteCap","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes","name":"idData","type":"bytes"},{"internalType":"uint256","name":"newRelativeCap","type":"uint256"}],"name":"decreaseRelativeCap","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes4","name":"selector","type":"bytes4"},{"internalType":"uint256","name":"newDuration","type":"uint256"}],"name":"decreaseTimelock","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint256","name":"assets","type":"uint256"},{"internalType":"address","name":"onBehalf","type":"address"}],"name":"deposit","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes","name":"data","type":"bytes"}],"name":"executableAt","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"firstTotalAssets","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"adapter","type":"address"},{"internalType":"bytes","name":"data","type":"bytes"},{"internalType":"uint256","name":"assets","type":"uint256"},{"internalType":"address","name":"onBehalf","type":"address"}],"name":"forceDeallocate","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"adapter","type":"address"}],"name":"forceDeallocatePenalty","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes","name":"idData","type":"bytes"},{"internalType":"uint256","name":"newAbsoluteCap","type":"uint256"}],"name":"increaseAbsoluteCap","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes","name":"idData","type":"bytes"},{"internalType":"uint256","name":"newRelativeCap","type":"uint256"}],"name":"increaseRelativeCap","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes4","name":"selector","type":"bytes4"},{"internalType":"uint256","name":"newDuration","type":"uint256"}],"name":"increaseTimelock","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"isAdapter","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"isAllocator","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"isSentinel","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"lastUpdate","outputs":[{"internalType":"uint64","name":"","type":"uint64"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"liquidityAdapter","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"liquidityData","outputs":[{"internalType":"bytes","name":"","type":"bytes"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"managementFee","outputs":[{"internalType":"uint96","name":"","type":"uint96"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"managementFeeRecipient","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"maxDeposit","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"pure","type":"function"},{"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"maxMint","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"pure","type":"function"},{"inputs":[],"name":"maxRate","outputs":[{"internalType":"uint64","name":"","type":"uint64"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"maxRedeem","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"pure","type":"function"},{"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"maxWithdraw","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"pure","type":"function"},{"inputs":[{"internalType":"uint256","name":"shares","type":"uint256"},{"internalType":"address","name":"onBehalf","type":"address"}],"name":"mint","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes[]","name":"data","type":"bytes[]"}],"name":"multicall","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"name","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"nonces","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"owner","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"performanceFee","outputs":[{"internalType":"uint96","name":"","type":"uint96"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"performanceFeeRecipient","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"_owner","type":"address"},{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"shares","type":"uint256"},{"internalType":"uint256","name":"deadline","type":"uint256"},{"internalType":"uint8","name":"v","type":"uint8"},{"internalType":"bytes32","name":"r","type":"bytes32"},{"internalType":"bytes32","name":"s","type":"bytes32"}],"name":"permit","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint256","name":"assets","type":"uint256"}],"name":"previewDeposit","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"shares","type":"uint256"}],"name":"previewMint","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"shares","type":"uint256"}],"name":"previewRedeem","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"assets","type":"uint256"}],"name":"previewWithdraw","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"receiveAssetsGate","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"receiveSharesGate","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"shares","type":"uint256"},{"internalType":"address","name":"receiver","type":"address"},{"internalType":"address","name":"onBehalf","type":"address"}],"name":"redeem","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes32","name":"id","type":"bytes32"}],"name":"relativeCap","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"removeAdapter","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes","name":"data","type":"bytes"}],"name":"revoke","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"sendAssetsGate","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"sendSharesGate","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"newAdapterRegistry","type":"address"}],"name":"setAdapterRegistry","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"newCurator","type":"address"}],"name":"setCurator","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"adapter","type":"address"},{"internalType":"uint256","name":"newForceDeallocatePenalty","type":"uint256"}],"name":"setForceDeallocatePenalty","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"account","type":"address"},{"internalType":"bool","name":"newIsAllocator","type":"bool"}],"name":"setIsAllocator","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"account","type":"address"},{"internalType":"bool","name":"newIsSentinel","type":"bool"}],"name":"setIsSentinel","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"newLiquidityAdapter","type":"address"},{"internalType":"bytes","name":"newLiquidityData","type":"bytes"}],"name":"setLiquidityAdapterAndData","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint256","name":"newManagementFee","type":"uint256"}],"name":"setManagementFee","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"newManagementFeeRecipient","type":"address"}],"name":"setManagementFeeRecipient","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint256","name":"newMaxRate","type":"uint256"}],"name":"setMaxRate","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"string","name":"newName","type":"string"}],"name":"setName","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"newOwner","type":"address"}],"name":"setOwner","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint256","name":"newPerformanceFee","type":"uint256"}],"name":"setPerformanceFee","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"newPerformanceFeeRecipient","type":"address"}],"name":"setPerformanceFeeRecipient","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"newReceiveAssetsGate","type":"address"}],"name":"setReceiveAssetsGate","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"newReceiveSharesGate","type":"address"}],"name":"setReceiveSharesGate","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"newSendAssetsGate","type":"address"}],"name":"setSendAssetsGate","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"newSendSharesGate","type":"address"}],"name":"setSendSharesGate","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"string","name":"newSymbol","type":"string"}],"name":"setSymbol","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes","name":"data","type":"bytes"}],"name":"submit","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"symbol","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes4","name":"selector","type":"bytes4"}],"name":"timelock","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"totalAssets","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"totalSupply","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"shares","type":"uint256"}],"name":"transfer","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"from","type":"address"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"shares","type":"uint256"}],"name":"transferFrom","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"virtualShares","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"assets","type":"uint256"},{"internalType":"address","name":"receiver","type":"address"},{"internalType":"address","name":"onBehalf","type":"address"}],"name":"withdraw","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"nonpayable","type":"function"}] as const satisfies Abi; \ No newline at end of file diff --git a/src/data-sources/subgraph/morpho-market-v1-adapters.ts b/src/data-sources/subgraph/morpho-market-v1-adapters.ts new file mode 100644 index 00000000..c88b99aa --- /dev/null +++ b/src/data-sources/subgraph/morpho-market-v1-adapters.ts @@ -0,0 +1,49 @@ +import { Address } from 'viem'; +import { subgraphGraphqlFetcher } from './fetchers'; +import { morphoMarketV1AdaptersQuery } from '@/graphql/morpho-market-v1-adapter-queries'; + +type MorphoMarketV1AdaptersResponse = { + data?: { + createMorphoMarketV1Adapters: { + id: string; + parentVault: string; + morpho: string; + morphoMarketV1Adapter: string; + }[]; + }; +}; + +export type MorphoMarketV1AdapterRecord = { + id: string; + adapter: Address; + parentVault: Address; + morpho: Address; +}; + +export async function fetchMorphoMarketV1Adapters({ + subgraphUrl, + parentVault, + morpho, +}: { + subgraphUrl: string; + parentVault: Address; + morpho: Address; +}): Promise { + const response = await subgraphGraphqlFetcher( + subgraphUrl, + morphoMarketV1AdaptersQuery, + { + parentVault: parentVault.toLowerCase(), + morpho: morpho.toLowerCase(), + }, + ); + + const adapters = response.data?.createMorphoMarketV1Adapters ?? []; + + return adapters.map((adapter) => ({ + id: adapter.id, + adapter: adapter.morphoMarketV1Adapter as Address, + parentVault: adapter.parentVault as Address, + morpho: adapter.morpho as Address, + })); +} diff --git a/src/data-sources/subgraph/v2-vaults.ts b/src/data-sources/subgraph/v2-vaults.ts index b18ed481..4f8d63c4 100644 --- a/src/data-sources/subgraph/v2-vaults.ts +++ b/src/data-sources/subgraph/v2-vaults.ts @@ -30,12 +30,12 @@ export const fetchUserVaultsV2 = async ( ): Promise => { const agentConfig = getAgentConfig(network); - if (!agentConfig?.subgraphEndpoint) { + if (!agentConfig?.vaultsSubgraphEndpoint) { console.log(`No subgraph endpoint configured for network ${network}`); return []; } - const subgraphUrl = agentConfig.subgraphEndpoint; + const subgraphUrl = agentConfig.vaultsSubgraphEndpoint; const userVaults: UserVaultV2[] = []; try { diff --git a/src/graphql/morpho-market-v1-adapter-queries.ts b/src/graphql/morpho-market-v1-adapter-queries.ts new file mode 100644 index 00000000..cf2713cb --- /dev/null +++ b/src/graphql/morpho-market-v1-adapter-queries.ts @@ -0,0 +1,10 @@ +export const morphoMarketV1AdaptersQuery = ` + query CreateMorphoMarketV1Adapters($parentVault: String!, $morpho: String!) { + createMorphoMarketV1Adapters(where: { parentVault: $parentVault, morpho: $morpho }) { + id + parentVault + morpho + morphoMarketV1Adapter + } + } +`; diff --git a/src/hooks/useAutovaultData.ts b/src/hooks/useAutovaultData.ts index 77fc8a3b..f33eebe0 100644 --- a/src/hooks/useAutovaultData.ts +++ b/src/hooks/useAutovaultData.ts @@ -1,5 +1,19 @@ import { useState, useEffect } from 'react'; import { Address } from 'viem'; +import { MorphoChainlinkOracleData } from '@/utils/types'; + +export type VaultAllocation = { + marketId: string; + chainId: number; + collateralAddress: Address; + collateralSymbol: string; + assetSymbol: string; + allocationFormatted: string; + apy: number | null; + lltv: number | null; + oracleData: MorphoChainlinkOracleData | null; + allocationPercent: number | null; +}; export type AutovaultStatus = 'active' | 'paused' | 'inactive'; @@ -34,6 +48,7 @@ export type AutovaultData = { amount: bigint; reason: string; }[]; + allocations?: VaultAllocation[]; }; type UseAutovaultDataResult = { diff --git a/src/hooks/useDeployMorphoMarketV1Adapter.ts b/src/hooks/useDeployMorphoMarketV1Adapter.ts new file mode 100644 index 00000000..29934acd --- /dev/null +++ b/src/hooks/useDeployMorphoMarketV1Adapter.ts @@ -0,0 +1,74 @@ +import { useCallback, useMemo } from 'react'; +import { Address, encodeFunctionData, zeroAddress } from 'viem'; +import { useAccount, useChainId } from 'wagmi'; +import { adapterFactoryAbi } from '@/abis/morpho-market-v1-adapter-factory'; +import { useTransactionWithToast } from './useTransactionWithToast'; +import { getNetworkConfig, SupportedNetworks } from '@/utils/networks'; +import { getMorphoAddress } from '@/utils/morpho'; + +const TX_TOAST_ID = 'deploy-morpho-market-adapter'; + +export function useDeployMorphoMarketV1Adapter({ + vaultAddress, + chainId, + morphoAddress, +}: { + vaultAddress?: Address; + chainId?: SupportedNetworks | number; + morphoAddress?: Address; +}) { + const { address: account } = useAccount(); + const connectedChainId = useChainId(); + const resolvedChainId = (chainId ?? connectedChainId) as SupportedNetworks; + + const factoryAddress = useMemo(() => { + try { + return getNetworkConfig(resolvedChainId).vaultConfig?.marketV1AdapterFactory ?? null; + } catch (error) { + return null; + } + }, [resolvedChainId]); + + const morpho = useMemo(() => { + if (morphoAddress) return morphoAddress; + return getMorphoAddress(resolvedChainId); + }, [morphoAddress, resolvedChainId]); + + const canDeploy = Boolean( + factoryAddress && + vaultAddress && + morpho && + morpho !== zeroAddress, + ); + + const { isConfirming: isDeploying, sendTransactionAsync } = useTransactionWithToast({ + toastId: TX_TOAST_ID, + pendingText: 'Deploying adapter', + successText: 'Adapter deployed', + errorText: 'Failed to deploy adapter', + pendingDescription: 'Creating Morpho Market V1 adapter for this vault', + successDescription: 'Adapter created. It may take a few seconds for data to index.', + chainId: resolvedChainId, + }); + + const deploy = useCallback(async () => { + if (!canDeploy || !account) return; + + await sendTransactionAsync({ + account, + to: factoryAddress as Address, + data: encodeFunctionData({ + abi: adapterFactoryAbi, + functionName: 'createMorphoMarketV1Adapter', + args: [vaultAddress as Address, morpho as Address], + }), + }); + }, [account, canDeploy, factoryAddress, morpho, sendTransactionAsync, vaultAddress]); + + return { + deploy, + isDeploying, + factoryAddress, + canDeploy, + }; +} diff --git a/src/hooks/useMorphoMarketV1Adapters.ts b/src/hooks/useMorphoMarketV1Adapters.ts new file mode 100644 index 00000000..2f99b2d9 --- /dev/null +++ b/src/hooks/useMorphoMarketV1Adapters.ts @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Address } from 'viem'; +import { fetchMorphoMarketV1Adapters, MorphoMarketV1AdapterRecord } from '@/data-sources/subgraph/morpho-market-v1-adapters'; +import { getMorphoAddress } from '@/utils/morpho'; +import { getNetworkConfig, SupportedNetworks } from '@/utils/networks'; + +export function useMorphoMarketV1Adapters({ + vaultAddress, + chainId, +}: { + vaultAddress?: Address; + chainId: SupportedNetworks; +}) { + const [adapters, setAdapters] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const vaultConfig = useMemo(() => { + try { + return getNetworkConfig(chainId).vaultConfig; + } catch (err) { + return undefined; + } + }, [chainId]); + + const subgraphUrl = vaultConfig?.adapterSubgraphEndpoint ?? null; + const morpho = useMemo(() => getMorphoAddress(chainId), [chainId]); + + const fetchAdapters = useCallback(async () => { + if (!vaultAddress || !subgraphUrl) { + setAdapters([]); + setError(null); + return; + } + + setLoading(true); + setError(null); + + try { + const result = await fetchMorphoMarketV1Adapters({ + subgraphUrl, + parentVault: vaultAddress, + morpho, + }); + setAdapters(result); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to fetch adapters')); + setAdapters([]); + } finally { + setLoading(false); + } + }, [vaultAddress, subgraphUrl, morpho]); + + useEffect(() => { + void fetchAdapters(); + }, [fetchAdapters]); + + return { + adapters, + loading, + error, + refetch: fetchAdapters, + hasAdapters: adapters.length > 0, + }; +} diff --git a/src/hooks/useVaultV2.ts b/src/hooks/useVaultV2.ts new file mode 100644 index 00000000..b2a4d5b2 --- /dev/null +++ b/src/hooks/useVaultV2.ts @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; +import { Address, zeroAddress } from 'viem'; +import { useChainId, useReadContract } from 'wagmi'; +import { vaultv2Abi } from '@/abis/vaultv2'; +import { SupportedNetworks } from '@/utils/networks'; + +const ADAPTER_INDEX = 0n; + +export function useVaultV2({ + vaultAddress, + chainId, +}: { + vaultAddress?: Address; + chainId?: SupportedNetworks | number; +}) { + const connectedChainId = useChainId(); + const chainIdToUse = (chainId ?? connectedChainId) as SupportedNetworks; + + const { + data, + isLoading, + isFetching, + refetch, + error, + } = useReadContract({ + address: vaultAddress, + abi: vaultv2Abi, + functionName: 'adapters', + args: [ADAPTER_INDEX], + chainId: chainIdToUse, + query: { + enabled: Boolean(vaultAddress), + }, + }); + + const adapter = useMemo(() => { + if (!data) return zeroAddress; + return data as Address; + }, [data]); + + const needsSetup = adapter === zeroAddress; + + return { + adapter, + needsSetup, + isLoading: isLoading || isFetching, + refetch, + error: error as Error | null, + }; +} diff --git a/src/utils/networks.ts b/src/utils/networks.ts index 952c4bc4..29e3406f 100644 --- a/src/utils/networks.ts +++ b/src/utils/networks.ts @@ -51,7 +51,10 @@ export const hyperevm = defineChain({ type VaultAgentConfig = { v2FactoryAddress: Address; - subgraphEndpoint?: string // temporary to allow fetching deployed vaults from subgraph + vaultsSubgraphEndpoint?: string // temporary Subgraph to fetch deployed vaults for users + morphoRegistry: Address; // the RegistryList contract deployed by morpho! + marketV1AdapterFactory: Address; // MorphoMarketV1AdapterFactory contract used to create adapters for markets + adapterSubgraphEndpoint?: string; strategies?: AgentMetadata[]; }; @@ -97,7 +100,10 @@ export const networks: NetworkConfig[] = [ vaultConfig: { v2FactoryAddress: '0x4501125508079A99ebBebCE205DeC9593C2b5857', strategies: v2AgentsBase, - subgraphEndpoint: "https://api.studio.thegraph.com/query/94369/morpho-v-2-vault-factory-base/version/latest" + vaultsSubgraphEndpoint: "https://api.studio.thegraph.com/query/94369/morpho-v-2-vault-factory-base/version/latest", + morphoRegistry: '0x5C2531Cbd2cf112Cf687da3Cd536708aDd7DB10a', + marketV1AdapterFactory: '0x133baC94306B99f6dAD85c381a5be851d8DD717c', + adapterSubgraphEndpoint: "https://api.studio.thegraph.com/query/94369/morpho-adapters/version/latest" }, blocktime: 2, maxBlockDelay: 5, @@ -180,7 +186,7 @@ export const isAgentAvailable = (chainId: number): boolean => { const network = getNetworkConfig(chainId); if (!network || !network.vaultConfig) return false - return network.vaultConfig.subgraphEndpoint !== undefined + return network.vaultConfig.vaultsSubgraphEndpoint !== undefined }; export const getAgentConfig = (chainId: SupportedNetworks): VaultAgentConfig | undefined => { From de43ca6f4b70f02b4bbd0025131924448b1d3b63 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 7 Oct 2025 18:14:04 +0800 Subject: [PATCH 03/29] feat: init function --- AGENTS.md | 2 +- .../components/VaultInitializationModal.tsx | 86 +++++++++--- app/autovault/[vaultAddress]/content.tsx | 6 - src/hooks/useVaultV2.ts | 130 +++++++++++++++++- 4 files changed, 195 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 06e749af..4a0811d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ Next.js routes live in `app/`. Shared logic sits in `src/` with UI in `src/compo Run `pnpm format` to apply the Prettier profile (100-char width, 2-space indent, single quotes, trailing commas, Tailwind-aware ordering). ESLint (Airbnb + Next.js) enforces hook safety and import hygiene; Stylelint keeps CSS utilities consistent. Use PascalCase for React components (`VaultBanner.tsx`), camelCase for helpers (`formatApr`), and SCREAMING_SNAKE_CASE for shared constants. Keep Tailwind classlists purposeful and lean; consolidate patterns with `tailwind-merge` helpers when they repeat. ## Styling Discipline -Consult `docs/Styling.md` before touching UI. Always follow the documented design tokens, Tailwind composition patterns, and variant rules—no exceptions. Mirror the examples in that guide for component structure, prop naming, and class ordering so the design system stays coherent. +Consult `docs/Styling.md` before touching UI. Always follow the documented design tokens, Tailwind composition patterns, and variant rules—no exceptions. Mirror the examples in that guide for component structure, prop naming, and class ordering so the design system stays coherent. When using the shared `Spinner` component, pass numeric pixel values (e.g. `size={12}`)—it does not accept semantic strings. ## Implementation Mindset Default to the simplest viable implementation first. Reach for straightforward data flows, avoid premature abstractions, and only layer on complexity when the trivial approach no longer meets requirements. diff --git a/app/autovault/[vaultAddress]/components/VaultInitializationModal.tsx b/app/autovault/[vaultAddress]/components/VaultInitializationModal.tsx index 84fd3af8..dfe9dff1 100644 --- a/app/autovault/[vaultAddress]/components/VaultInitializationModal.tsx +++ b/app/autovault/[vaultAddress]/components/VaultInitializationModal.tsx @@ -6,9 +6,9 @@ import { Spinner } from '@/components/common/Spinner'; import { AddressDisplay } from '@/components/common/AddressDisplay'; import { useDeployMorphoMarketV1Adapter } from '@/hooks/useDeployMorphoMarketV1Adapter'; import { useMorphoMarketV1Adapters } from '@/hooks/useMorphoMarketV1Adapters'; -import { useVaultV2 } from '@/hooks/useVaultV2'; import { SupportedNetworks, getNetworkConfig } from '@/utils/networks'; import { getMorphoAddress } from '@/utils/morpho'; +import { useVaultV2 } from '@/hooks/useVaultV2'; const ZERO_ADDRESS = zeroAddress; const shortenAddress = (value: Address | string) => @@ -66,21 +66,25 @@ function DeployAdapterStep({ } function FinalizeSetupStep({ - chainId, adapter, + registryAddress, + isFinalizing, }: { - chainId: SupportedNetworks; adapter: Address; + registryAddress: Address; + isFinalizing: boolean; }) { - const registryAddress = getNetworkConfig(chainId).vaultConfig?.morphoRegistry ?? ZERO_ADDRESS; const adapterIsReady = adapter !== ZERO_ADDRESS; return (
-

- Finalize setup to link the vault to the adapter and commit to the Morpho registry. This permanently - opts the vault into Morpho-approved adapters. -

+
+ {isFinalizing && } + + Finalize setup to link the vault to the adapter and commit to the Morpho registry. This permanently + opts the vault into Morpho-approved adapters. + +
Adapter @@ -117,18 +121,29 @@ export function VaultInitializationModal({ chainId: SupportedNetworks; onAdapterConfigured: () => void; }) { + const [stepIndex, setStepIndex] = useState(0); + const [statusVisible, setStatusVisible] = useState(false); const currentStep = STEP_SEQUENCE[stepIndex]; const morphoAddress = useMemo(() => getMorphoAddress(chainId), [chainId]); + const registryAddress = useMemo(() => { + const configured = getNetworkConfig(chainId).vaultConfig?.morphoRegistry; + return (configured as Address | undefined) ?? ZERO_ADDRESS; + }, [chainId]); const { adapters, loading: adaptersLoading, refetch: refetchAdapters, } = useMorphoMarketV1Adapters({ vaultAddress, chainId }); - const subgraphAdapter = adapters[0]?.adapter ?? ZERO_ADDRESS; + const subgraphAdapter = (adapters[0]?.adapter as Address | undefined) ?? ZERO_ADDRESS; - const { adapter: onChainAdapter, refetch: refetchVault } = useVaultV2({ + const { + adapter: onChainAdapter, + refetch: refetchVault, + finalizeSetup, + isFinalizing, + } = useVaultV2({ vaultAddress, chainId, }); @@ -147,12 +162,6 @@ export function VaultInitializationModal({ morphoAddress, }); - const [statusVisible, setStatusVisible] = useState(false); - - const handleAdapterDetected = useCallback(async () => { - await refetchVault(); - onAdapterConfigured(); - }, [onAdapterConfigured, refetchVault]); const handleDeploy = useCallback(async () => { setStatusVisible(true); @@ -160,6 +169,28 @@ export function VaultInitializationModal({ await refetchAdapters(); }, [deploy, refetchAdapters]); + const handleAdapterDetected = useCallback(async () => { + await refetchVault(); + onAdapterConfigured(); + }, [onAdapterConfigured, refetchVault]); + + const handleFinalize = useCallback(async () => { + if (unifiedAdapter === ZERO_ADDRESS || registryAddress === ZERO_ADDRESS) return; + + try { + const success = await finalizeSetup(registryAddress, unifiedAdapter); + if (!success) { + return; + } + + await refetchVault(); + onAdapterConfigured(); + onClose(); + } catch (error) { + console.error('Failed to finalize setup', error); + } + }, [finalizeSetup, onAdapterConfigured, onClose, refetchVault, registryAddress, unifiedAdapter]); + useEffect(() => { if (!isOpen) { setStepIndex(0); @@ -185,6 +216,7 @@ export function VaultInitializationModal({ } }, [currentStep]); + const canFinalize = adapterDetected && registryAddress !== ZERO_ADDRESS; const showLoading = statusVisible && (isDeploying || adaptersLoading); const showBackButton = stepIndex > 0; const renderCta = () => { @@ -209,8 +241,20 @@ export function VaultInitializationModal({ } return ( - ); }; @@ -243,7 +287,11 @@ export function VaultInitializationModal({ /> )} {currentStep === 'finalize' && ( - + )} diff --git a/app/autovault/[vaultAddress]/content.tsx b/app/autovault/[vaultAddress]/content.tsx index 3f5189fe..c3e0fdfa 100644 --- a/app/autovault/[vaultAddress]/content.tsx +++ b/app/autovault/[vaultAddress]/content.tsx @@ -235,12 +235,6 @@ export default function VaultContent() { )}
- {isPlaceholder && ( -
- No on-chain telemetry yet—this layout shows where we will surface performance, allocations, and role status once data is wired in. -
- )} - {needsSetup && networkConfig?.vaultConfig?.marketV1AdapterFactory && ( diff --git a/src/hooks/useVaultV2.ts b/src/hooks/useVaultV2.ts index b2a4d5b2..ae46472d 100644 --- a/src/hooks/useVaultV2.ts +++ b/src/hooks/useVaultV2.ts @@ -1,8 +1,9 @@ -import { useMemo } from 'react'; -import { Address, zeroAddress } from 'viem'; -import { useChainId, useReadContract } from 'wagmi'; +import { useCallback, useMemo } from 'react'; +import { Address, encodeFunctionData, toFunctionSelector, zeroAddress } from 'viem'; +import { useAccount, useChainId, useReadContract } from 'wagmi'; import { vaultv2Abi } from '@/abis/vaultv2'; import { SupportedNetworks } from '@/utils/networks'; +import { useTransactionWithToast } from './useTransactionWithToast'; const ADAPTER_INDEX = 0n; @@ -15,6 +16,7 @@ export function useVaultV2({ }) { const connectedChainId = useChainId(); const chainIdToUse = (chainId ?? connectedChainId) as SupportedNetworks; + const { address: account } = useAccount(); const { data, @@ -33,6 +35,126 @@ export function useVaultV2({ }, }); + const { data: curator } = useReadContract({ + address: vaultAddress, + abi: vaultv2Abi, + functionName: 'curator', + args: [], + chainId: chainIdToUse, + query: { + enabled: Boolean(vaultAddress), + }, + }); + + const currentCurator = useMemo(() => (curator as Address | undefined) ?? zeroAddress, [curator]); + + const handleFinalizeSuccess = useCallback(() => { + void refetch(); + }, [refetch]); + + const { isConfirming: isFinalizing, sendTransactionAsync } = useTransactionWithToast({ + toastId: 'finalizeSetup', + pendingText: 'Finalizing setup', + successText: 'Setup finalized', + errorText: 'Failed to finalize setup', + pendingDescription: 'Finalizing setup', + successDescription: 'Setup finalized', + chainId: chainIdToUse, + onSuccess: handleFinalizeSuccess, + }); + + + // All morpho v2 vault operations have to be proposed first, and then execute + const finalizeSetup = useCallback( + async (morphoRegistry: Address, marketV1Adapter: Address): Promise => { + if (!account || !vaultAddress || marketV1Adapter === zeroAddress) return false; + + const txs: `0x${string}`[] = []; + + // Step 1. Assign curator if unset. + if (currentCurator === zeroAddress) { + const setCuratorTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'setCurator', + args: [account], + }); + txs.push(setCuratorTx); + } + + // Step 2. Commit to Morpho registry. + const setRegistryTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'setAdapterRegistry', + args: [morphoRegistry], + }); + + const submitSetRegistryTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'submit', + args: [setRegistryTx], + }); + + txs.push(submitSetRegistryTx, setRegistryTx); + + // Step 3. Register the deployed adapter. + const addAdapterTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'addAdapter', + args: [marketV1Adapter], + }); + + const submitAddAdapterTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'submit', + args: [addAdapterTx], + }); + + txs.push(submitAddAdapterTx, addAdapterTx); + + // Step 4. Abdicate registry control. + const setAdapterRegistrySelector = toFunctionSelector('setAdapterRegistry(address)'); + + const abdicateSetAdapterRegistryTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'abdicate', + args: [setAdapterRegistrySelector], + }); + + const submitAbdicateSetAdapterRegistryTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'submit', + args: [abdicateSetAdapterRegistryTx], + }); + + txs.push(submitAbdicateSetAdapterRegistryTx, abdicateSetAdapterRegistryTx); + + // Step 5. Execute multicall with all steps. + const multicallTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'multicall', + args: [txs], + }); + + try { + await sendTransactionAsync({ + account, + to: vaultAddress, + data: multicallTx, + chainId: chainIdToUse, + }); + return true; + } catch (error) { + if (error instanceof Error && error.message.toLowerCase().includes('reject')) { + // user rejected the transaction; treat as graceful cancellation + return false; + } + console.error('Failed to finalize vault setup', error); + throw error; + } + }, + [account, chainIdToUse, currentCurator, sendTransactionAsync, vaultAddress], + ); + const adapter = useMemo(() => { if (!data) return zeroAddress; return data as Address; @@ -46,5 +168,7 @@ export function useVaultV2({ isLoading: isLoading || isFetching, refetch, error: error as Error | null, + finalizeSetup, + isFinalizing, }; } From 00a41f06502a15d56d26a7187736e28b4970f4d5 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 7 Oct 2025 18:27:53 +0800 Subject: [PATCH 04/29] chore: move folder structure to include chain Id --- .../components/VaultAgentSummary.tsx | 0 .../components/VaultApyHistory.tsx | 0 .../components/VaultAssetMovements.tsx | 0 .../components/VaultInitializationModal.tsx | 0 .../components/VaultMarketAllocations.tsx | 0 .../components/VaultRolesModal.tsx | 0 .../components/VaultRolesOverview.tsx | 0 .../components/VaultSettings.tsx | 0 .../components/VaultSummaryMetrics.tsx | 0 .../[vaultAddress]/content.tsx | 14 +++++++----- .../[chainId]/[vaultAddress]/page.tsx | 22 +++++++++++++++++++ app/autovault/[vaultAddress]/page.tsx | 14 ------------ app/autovault/components/VaultListV2.tsx | 4 ++-- 13 files changed, 32 insertions(+), 22 deletions(-) rename app/autovault/{ => [chainId]}/[vaultAddress]/components/VaultAgentSummary.tsx (100%) rename app/autovault/{ => [chainId]}/[vaultAddress]/components/VaultApyHistory.tsx (100%) rename app/autovault/{ => [chainId]}/[vaultAddress]/components/VaultAssetMovements.tsx (100%) rename app/autovault/{ => [chainId]}/[vaultAddress]/components/VaultInitializationModal.tsx (100%) rename app/autovault/{ => [chainId]}/[vaultAddress]/components/VaultMarketAllocations.tsx (100%) rename app/autovault/{ => [chainId]}/[vaultAddress]/components/VaultRolesModal.tsx (100%) rename app/autovault/{ => [chainId]}/[vaultAddress]/components/VaultRolesOverview.tsx (100%) rename app/autovault/{ => [chainId]}/[vaultAddress]/components/VaultSettings.tsx (100%) rename app/autovault/{ => [chainId]}/[vaultAddress]/components/VaultSummaryMetrics.tsx (100%) rename app/autovault/{ => [chainId]}/[vaultAddress]/content.tsx (96%) create mode 100644 app/autovault/[chainId]/[vaultAddress]/page.tsx delete mode 100644 app/autovault/[vaultAddress]/page.tsx diff --git a/app/autovault/[vaultAddress]/components/VaultAgentSummary.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultAgentSummary.tsx similarity index 100% rename from app/autovault/[vaultAddress]/components/VaultAgentSummary.tsx rename to app/autovault/[chainId]/[vaultAddress]/components/VaultAgentSummary.tsx diff --git a/app/autovault/[vaultAddress]/components/VaultApyHistory.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultApyHistory.tsx similarity index 100% rename from app/autovault/[vaultAddress]/components/VaultApyHistory.tsx rename to app/autovault/[chainId]/[vaultAddress]/components/VaultApyHistory.tsx diff --git a/app/autovault/[vaultAddress]/components/VaultAssetMovements.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultAssetMovements.tsx similarity index 100% rename from app/autovault/[vaultAddress]/components/VaultAssetMovements.tsx rename to app/autovault/[chainId]/[vaultAddress]/components/VaultAssetMovements.tsx diff --git a/app/autovault/[vaultAddress]/components/VaultInitializationModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultInitializationModal.tsx similarity index 100% rename from app/autovault/[vaultAddress]/components/VaultInitializationModal.tsx rename to app/autovault/[chainId]/[vaultAddress]/components/VaultInitializationModal.tsx diff --git a/app/autovault/[vaultAddress]/components/VaultMarketAllocations.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx similarity index 100% rename from app/autovault/[vaultAddress]/components/VaultMarketAllocations.tsx rename to app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx diff --git a/app/autovault/[vaultAddress]/components/VaultRolesModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultRolesModal.tsx similarity index 100% rename from app/autovault/[vaultAddress]/components/VaultRolesModal.tsx rename to app/autovault/[chainId]/[vaultAddress]/components/VaultRolesModal.tsx diff --git a/app/autovault/[vaultAddress]/components/VaultRolesOverview.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultRolesOverview.tsx similarity index 100% rename from app/autovault/[vaultAddress]/components/VaultRolesOverview.tsx rename to app/autovault/[chainId]/[vaultAddress]/components/VaultRolesOverview.tsx diff --git a/app/autovault/[vaultAddress]/components/VaultSettings.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettings.tsx similarity index 100% rename from app/autovault/[vaultAddress]/components/VaultSettings.tsx rename to app/autovault/[chainId]/[vaultAddress]/components/VaultSettings.tsx diff --git a/app/autovault/[vaultAddress]/components/VaultSummaryMetrics.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultSummaryMetrics.tsx similarity index 100% rename from app/autovault/[vaultAddress]/components/VaultSummaryMetrics.tsx rename to app/autovault/[chainId]/[vaultAddress]/components/VaultSummaryMetrics.tsx diff --git a/app/autovault/[vaultAddress]/content.tsx b/app/autovault/[chainId]/[vaultAddress]/content.tsx similarity index 96% rename from app/autovault/[vaultAddress]/content.tsx rename to app/autovault/[chainId]/[vaultAddress]/content.tsx index c3e0fdfa..0348fa80 100644 --- a/app/autovault/[vaultAddress]/content.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/content.tsx @@ -6,7 +6,7 @@ import { GearIcon } from '@radix-ui/react-icons'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import { Address } from 'viem'; -import { useAccount, useChainId } from 'wagmi'; +import { useAccount } from 'wagmi'; import { Button } from '@/components/common'; import { AddressDisplay } from '@/components/common/AddressDisplay'; import Header from '@/components/layout/header/Header'; @@ -35,14 +35,16 @@ function formatUsd(value: number | bigint): string { } export default function VaultContent() { - const { vaultAddress } = useParams<{ vaultAddress: string }>(); + const { chainId: chainIdParam, vaultAddress } = useParams<{ chainId: string; vaultAddress: string }>(); const vaultAddressValue = vaultAddress as Address; const { address } = useAccount(); - const chainId = useChainId(); const supportedChainId = useMemo(() => { - const maybe = chainId as SupportedNetworks; - return ALL_SUPPORTED_NETWORKS.includes(maybe) ? maybe : SupportedNetworks.Base; - }, [chainId]); + const parsed = Number(chainIdParam); + if (Number.isFinite(parsed) && ALL_SUPPORTED_NETWORKS.includes(parsed as SupportedNetworks)) { + return parsed as SupportedNetworks; + } + return SupportedNetworks.Base; + }, [chainIdParam]); const networkConfig = useMemo(() => { try { diff --git a/app/autovault/[chainId]/[vaultAddress]/page.tsx b/app/autovault/[chainId]/[vaultAddress]/page.tsx new file mode 100644 index 00000000..0a91b05b --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/page.tsx @@ -0,0 +1,22 @@ +import { generateMetadata as buildMetadata } from '@/utils/generateMetadata'; + +import VaultContent from './content'; + +export async function generateMetadata({ + params, +}: { + params: { chainId: string; vaultAddress: string }; +}) { + const { chainId, vaultAddress } = params; + + return buildMetadata({ + title: 'Vault Details | Monarch', + description: 'Detailed information about a specific autovault', + images: 'themes.png', + pathname: `/autovault/${chainId}/${vaultAddress}`, + }); +} + +export default function VaultPage() { + return ; +} diff --git a/app/autovault/[vaultAddress]/page.tsx b/app/autovault/[vaultAddress]/page.tsx deleted file mode 100644 index bc1ccc3a..00000000 --- a/app/autovault/[vaultAddress]/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { generateMetadata } from '@/utils/generateMetadata'; - -import VaultContent from './content'; - -export const metadata = generateMetadata({ - title: 'Vault Details | Monarch', - description: 'Detailed information about a specific autovault', - images: 'themes.png', - pathname: '', -}); - -export default function VaultPage() { - return ; -} diff --git a/app/autovault/components/VaultListV2.tsx b/app/autovault/components/VaultListV2.tsx index 956d1930..1b084603 100644 --- a/app/autovault/components/VaultListV2.tsx +++ b/app/autovault/components/VaultListV2.tsx @@ -7,7 +7,7 @@ import { useTokens } from '@/components/providers/TokenProvider'; import { TokenIcon } from '@/components/TokenIcon'; import { UserVaultV2 } from '@/data-sources/subgraph/v2-vaults'; import { formatReadable } from '@/utils/balance'; -import { getNetworkImg } from '@/utils/networks'; +import { SupportedNetworks, getNetworkImg } from '@/utils/networks'; type VaultListV2Props = { vaults: UserVaultV2[]; @@ -109,7 +109,7 @@ export function VaultListV2({ vaults, loading }: VaultListV2Props) { {/* Action */}
- + From d8e0041f594e1033cc8870a6000a1d97864e3a21 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 7 Oct 2025 20:13:23 +0800 Subject: [PATCH 05/29] feat: setting modal --- .../components/VaultAgentSummary.tsx | 9 +- .../components/VaultSettings.tsx | 43 --- .../components/VaultSettingsModal.tsx | 311 ++++++++++++++++++ .../[chainId]/[vaultAddress]/content.tsx | 67 +++- src/hooks/useAutovaultData.ts | 1 + src/hooks/useVaultV2.ts | 113 ++++++- 6 files changed, 481 insertions(+), 63 deletions(-) delete mode 100644 app/autovault/[chainId]/[vaultAddress]/components/VaultSettings.tsx create mode 100644 app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultAgentSummary.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultAgentSummary.tsx index d69c197b..c213a18d 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultAgentSummary.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultAgentSummary.tsx @@ -9,6 +9,7 @@ type VaultAgentSummaryProps = { activeAgents: number; description: string; onManageAgents: () => void; + onManageAllocations?: () => void; onViewRoles: () => void; roleStatusText: string; }; @@ -18,6 +19,7 @@ export function VaultAgentSummary({ activeAgents, description, onManageAgents, + onManageAllocations, onViewRoles, roleStatusText, }: VaultAgentSummaryProps) { @@ -57,10 +59,15 @@ export function VaultAgentSummary({

{roleStatusText}

-
+
+ {onManageAllocations && ( + + )} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettings.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettings.tsx deleted file mode 100644 index 4e61134d..00000000 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettings.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Button } from '@/components/common/Button'; -import { AutovaultData } from '@/hooks/useAutovaultData'; - -type VaultSettingsProps = { - vault: AutovaultData; - onClose: () => void; -}; - -export function VaultSettings({ onClose, vault }: VaultSettingsProps) { - const allocatorCount = vault.agents.length; - - return ( -
-
-

Vault Controls

-

Assign automation roles and manage the optimization agent.

-
- -
-

Optimization agent

-

- Authorize the allocator address that executes deposits and withdrawals between enabled adapters. -

-
- - {allocatorCount === 0 - ? 'No allocator assigned' - : `${allocatorCount} allocator${allocatorCount > 1 ? 's' : ''} authorized`} - - -
-
- -
- -
-
- ); -} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx new file mode 100644 index 00000000..e67fa9f0 --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx @@ -0,0 +1,311 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Input } from '@heroui/react'; +import { LuX } from 'react-icons/lu'; +import { Button } from '@/components/common/Button'; +import { Spinner } from '@/components/common/Spinner'; +import { AutovaultData } from '@/hooks/useAutovaultData'; + +type SettingsTab = 'general' | 'agents' | 'allocations'; + +const TABS: { id: SettingsTab; label: string }[] = [ + { id: 'general', label: 'General' }, + { id: 'agents', label: 'Agent' }, + { id: 'allocations', label: 'Allocation' }, +]; + +type VaultSettingsModalProps = { + isOpen: boolean; + onClose: () => void; + initialTab?: SettingsTab; + vault: AutovaultData; + isOwner: boolean; + onUpdateMetadata: (values: { name?: string; symbol?: string }) => Promise; + updatingMetadata: boolean; + defaultName: string; + defaultSymbol: string; + currentName: string; + currentSymbol: string; +}; + +export function VaultSettingsModal({ + isOpen, + onClose, + initialTab = 'general', + vault, + isOwner, + onUpdateMetadata, + updatingMetadata, + defaultName, + defaultSymbol, + currentName, + currentSymbol, +}: VaultSettingsModalProps) { + const [activeTab, setActiveTab] = useState(initialTab); + const previousName = useMemo(() => currentName.trim(), [currentName]); + const previousSymbol = useMemo(() => currentSymbol.trim(), [currentSymbol]); + const [nameInput, setNameInput] = useState(previousName || defaultName); + const [symbolInput, setSymbolInput] = useState(previousSymbol || defaultSymbol); + const [metadataError, setMetadataError] = useState(null); + + const wasOpenRef = useRef(false); + + useEffect(() => { + const wasOpen = wasOpenRef.current; + + if (isOpen && !wasOpen) { + setActiveTab(initialTab); + } + + if (!isOpen && wasOpen) { + setMetadataError(null); + setNameInput(previousName || defaultName); + setSymbolInput(previousSymbol || defaultSymbol); + } + + wasOpenRef.current = isOpen; + }, [defaultName, defaultSymbol, initialTab, isOpen, previousName, previousSymbol]); + + const handleTabChange = useCallback((tab: SettingsTab) => { + setActiveTab(tab); + }, []); + + const trimmedName = nameInput.trim(); + const trimmedSymbol = symbolInput.trim(); + const metadataChanged = useMemo(() => { + const hasNewName = trimmedName !== previousName; + const hasNewSymbol = trimmedSymbol !== previousSymbol; + return hasNewName || hasNewSymbol; + }, [previousName, previousSymbol, trimmedName, trimmedSymbol]); + + const allocatorCount = vault.agents.length; + + useEffect(() => { + if (metadataError && metadataChanged) { + setMetadataError(null); + } + }, [metadataChanged, metadataError]); + + const handleMetadataSubmit = useCallback(async () => { + if (!metadataChanged) { + setMetadataError('No changes detected.'); + return; + } + + setMetadataError(null); + + const success = await onUpdateMetadata({ + name: trimmedName !== previousName ? trimmedName || undefined : undefined, + symbol: trimmedSymbol !== previousSymbol ? trimmedSymbol || undefined : undefined, + }); + + if (success) { + setMetadataError(null); + } + }, [metadataChanged, onUpdateMetadata, previousName, previousSymbol, trimmedName, trimmedSymbol]); + + const renderGeneralTab = () => ( +
+
+
+ + setNameInput(event.target.value)} + placeholder={defaultName} + isDisabled={!isOwner} + classNames={{ + input: 'text-sm', + inputWrapper: + 'bg-hovered/60 border-transparent shadow-none focus-within:border-transparent focus-within:bg-hovered/80', + }} + /> +
+
+ + setSymbolInput(event.target.value)} + placeholder={defaultSymbol} + maxLength={16} + isDisabled={!isOwner} + classNames={{ + input: 'text-sm', + inputWrapper: + 'bg-hovered/60 border-transparent shadow-none focus-within:border-transparent focus-within:bg-hovered/80', + }} + /> +
+ + {metadataError &&

{metadataError}

} + + +
+
+ ); + + const renderAgentTab = () => ( +
+
+

Automation agent

+

+ Authorize the allocator address that executes deposits and withdrawals between enabled adapters. +

+
+
+
+ + {allocatorCount === 0 + ? 'No allocator assigned yet' + : `${allocatorCount} allocator${allocatorCount > 1 ? 's' : ''} authorized`} + + +
+

+ Allocators handle on-chain execution based on the curator’s guardrails. Add your automation agent or desk wallet + here so it can rebalance adapters. +

+
+
+ ); + + const renderAllocationsTab = () => ( +
+
+

Allocation caps

+

Configure market-level caps and guardrails for the automation agent.

+
+
+ Allocation management coming soon. You’ll be able to set per-market caps and minimum cash buffers here. +
+
+ ); + + const renderActiveTab = () => { + switch (activeTab) { + case 'general': + return renderGeneralTab(); + case 'agents': + return renderAgentTab(); + case 'allocations': + return renderAllocationsTab(); + default: + return null; + } + }; + + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (isOpen) { + setNameInput(previousName || defaultName); + setSymbolInput(previousSymbol || defaultSymbol); + } + }, [defaultName, defaultSymbol, isOpen, previousName, previousSymbol]); + + useEffect(() => { + if (!isOpen) return; + const originalOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = originalOverflow; + }; + }, [isOpen]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && isOpen) { + onClose(); + } + }; + + if (isOpen) { + window.addEventListener('keydown', handleKeyDown); + } + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen, onClose]); + + if (!mounted || !isOpen) { + return null; + } + + return createPortal( +
+
event.stopPropagation()} + > +
+
+

Vault Settings

+ +
+ +
+ + +
+
{renderActiveTab()}
+
+
+
+
+
, + document.body, + ); +} diff --git a/app/autovault/[chainId]/[vaultAddress]/content.tsx b/app/autovault/[chainId]/[vaultAddress]/content.tsx index 0348fa80..640b910d 100644 --- a/app/autovault/[chainId]/[vaultAddress]/content.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/content.tsx @@ -1,7 +1,6 @@ 'use client'; import { useMemo, useState } from 'react'; -import { Card } from '@heroui/react'; import { GearIcon } from '@radix-ui/react-icons'; import Link from 'next/link'; import { useParams } from 'next/navigation'; @@ -16,7 +15,7 @@ import { VaultApyHistory } from './components/VaultApyHistory'; import { VaultAssetMovements, VaultAssetMovement } from './components/VaultAssetMovements'; import { VaultMarketAllocations } from './components/VaultMarketAllocations'; import { VaultRole } from './components/VaultRolesOverview'; -import { VaultSettings } from './components/VaultSettings'; +import { VaultSettingsModal } from './components/VaultSettingsModal'; import { VaultSummaryMetrics, VaultMetric } from './components/VaultSummaryMetrics'; import { VaultAgentSummary } from './components/VaultAgentSummary'; import { VaultRolesModal } from './components/VaultRolesModal'; @@ -54,11 +53,21 @@ export default function VaultContent() { } }, [supportedChainId]); - const { adapter, needsSetup, isLoading: adapterLoading, refetch: refetchAdapter } = useVaultV2({ + const { + adapter, + needsSetup, + isLoading: adapterLoading, + refetch: refetchAdapter, + updateNameAndSymbol, + isUpdatingMetadata, + name: onChainName, + symbol: onChainSymbol, + } = useVaultV2({ vaultAddress: vaultAddressValue, chainId: supportedChainId, }); + const [settingsTab, setSettingsTab] = useState<'general' | 'agents' | 'allocations'>('general'); const [showSettings, setShowSettings] = useState(false); const [showRolesModal, setShowRolesModal] = useState(false); const [showInitializationModal, setShowInitializationModal] = useState(false); @@ -102,7 +111,8 @@ export default function VaultContent() { const placeholderVault: AutovaultData = { id: 'placeholder', address: vaultAddress as Address, - name: 'Autovault strategy overview', + name: 'Monarch Auto Vault', + symbol: 'mAUTO', description: 'Track how your automation is performing and what still needs configuration.', totalValue: BigInt(2_100_000_000), currentApy: 7.4, @@ -161,6 +171,10 @@ export default function VaultContent() { const marketAllocations: VaultAllocation[] = displayVault.allocations ?? []; const vaultAssetSymbol = marketAllocations[0]?.assetSymbol ?? '—'; + const fallbackSymbol = vaultAssetSymbol !== '—' ? `m${vaultAssetSymbol}` : 'mAUTO'; + const fallbackName = `Monarch Auto ${vaultAssetSymbol !== '—' ? vaultAssetSymbol : 'Vault'}`; + const effectiveName = (onChainName?.trim() || displayVault.name || fallbackName).trim(); + const effectiveSymbol = (onChainSymbol?.trim() || displayVault.symbol || fallbackSymbol).trim(); const assetMovements: VaultAssetMovement[] = isPlaceholder ? [] @@ -220,7 +234,10 @@ export default function VaultContent() {
-

{displayVault.name}

+
+

{effectiveName}

+ {effectiveSymbol} +

{displayVault.description}

Automation service overview • vault analytics
@@ -228,7 +245,10 @@ export default function VaultContent() {
diff --git a/src/hooks/useAutovaultData.ts b/src/hooks/useAutovaultData.ts index f33eebe0..0acd62a2 100644 --- a/src/hooks/useAutovaultData.ts +++ b/src/hooks/useAutovaultData.ts @@ -33,6 +33,7 @@ export type AutovaultData = { id: string; address: Address; name: string; + symbol?: string; description: string; totalValue: bigint; currentApy: number; diff --git a/src/hooks/useVaultV2.ts b/src/hooks/useVaultV2.ts index ae46472d..c49f26f6 100644 --- a/src/hooks/useVaultV2.ts +++ b/src/hooks/useVaultV2.ts @@ -46,13 +46,35 @@ export function useVaultV2({ }, }); + const { data: rawName } = useReadContract({ + address: vaultAddress, + abi: vaultv2Abi, + functionName: 'name', + args: [], + chainId: chainIdToUse, + query: { + enabled: Boolean(vaultAddress), + }, + }); + + const { data: rawSymbol } = useReadContract({ + address: vaultAddress, + abi: vaultv2Abi, + functionName: 'symbol', + args: [], + chainId: chainIdToUse, + query: { + enabled: Boolean(vaultAddress), + }, + }); + const currentCurator = useMemo(() => (curator as Address | undefined) ?? zeroAddress, [curator]); const handleFinalizeSuccess = useCallback(() => { void refetch(); }, [refetch]); - const { isConfirming: isFinalizing, sendTransactionAsync } = useTransactionWithToast({ + const { isConfirming: isFinalizing, sendTransactionAsync: sendFinalizeTx } = useTransactionWithToast({ toastId: 'finalizeSetup', pendingText: 'Finalizing setup', successText: 'Setup finalized', @@ -63,6 +85,16 @@ export function useVaultV2({ onSuccess: handleFinalizeSuccess, }); + const { isConfirming: isUpdatingMetadata, sendTransactionAsync: sendMetadataTx } = useTransactionWithToast({ + toastId: 'update-vault-metadata', + pendingText: 'Updating vault metadata', + successText: 'Vault metadata updated', + errorText: 'Failed to update vault metadata', + pendingDescription: 'Applying new name and symbol', + successDescription: 'Vault metadata saved', + chainId: chainIdToUse, + }); + // All morpho v2 vault operations have to be proposed first, and then execute const finalizeSetup = useCallback( @@ -136,7 +168,7 @@ export function useVaultV2({ }); try { - await sendTransactionAsync({ + await sendFinalizeTx({ account, to: vaultAddress, data: multicallTx, @@ -152,7 +184,68 @@ export function useVaultV2({ throw error; } }, - [account, chainIdToUse, currentCurator, sendTransactionAsync, vaultAddress], + [account, chainIdToUse, currentCurator, sendFinalizeTx, vaultAddress], + ); + + const updateNameAndSymbol = useCallback( + async ({ name, symbol }: { name?: string; symbol?: string }): Promise => { + if (!account || !vaultAddress) return false; + + const nextName = name?.trim(); + const nextSymbol = symbol?.trim(); + + const calls: `0x${string}`[] = []; + + if (nextName) { + calls.push( + encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'setName', + args: [nextName], + }), + ); + } + + if (nextSymbol) { + calls.push( + encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'setSymbol', + args: [nextSymbol], + }), + ); + } + + if (calls.length === 0) { + return false; + } + + const txData = + calls.length === 1 + ? calls[0] + : encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'multicall', + args: [calls], + }); + + try { + await sendMetadataTx({ + account, + to: vaultAddress, + data: txData, + chainId: chainIdToUse, + }); + return true; + } catch (error) { + if (error instanceof Error && error.message.toLowerCase().includes('reject')) { + return false; + } + console.error('Failed to update vault metadata', error); + throw error; + } + }, + [account, chainIdToUse, sendMetadataTx, vaultAddress], ); const adapter = useMemo(() => { @@ -160,6 +253,16 @@ export function useVaultV2({ return data as Address; }, [data]); + const name = useMemo(() => { + if (!rawName) return ''; + return String(rawName); + }, [rawName]); + + const symbol = useMemo(() => { + if (!rawSymbol) return ''; + return String(rawSymbol); + }, [rawSymbol]); + const needsSetup = adapter === zeroAddress; return { @@ -170,5 +273,9 @@ export function useVaultV2({ error: error as Error | null, finalizeSetup, isFinalizing, + name, + symbol, + updateNameAndSymbol, + isUpdatingMetadata, }; } From a2985a9355721398f7d91a056db3ffd2c8f5f2d3 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 7 Oct 2025 20:16:41 +0800 Subject: [PATCH 06/29] chore: fix lint --- .../components/VaultAgentSummary.tsx | 2 +- .../components/VaultInitializationModal.tsx | 8 +++---- .../components/VaultMarketAllocations.tsx | 2 +- .../components/VaultSettingsModal.tsx | 20 ++++++++++++----- .../[chainId]/[vaultAddress]/content.tsx | 11 +++++----- .../subgraph/morpho-market-v1-adapters.ts | 2 +- src/hooks/useDeployMorphoMarketV1Adapter.ts | 4 ++-- src/hooks/useVaultV2.ts | 22 ++++++++++++------- 8 files changed, 42 insertions(+), 29 deletions(-) diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultAgentSummary.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultAgentSummary.tsx index c213a18d..dbb3b205 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultAgentSummary.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultAgentSummary.tsx @@ -1,7 +1,7 @@ -import { Button } from '@/components/common'; import { Tooltip } from '@heroui/react'; import clsx from 'clsx'; import { GrStatusGood } from 'react-icons/gr'; +import { Button } from '@/components/common'; import { TooltipContent } from '@/components/TooltipContent'; type VaultAgentSummaryProps = { diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultInitializationModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultInitializationModal.tsx index dfe9dff1..d1a629bf 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultInitializationModal.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultInitializationModal.tsx @@ -1,14 +1,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Address, zeroAddress } from 'viem'; import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@heroui/react'; +import { Address, zeroAddress } from 'viem'; import { Button } from '@/components/common'; -import { Spinner } from '@/components/common/Spinner'; import { AddressDisplay } from '@/components/common/AddressDisplay'; +import { Spinner } from '@/components/common/Spinner'; import { useDeployMorphoMarketV1Adapter } from '@/hooks/useDeployMorphoMarketV1Adapter'; import { useMorphoMarketV1Adapters } from '@/hooks/useMorphoMarketV1Adapters'; -import { SupportedNetworks, getNetworkConfig } from '@/utils/networks'; -import { getMorphoAddress } from '@/utils/morpho'; import { useVaultV2 } from '@/hooks/useVaultV2'; +import { getMorphoAddress } from '@/utils/morpho'; +import { SupportedNetworks, getNetworkConfig } from '@/utils/networks'; const ZERO_ADDRESS = zeroAddress; const shortenAddress = (value: Address | string) => diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx index 351a4da4..91be655e 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx @@ -1,5 +1,5 @@ -import { TokenIcon } from '@/components/TokenIcon'; import OracleVendorBadge from '@/components/OracleVendorBadge'; +import { TokenIcon } from '@/components/TokenIcon'; import { VaultAllocation } from '@/hooks/useAutovaultData'; const formatPercent = (value: number | null | undefined) => diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx index e67fa9f0..48bc1fbf 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; +import { useCallback, useEffect, useMemo, useRef, useState, useId } from 'react'; import { Input } from '@heroui/react'; +import { createPortal } from 'react-dom'; import { LuX } from 'react-icons/lu'; import { Button } from '@/components/common/Button'; import { Spinner } from '@/components/common/Spinner'; @@ -42,6 +42,8 @@ export function VaultSettingsModal({ currentSymbol, }: VaultSettingsModalProps) { const [activeTab, setActiveTab] = useState(initialTab); + const nameInputId = useId(); + const symbolInputId = useId(); const previousName = useMemo(() => currentName.trim(), [currentName]); const previousSymbol = useMemo(() => currentSymbol.trim(), [currentSymbol]); const [nameInput, setNameInput] = useState(previousName || defaultName); @@ -108,7 +110,9 @@ export function VaultSettingsModal({
- + setNameInput(event.target.value)} placeholder={defaultName} isDisabled={!isOwner} + id={nameInputId} classNames={{ input: 'text-sm', inputWrapper: @@ -125,7 +130,9 @@ export function VaultSettingsModal({ />
- +
event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} >
diff --git a/app/autovault/[chainId]/[vaultAddress]/content.tsx b/app/autovault/[chainId]/[vaultAddress]/content.tsx index 640b910d..110eed02 100644 --- a/app/autovault/[chainId]/[vaultAddress]/content.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/content.tsx @@ -11,17 +11,17 @@ import { AddressDisplay } from '@/components/common/AddressDisplay'; import Header from '@/components/layout/header/Header'; import LoadingScreen from '@/components/Status/LoadingScreen'; import { AutovaultData, VaultAllocation, useVaultDetails } from '@/hooks/useAutovaultData'; +import { useVaultV2 } from '@/hooks/useVaultV2'; +import { ALL_SUPPORTED_NETWORKS, SupportedNetworks, getNetworkConfig } from '@/utils/networks'; +import { VaultAgentSummary } from './components/VaultAgentSummary'; import { VaultApyHistory } from './components/VaultApyHistory'; import { VaultAssetMovements, VaultAssetMovement } from './components/VaultAssetMovements'; +import { VaultInitializationModal } from './components/VaultInitializationModal'; import { VaultMarketAllocations } from './components/VaultMarketAllocations'; +import { VaultRolesModal } from './components/VaultRolesModal'; import { VaultRole } from './components/VaultRolesOverview'; import { VaultSettingsModal } from './components/VaultSettingsModal'; import { VaultSummaryMetrics, VaultMetric } from './components/VaultSummaryMetrics'; -import { VaultAgentSummary } from './components/VaultAgentSummary'; -import { VaultRolesModal } from './components/VaultRolesModal'; -import { useVaultV2 } from '@/hooks/useVaultV2'; -import { ALL_SUPPORTED_NETWORKS, SupportedNetworks, getNetworkConfig } from '@/utils/networks'; -import { VaultInitializationModal } from './components/VaultInitializationModal'; function formatUsd(value: number | bigint): string { const numeric = typeof value === 'bigint' ? Number(value) : value; @@ -54,7 +54,6 @@ export default function VaultContent() { }, [supportedChainId]); const { - adapter, needsSetup, isLoading: adapterLoading, refetch: refetchAdapter, diff --git a/src/data-sources/subgraph/morpho-market-v1-adapters.ts b/src/data-sources/subgraph/morpho-market-v1-adapters.ts index c88b99aa..6109ba09 100644 --- a/src/data-sources/subgraph/morpho-market-v1-adapters.ts +++ b/src/data-sources/subgraph/morpho-market-v1-adapters.ts @@ -1,6 +1,6 @@ import { Address } from 'viem'; -import { subgraphGraphqlFetcher } from './fetchers'; import { morphoMarketV1AdaptersQuery } from '@/graphql/morpho-market-v1-adapter-queries'; +import { subgraphGraphqlFetcher } from './fetchers'; type MorphoMarketV1AdaptersResponse = { data?: { diff --git a/src/hooks/useDeployMorphoMarketV1Adapter.ts b/src/hooks/useDeployMorphoMarketV1Adapter.ts index 29934acd..85f322fd 100644 --- a/src/hooks/useDeployMorphoMarketV1Adapter.ts +++ b/src/hooks/useDeployMorphoMarketV1Adapter.ts @@ -2,9 +2,9 @@ import { useCallback, useMemo } from 'react'; import { Address, encodeFunctionData, zeroAddress } from 'viem'; import { useAccount, useChainId } from 'wagmi'; import { adapterFactoryAbi } from '@/abis/morpho-market-v1-adapter-factory'; -import { useTransactionWithToast } from './useTransactionWithToast'; -import { getNetworkConfig, SupportedNetworks } from '@/utils/networks'; import { getMorphoAddress } from '@/utils/morpho'; +import { getNetworkConfig, SupportedNetworks } from '@/utils/networks'; +import { useTransactionWithToast } from './useTransactionWithToast'; const TX_TOAST_ID = 'deploy-morpho-market-adapter'; diff --git a/src/hooks/useVaultV2.ts b/src/hooks/useVaultV2.ts index c49f26f6..7db7b89b 100644 --- a/src/hooks/useVaultV2.ts +++ b/src/hooks/useVaultV2.ts @@ -175,13 +175,16 @@ export function useVaultV2({ chainId: chainIdToUse, }); return true; - } catch (error) { - if (error instanceof Error && error.message.toLowerCase().includes('reject')) { + } catch (finalizeError) { + if ( + finalizeError instanceof Error && + finalizeError.message.toLowerCase().includes('reject') + ) { // user rejected the transaction; treat as graceful cancellation return false; } - console.error('Failed to finalize vault setup', error); - throw error; + console.error('Failed to finalize vault setup', finalizeError); + throw finalizeError; } }, [account, chainIdToUse, currentCurator, sendFinalizeTx, vaultAddress], @@ -237,12 +240,15 @@ export function useVaultV2({ chainId: chainIdToUse, }); return true; - } catch (error) { - if (error instanceof Error && error.message.toLowerCase().includes('reject')) { + } catch (metadataUpdateError) { + if ( + metadataUpdateError instanceof Error && + metadataUpdateError.message.toLowerCase().includes('reject') + ) { return false; } - console.error('Failed to update vault metadata', error); - throw error; + console.error('Failed to update vault metadata', metadataUpdateError); + throw metadataUpdateError; } }, [account, chainIdToUse, sendMetadataTx, vaultAddress], From 929dd3ef39e983d83fab34deb2e9225d5db9f927 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 8 Oct 2025 08:32:11 +0800 Subject: [PATCH 07/29] chore: layout --- AGENTS.md | 3 + .../components/VaultAgentSummary.tsx | 5 - .../components/VaultApyHistory.tsx | 2 +- .../components/VaultAssetMovements.tsx | 13 +- .../components/VaultMarketAllocations.tsx | 4 +- .../components/VaultRolesModal.tsx | 32 -- .../components/VaultRolesOverview.tsx | 109 ------ .../components/VaultSettingsModal.tsx | 75 +++- .../components/VaultSummaryMetrics.tsx | 38 +- .../[chainId]/[vaultAddress]/content.tsx | 344 ++++++++---------- docs/Styling.md | 5 + src/components/common/AddressDisplay.tsx | 80 +++- src/data-sources/morpho-api/vaults.ts | 67 ++++ src/hooks/useAutovaultData.ts | 32 +- src/hooks/useVaultV2Data.ts | 223 ++++++++++++ 15 files changed, 611 insertions(+), 421 deletions(-) delete mode 100644 app/autovault/[chainId]/[vaultAddress]/components/VaultRolesModal.tsx delete mode 100644 app/autovault/[chainId]/[vaultAddress]/components/VaultRolesOverview.tsx create mode 100644 src/data-sources/morpho-api/vaults.ts create mode 100644 src/hooks/useVaultV2Data.ts diff --git a/AGENTS.md b/AGENTS.md index 4a0811d8..ea05fff9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,3 +28,6 @@ When writing new on-chain hooks, mirror the structure in `src/hooks/useERC20Appr ## Commit & Pull Request Guidelines Mirror the Conventional Commits style in history (`feat:`, `fix:`, `chore:`), keeping messages imperative and scoped. Sync with `main`, run `pnpm check`, and capture UI evidence (screenshots or short clips) for anything user-facing. Reference the relevant Linear/Jira ticket with closing keywords, call out risk areas, and flag required follow-ups. Tag reviewers who understand the touched protocol surfaces to speed feedback. + +## Incident Log +- Autovault settings refactor: we unintentionally spammed the Morpho API because we passed fresh array literals (`defaultAllocatorAddresses`) into `useVaultV2Data`. That array was part of the hook’s memoised fetch dependencies, so every render produced a new reference, rebuilt the `useCallback`, and re-triggered the fetch effect. **Guardrail:** before handing arrays or objects to hooks that fire network requests, memoize the props (or pass a stable key) so React’s dependency checks only change when the underlying data truly changes. diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultAgentSummary.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultAgentSummary.tsx index dbb3b205..53493b28 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultAgentSummary.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultAgentSummary.tsx @@ -10,7 +10,6 @@ type VaultAgentSummaryProps = { description: string; onManageAgents: () => void; onManageAllocations?: () => void; - onViewRoles: () => void; roleStatusText: string; }; @@ -20,7 +19,6 @@ export function VaultAgentSummary({ description, onManageAgents, onManageAllocations, - onViewRoles, roleStatusText, }: VaultAgentSummaryProps) { return ( @@ -68,9 +66,6 @@ export function VaultAgentSummary({ Allocation caps )} -
); diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultApyHistory.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultApyHistory.tsx index 2cee5246..645b00a6 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultApyHistory.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultApyHistory.tsx @@ -9,7 +9,7 @@ export function VaultApyHistory({ timeframes }: VaultApyHistoryProps) {
-

Historical APY

+

Historical APY

Performance data updates every epoch.

diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultAssetMovements.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultAssetMovements.tsx index 85da332b..d3b61740 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultAssetMovements.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultAssetMovements.tsx @@ -1,5 +1,3 @@ -import { Button } from '@/components/common'; - export type VaultAssetMovement = { timestamp: string; action: 'allocate' | 'deallocate'; @@ -16,14 +14,9 @@ type VaultAssetMovementsProps = { export function VaultAssetMovements({ history }: VaultAssetMovementsProps) { return (
-
-
-

Asset Movements

-

Track how the allocator rebalanced capital.

-
- +
+

Asset Movements

+

Track how the allocator rebalanced capital.

{history.length === 0 ? (
diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx index 91be655e..48e6f4a3 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx @@ -25,11 +25,11 @@ export function VaultMarketAllocations({ allocations, vaultAssetSymbol }: VaultM
-

Active Markets

+

Active Markets

Supply allocations managed by this vault.

- Vault asset: {vaultAssetSymbol} + Vault asset: {vaultAssetSymbol}
diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultRolesModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultRolesModal.tsx deleted file mode 100644 index 4c0e6ced..00000000 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultRolesModal.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Modal, ModalBody, ModalContent, ModalHeader } from '@heroui/react'; -import { VaultRole } from './VaultRolesOverview'; -import { VaultRolesOverview } from './VaultRolesOverview'; - -type VaultRolesModalProps = { - isOpen: boolean; - onClose: () => void; - roles: VaultRole[]; -}; - -export function VaultRolesModal({ isOpen, onClose, roles }: VaultRolesModalProps) { - return ( - - - Vault roles & safeguards - - - - - - ); -} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultRolesOverview.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultRolesOverview.tsx deleted file mode 100644 index 4be95b39..00000000 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultRolesOverview.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { Tooltip } from '@heroui/react'; -import { FiShield, FiUsers } from 'react-icons/fi'; -import { TooltipContent } from '@/components/TooltipContent'; - -export type VaultRole = { - key: 'owner' | 'curator' | 'allocator' | 'sentinel'; - label: string; - description: string; - addresses: string[]; - status: 'configured' | 'pending'; - guidance: string; - capabilities: string[]; -}; - -type VaultRolesOverviewProps = { - roles: VaultRole[]; -}; - -const ROLE_COLORS: Record = { - owner: 'bg-purple-500', - curator: 'bg-sky-500', - allocator: 'bg-emerald-500', - sentinel: 'bg-amber-500', -}; - -export function VaultRolesOverview({ roles }: VaultRolesOverviewProps) { - return ( -
-
-
-

Roles & Governance

-

- Assign independent keys so administration, risk, and execution stay separated. -

-
- } - title="Role Separation" - detail="Owner administers, Curator steers risk, Allocator executes, Sentinel reacts. Keep keys split." - /> - } - > - - -
- -
- {roles.map((role) => ( -
-
-
- -

{role.label}

-
- - {role.status === 'configured' ? 'Configured' : 'Needs attention'} - -
-

{role.description}

- -
- Assigned - {role.addresses.length === 0 ? ( -
- No address assigned yet. -
- ) : ( -
    - {role.addresses.map((address) => ( -
  • - {address} -
  • - ))} -
- )} -
- -
- Capabilities -
    - {role.capabilities.map((item) => ( -
  • • {item}
  • - ))} -
-
- - {role.status === 'pending' && ( -
- {role.guidance} -
- )} -
- ))} -
-
- ); -} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx index 48bc1fbf..5107d8bd 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx @@ -4,7 +4,6 @@ import { createPortal } from 'react-dom'; import { LuX } from 'react-icons/lu'; import { Button } from '@/components/common/Button'; import { Spinner } from '@/components/common/Spinner'; -import { AutovaultData } from '@/hooks/useAutovaultData'; type SettingsTab = 'general' | 'agents' | 'allocations'; @@ -18,7 +17,6 @@ type VaultSettingsModalProps = { isOpen: boolean; onClose: () => void; initialTab?: SettingsTab; - vault: AutovaultData; isOwner: boolean; onUpdateMetadata: (values: { name?: string; symbol?: string }) => Promise; updatingMetadata: boolean; @@ -26,13 +24,16 @@ type VaultSettingsModalProps = { defaultSymbol: string; currentName: string; currentSymbol: string; + ownerAddress?: string; + curatorAddress?: string; + allocatorAddresses: string[]; + guardianAddresses?: string[]; }; export function VaultSettingsModal({ isOpen, onClose, initialTab = 'general', - vault, isOwner, onUpdateMetadata, updatingMetadata, @@ -40,6 +41,10 @@ export function VaultSettingsModal({ defaultSymbol, currentName, currentSymbol, + ownerAddress, + curatorAddress, + allocatorAddresses, + guardianAddresses = [], }: VaultSettingsModalProps) { const [activeTab, setActiveTab] = useState(initialTab); const nameInputId = useId(); @@ -80,7 +85,33 @@ export function VaultSettingsModal({ return hasNewName || hasNewSymbol; }, [previousName, previousSymbol, trimmedName, trimmedSymbol]); - const allocatorCount = vault.agents.length; + const renderAddress = (address: string | undefined, emptyLabel: string) => { + if (!address) { + return {emptyLabel}; + } + + return ( + + {address} + + ); + }; + + const renderAddressGroup = (addresses: string[], emptyLabel: string) => { + if (!addresses.length) { + return {emptyLabel}; + } + + return ( +
+ {addresses.map((address) => ( + + {address} + + ))} +
+ ); + }; useEffect(() => { if (metadataError && metadataChanged) { @@ -175,34 +206,50 @@ export function VaultSettingsModal({ const renderAgentTab = () => (
-

Automation agent

+

Automation agent

Authorize the allocator address that executes deposits and withdrawals between enabled adapters.

- - {allocatorCount === 0 - ? 'No allocator assigned yet' - : `${allocatorCount} allocator${allocatorCount > 1 ? 's' : ''} authorized`} - - +

Authorized allocators

+ {renderAddressGroup(allocatorAddresses, 'No allocators assigned')} +
+
+

Authorized allocators

+ {renderAddressGroup(allocatorAddresses, 'No allocators assigned')}

Allocators handle on-chain execution based on the curator’s guardrails. Add your automation agent or desk wallet here so it can rebalance adapters.

+ +
+

Role assignments

+
+
+

Owner

+ {renderAddress(ownerAddress, 'Owner not assigned')} +
+
+

Risk curator

+ {renderAddress(curatorAddress, 'Curator not assigned')} +
+
+

Guardian(s)

+ {renderAddressGroup(guardianAddresses, 'No guardians configured')} +
+
+
); const renderAllocationsTab = () => (
-

Allocation caps

+

Allocation caps

Configure market-level caps and guardrails for the automation agent.

diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultSummaryMetrics.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultSummaryMetrics.tsx index 20eb599b..89f4da7d 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultSummaryMetrics.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultSummaryMetrics.tsx @@ -1,37 +1,5 @@ -import { ReactNode } from 'react'; +import { PropsWithChildren } from 'react'; -export type VaultMetric = { - label: string; - value: string; - helper?: string; - trendLabel?: string; - trendValue?: string; - icon?: ReactNode; -}; - -type VaultSummaryMetricsProps = { - metrics: VaultMetric[]; -}; - -export function VaultSummaryMetrics({ metrics }: VaultSummaryMetricsProps) { - return ( -
- {metrics.map((metric) => ( -
-
- {metric.label} - {metric.icon && {metric.icon}} -
-
{metric.value}
- {metric.helper &&
{metric.helper}
} - {metric.trendLabel && metric.trendValue && ( -
- {metric.trendLabel} - {metric.trendValue} -
- )} -
- ))} -
- ); +export function VaultSummaryMetrics({ children }: PropsWithChildren) { + return
{children}
; } diff --git a/app/autovault/[chainId]/[vaultAddress]/content.tsx b/app/autovault/[chainId]/[vaultAddress]/content.tsx index 110eed02..2babdde8 100644 --- a/app/autovault/[chainId]/[vaultAddress]/content.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/content.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { GearIcon } from '@radix-ui/react-icons'; import Link from 'next/link'; import { useParams } from 'next/navigation'; @@ -10,33 +10,31 @@ import { Button } from '@/components/common'; import { AddressDisplay } from '@/components/common/AddressDisplay'; import Header from '@/components/layout/header/Header'; import LoadingScreen from '@/components/Status/LoadingScreen'; -import { AutovaultData, VaultAllocation, useVaultDetails } from '@/hooks/useAutovaultData'; +import { TokenIcon } from '@/components/TokenIcon'; +import { VaultAllocation, useVaultDetails } from '@/hooks/useAutovaultData'; import { useVaultV2 } from '@/hooks/useVaultV2'; +import { useVaultV2Data } from '@/hooks/useVaultV2Data'; +import { getSlicedAddress } from '@/utils/address'; +import { formatBalance } from '@/utils/balance'; import { ALL_SUPPORTED_NETWORKS, SupportedNetworks, getNetworkConfig } from '@/utils/networks'; import { VaultAgentSummary } from './components/VaultAgentSummary'; import { VaultApyHistory } from './components/VaultApyHistory'; -import { VaultAssetMovements, VaultAssetMovement } from './components/VaultAssetMovements'; import { VaultInitializationModal } from './components/VaultInitializationModal'; import { VaultMarketAllocations } from './components/VaultMarketAllocations'; -import { VaultRolesModal } from './components/VaultRolesModal'; -import { VaultRole } from './components/VaultRolesOverview'; import { VaultSettingsModal } from './components/VaultSettingsModal'; -import { VaultSummaryMetrics, VaultMetric } from './components/VaultSummaryMetrics'; - -function formatUsd(value: number | bigint): string { - const numeric = typeof value === 'bigint' ? Number(value) : value; - if (!numeric || Number.isNaN(numeric)) return '--'; - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - maximumFractionDigits: numeric >= 1 ? 2 : 4, - }).format(numeric); -} +import { VaultSummaryMetrics } from './components/VaultSummaryMetrics'; export default function VaultContent() { const { chainId: chainIdParam, vaultAddress } = useParams<{ chainId: string; vaultAddress: string }>(); const vaultAddressValue = vaultAddress as Address; const { address } = useAccount(); + const [hasMounted, setHasMounted] = useState(false); + + useEffect(() => { + setHasMounted(true); + }, []); + + const connectedAddress = hasMounted ? address : undefined; const supportedChainId = useMemo(() => { const parsed = Number(chainIdParam); if (Number.isFinite(parsed) && ALL_SUPPORTED_NETWORKS.includes(parsed as SupportedNetworks)) { @@ -68,24 +66,62 @@ export default function VaultContent() { const [settingsTab, setSettingsTab] = useState<'general' | 'agents' | 'allocations'>('general'); const [showSettings, setShowSettings] = useState(false); - const [showRolesModal, setShowRolesModal] = useState(false); const [showInitializationModal, setShowInitializationModal] = useState(false); - const { vault, isLoading, isError } = useVaultDetails(vaultAddressValue); + const { vault: vaultDetails, isLoading, isError } = useVaultDetails(vaultAddressValue); - if (isLoading) { - return ( -
-
-
- -
-
- ); - } + const isOwner = Boolean( + vaultDetails.owner && connectedAddress && vaultDetails.owner.toLowerCase() === connectedAddress.toLowerCase(), + ); + + const marketAllocations: VaultAllocation[] = vaultDetails.allocations ?? []; + const vaultAssetSymbol = marketAllocations[0]?.assetSymbol ?? '—'; + const vaultName = vaultDetails.name?.trim(); + const vaultSymbol = vaultDetails.symbol?.trim(); + const fallbackTitle = `Vault ${getSlicedAddress(vaultAddressValue)}`; + const { data: vaultData, loading: vaultDataLoading } = useVaultV2Data({ + vaultAddress: vaultAddressValue, + chainId: supportedChainId, + fallbackName: vaultName, + fallbackSymbol: vaultSymbol, + onChainName, + onChainSymbol, + ownerAddress: vaultDetails.owner, + defaultAllocatorAddresses: vaultDetails.agents.map((agent) => agent.id), + }); + const isFetchingSummary = isLoading || vaultDataLoading; + + const title = vaultName || fallbackTitle; + const symbolToDisplay = vaultSymbol ? vaultSymbol : ''; + const allocatorAddresses = vaultData.allocatorAddresses; + const guardianAddresses = vaultData.guardianAddresses; + const allocatorCount = vaultData.allocatorCount; + const roleStatusText = useMemo(() => { + if (needsSetup) return 'Adapter pending deployment'; + if (!vaultData.curatorAddress) return 'Curator not assigned yet'; + if (allocatorCount === 0) return 'Add an allocator to enable automation'; + return 'All critical roles are assigned to safe wallets.'; + }, [allocatorCount, needsSetup, vaultData.curatorAddress]); + + const assetAddress = vaultData.assetAddress; + const totalSupplyLabel = useMemo(() => { + if (!vaultData.totalSupplyRaw || vaultData.tokenDecimals === undefined) { + return '--'; + } + + try { + const rawValue = BigInt(vaultData.totalSupplyRaw); + const numericSupply = formatBalance(rawValue, vaultData.tokenDecimals); + const formatted = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 2, + }).format(numericSupply); + return `${formatted}${vaultData.tokenSymbol ? ` ${vaultData.tokenSymbol}` : ''}`.trim(); + } catch (_error) { + return '--'; + } + }, [vaultData.tokenDecimals, vaultData.tokenSymbol, vaultData.totalSupplyRaw]); + + const apyDisplay = vaultDetails.currentApy ? `${vaultDetails.currentApy.toFixed(2)}%` : '0%'; if (isError) { return ( @@ -93,7 +129,7 @@ export default function VaultContent() {
-

Vault data unavailable

+

Vault data unavailable

We could not load this autovault right now. Please retry in a few minutes.

@@ -106,162 +142,55 @@ export default function VaultContent() { ); } - const now = new Date(); - const placeholderVault: AutovaultData = { - id: 'placeholder', - address: vaultAddress as Address, - name: 'Monarch Auto Vault', - symbol: 'mAUTO', - description: 'Track how your automation is performing and what still needs configuration.', - totalValue: BigInt(2_100_000_000), - currentApy: 7.4, - agents: [ - { - id: '0xallocator-bot', - name: 'Allocator Bot', - description: 'Automated allocator executing the base strategy.', - status: 'active', - performance: { - totalValue: BigInt(1_500_000_000), - apr: 6.9, - totalReturns: BigInt(120_000_000), - }, - }, - ], - status: 'active', - owner: (address ?? '0x0000000000000000000000000000000000000000') as Address, - createdAt: now, - lastActivity: now, - rebalanceHistory: [], - allocations: [], - }; - - const displayVault = vault ?? placeholderVault; - const isPlaceholder = !vault; - - const isOwner = Boolean( - displayVault?.owner && - address && - displayVault.owner.toLowerCase() === (address ?? '').toLowerCase(), - ); - - const metrics: VaultMetric[] = [ - { - label: 'Total Assets', - value: formatUsd(Number(displayVault.totalValue) / 1e6), - helper: 'Assets currently automated across adapters', - }, - { - label: 'Current APY', - value: displayVault.currentApy ? `${displayVault.currentApy.toFixed(2)}%` : '--', - helper: 'Net of performance and management fees', - }, - { - label: '24h Earnings', - value: isPlaceholder ? '$12,420' : '--', - helper: 'Based on adapter reports (coming soon)', - }, - { - label: 'Last Activity', - value: displayVault.lastActivity.toLocaleDateString(), - helper: 'Most recent automation event', - }, - ]; - - const marketAllocations: VaultAllocation[] = displayVault.allocations ?? []; - const vaultAssetSymbol = marketAllocations[0]?.assetSymbol ?? '—'; - const fallbackSymbol = vaultAssetSymbol !== '—' ? `m${vaultAssetSymbol}` : 'mAUTO'; - const fallbackName = `Monarch Auto ${vaultAssetSymbol !== '—' ? vaultAssetSymbol : 'Vault'}`; - const effectiveName = (onChainName?.trim() || displayVault.name || fallbackName).trim(); - const effectiveSymbol = (onChainSymbol?.trim() || displayVault.symbol || fallbackSymbol).trim(); - - const assetMovements: VaultAssetMovement[] = isPlaceholder - ? [] - : displayVault.rebalanceHistory.map((rebalance) => ({ - timestamp: rebalance.timestamp.toLocaleString(), - action: 'allocate', - from: rebalance.fromMarket, - to: rebalance.toMarket, - amount: `${Number(rebalance.amount ?? 0n) / 1e6} tokens`, - })); - - const allocatorAddresses = displayVault.agents.map((agent) => agent.id); - - const roles: VaultRole[] = [ - { - key: 'owner', - label: 'Owner', - description: 'Appoints other roles and manages high-level governance.', - addresses: displayVault.owner ? [displayVault.owner] : [], - status: displayVault.owner ? 'configured' : 'pending', - guidance: 'Assign a secure multisig (4-of-6 recommended) responsible for curators and sentinels.', - capabilities: ['Transfer ownership', 'Appoint curator', 'Add/remove sentinels', 'Update vault metadata'], - }, - { - key: 'curator', - label: 'Risk Curator', - description: 'Defines adapters, caps, and fees via timelocked actions.', - addresses: [], - status: 'pending', - guidance: 'Nominate a curator multisig so the strategy can evolve under controlled timelocks.', - capabilities: ['Enable/disable adapters (timelocked)', 'Tune caps and rates', 'Manage allocators & compliance gates'], - }, - { - key: 'allocator', - label: 'Allocator(s)', - description: 'Executes the strategy within the guardrails the curator sets.', - addresses: allocatorAddresses, - status: allocatorAddresses.length > 0 ? 'configured' : 'pending', - guidance: 'Authorize your automation agent or desk wallet so it can move liquidity between adapters.', - capabilities: ['Allocate idle assets', 'Deallocate when liquidity is needed', 'Operate liquidity adapter for deposits'], - }, - { - key: 'sentinel', - label: 'Sentinel', - description: 'Emergency responder that can unwind or veto risky actions.', - addresses: [], - status: 'pending', - guidance: 'Add a sentinel key (bot or DAO) that can revoke unsafe curator actions and unwind adapters fast.', - capabilities: ['Instantly lower caps', 'Deallocate from adapters', 'Revoke timelocked actions before execution'], - }, - ]; - return (
-
-
-
-

{effectiveName}

- {effectiveSymbol} -
-

{displayVault.description}

-
Automation service overview • vault analytics
+ {isFetchingSummary && ( +
+
- {isOwner && ( - - )} + showExplorerLink + /> + {isOwner && ( + + )} +
- - {needsSetup && networkConfig?.vaultConfig?.marketV1AdapterFactory && (
-

Adapter not configured

+

Adapter not configured

Finish the initialization process to begin configuring strategies for this vault.

@@ -278,28 +207,46 @@ export default function VaultContent() {
)} - + {!isFetchingSummary && ( + +
+ Total supply +
+ {totalSupplyLabel} + {assetAddress && ( + + )} +
+
+ {vaultData.tokenSymbol ? `${vaultData.tokenSymbol} vault supply` : 'Vault token supply'} +
+
+
+ Current APY +
{apyDisplay}
+
Live APY coming soon
+
+
+ Allocators +
{allocatorCount}
+
+ {allocatorCount > 0 ? 'Active automation agents' : 'Add an allocator to enable automation'} +
+
+
+ )} role.status !== 'configured').length > 0 - ? `Pending roles: ${roles - .filter((role) => role.status !== 'configured') - .map((role) => role.label) - .join(', ')}` - : 'All critical roles are assigned to safe wallets.' - } + roleStatusText={roleStatusText} onManageAgents={() => { if (needsSetup && networkConfig?.vaultConfig?.marketV1AdapterFactory) { setShowInitializationModal(true); @@ -312,7 +259,6 @@ export default function VaultContent() { setSettingsTab('allocations'); setShowSettings(true); }} - onViewRoles={() => setShowRolesModal(true)} /> - - setShowSettings(false)} initialTab={settingsTab} - vault={displayVault} isOwner={isOwner} onUpdateMetadata={updateNameAndSymbol} updatingMetadata={isUpdatingMetadata} - defaultName={fallbackName} - defaultSymbol={fallbackSymbol} + defaultName={vaultName || ''} + defaultSymbol={vaultSymbol || ''} currentName={onChainName ?? ''} currentSymbol={onChainSymbol ?? ''} + ownerAddress={vaultData.ownerAddress} + curatorAddress={vaultData.curatorAddress} + allocatorAddresses={allocatorAddresses} + guardianAddresses={guardianAddresses} />
- setShowRolesModal(false)} - roles={roles} - /> {networkConfig?.vaultConfig?.marketV1AdapterFactory && ( { + if (!showExplorerLink || chainId === undefined) return null; + const numericChainId = Number(chainId); + if (!Number.isFinite(numericChainId)) return null; + return getExplorerURL(address as `0x${string}`, numericChainId as SupportedNetworks); + }, [address, chainId, showExplorerLink]); + + if (size === 'sm') { + return ( +
+ + {explorerHref && ( + + + + )} +
+ ); + } + return ( -
+
{mounted && isOwner && isConnected && ( @@ -36,12 +84,28 @@ export function AddressDisplay({ address }: AddressDisplayProps) { )}
- +
+ + {explorerHref && ( + + + + )} +
); diff --git a/src/data-sources/morpho-api/vaults.ts b/src/data-sources/morpho-api/vaults.ts new file mode 100644 index 00000000..75647cb6 --- /dev/null +++ b/src/data-sources/morpho-api/vaults.ts @@ -0,0 +1,67 @@ +import { morphoGraphqlFetcher } from './fetchers'; + +type VaultV2GraphItem = { + id: string; + name?: string | null; + symbol?: string | null; + totalSupply?: string | null; + asset?: { + id?: string | null; + decimals?: number | null; + } | null; + curator?: { + address?: string | null; + } | null; + allocators?: { + allocator?: { + address?: string | null; + } | null; + }[] | null; +}; + +type VaultV2GraphResponse = { + vaultV2s: { + items: VaultV2GraphItem[]; + }; +}; + +const VAULT_V2_QUERY = /* GraphQL */ ` + query VaultV2Query($address: String!, $chainId: Int!) { + vaultV2s(where: { chainId_in: [$chainId], address_in: [$address] }) { + items { + id + name + symbol + totalSupply + asset { + id + decimals + } + curator { + address + } + allocators { + allocator { + address + } + } + } + } + } +`; + +export async function fetchVaultV2({ + address, + chainId, +}: { + address: string; + chainId: number; +}): Promise { + const response = await morphoGraphqlFetcher(VAULT_V2_QUERY, { + address: address.toLowerCase(), + chainId, + }); + + const item = response?.vaultV2s?.items?.[0]; + return item ?? null; +} diff --git a/src/hooks/useAutovaultData.ts b/src/hooks/useAutovaultData.ts index 0acd62a2..fe754320 100644 --- a/src/hooks/useAutovaultData.ts +++ b/src/hooks/useAutovaultData.ts @@ -52,6 +52,28 @@ export type AutovaultData = { allocations?: VaultAllocation[]; }; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Address; + +const createEmptyVault = (address?: Address): AutovaultData => { + const safeAddress = address ?? ZERO_ADDRESS; + return { + id: 'empty', + address: safeAddress, + name: '', + symbol: '', + description: '', + totalValue: 0n, + currentApy: 0, + agents: [], + status: 'inactive', + owner: ZERO_ADDRESS, + createdAt: new Date(0), + lastActivity: new Date(0), + rebalanceHistory: [], + allocations: [], + }; +}; + type UseAutovaultDataResult = { autovaults: AutovaultData[]; isLoading: boolean; @@ -135,20 +157,20 @@ export function useHasActiveAutovaults(account?: Address): { // Hook to get specific vault details by vault address export function useVaultDetails(vaultAddress?: Address): { - vault: AutovaultData | null; + vault: AutovaultData; isLoading: boolean; isError: boolean; error: Error | null; refetch: () => Promise; } { - const [vault, setVault] = useState(null); + const [vault, setVault] = useState(() => createEmptyVault(vaultAddress)); const [isLoading, setIsLoading] = useState(true); const [isError, setIsError] = useState(false); const [error, setError] = useState(null); const fetchVaultDetails = async () => { if (!vaultAddress) { - setVault(null); + setVault(createEmptyVault()); setIsLoading(false); return; } @@ -169,10 +191,11 @@ export function useVaultDetails(vaultAddress?: Address): { // Mock data - replace with actual implementation const mockVault: AutovaultData | null = null; - setVault(mockVault); + setVault(mockVault ?? createEmptyVault(vaultAddress)); } catch (err) { setIsError(true); setError(err instanceof Error ? err : new Error('Failed to fetch vault details')); + setVault(createEmptyVault(vaultAddress)); } finally { setIsLoading(false); } @@ -183,6 +206,7 @@ export function useVaultDetails(vaultAddress?: Address): { }; useEffect(() => { + setVault(createEmptyVault(vaultAddress)); void fetchVaultDetails(); }, [vaultAddress]); diff --git a/src/hooks/useVaultV2Data.ts b/src/hooks/useVaultV2Data.ts new file mode 100644 index 00000000..f460912e --- /dev/null +++ b/src/hooks/useVaultV2Data.ts @@ -0,0 +1,223 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Address, formatUnits } from 'viem'; +import { useTokens } from '@/components/providers/TokenProvider'; +import { fetchVaultV2 } from '@/data-sources/morpho-api/vaults'; +import { getSlicedAddress } from '@/utils/address'; +import { formatReadable } from '@/utils/balance'; + +const normalize = (value?: string | null) => value?.toLowerCase().trim() ?? undefined; + +type UseVaultV2DataArgs = { + vaultAddress?: Address; + chainId: number; + fallbackName?: string; + fallbackSymbol?: string; + onChainName?: string | null; + onChainSymbol?: string | null; + ownerAddress?: Address; + defaultAllocatorAddresses?: string[]; +}; + +export type VaultV2ComputedData = { + displayName: string; + displaySymbol: string; + assetAddress?: string; + tokenSymbol?: string; + tokenDecimals?: number; + totalSupplyDisplay: string; + totalSupplyRaw?: string; + allocatorAddresses: string[]; + allocatorCount: number; + ownerAddress?: string; + curatorAddress?: string; + guardianAddresses: string[]; + curatorDisplay: string; +}; + +type UseVaultV2DataReturn = { + data: VaultV2ComputedData; + loading: boolean; + error: Error | null; + refetch: () => Promise; +}; + +export function useVaultV2Data({ + vaultAddress, + chainId, + fallbackName = '', + fallbackSymbol = '', + onChainName, + onChainSymbol, + ownerAddress, + defaultAllocatorAddresses, +}: UseVaultV2DataArgs): UseVaultV2DataReturn { + const { findToken } = useTokens(); + + const normalizedOwner = useMemo(() => normalize(ownerAddress), [ownerAddress]); + const allocatorInputKey = useMemo(() => { + if (!defaultAllocatorAddresses?.length) return ''; + return defaultAllocatorAddresses.map((addr) => normalize(addr) ?? '').join('|'); + }, [defaultAllocatorAddresses]); + + const defaultAllocators = useMemo(() => { + if (!defaultAllocatorAddresses?.length) return []; + return defaultAllocatorAddresses + .map((addr) => normalize(addr)) + .filter((addr): addr is string => Boolean(addr)); + }, [allocatorInputKey]); + + const buildData = useCallback( + (item: { + name?: string; + symbol?: string; + totalSupply?: string; + assetAddress?: string; + assetDecimals?: number; + allocatorAddresses: string[]; + curatorAddress?: string; + } | null): VaultV2ComputedData => { + const assetAddress = item?.assetAddress; + const token = assetAddress ? findToken(assetAddress, chainId) : undefined; + const assetDecimals = item?.assetDecimals; + + const displayName = (item?.name?.trim() || onChainName?.trim() || fallbackName).trim(); + const displaySymbol = (item?.symbol?.trim() || onChainSymbol?.trim() || fallbackSymbol).trim(); + + let totalSupplyDisplay = '--'; + if (item?.totalSupply) { + try { + const decimals = token?.decimals ?? assetDecimals ?? 18; + const parsed = Number(formatUnits(BigInt(item.totalSupply), decimals)); + totalSupplyDisplay = `${formatReadable(parsed)} ${token?.symbol ?? displaySymbol}`; + } catch (error) { + console.error('Failed to format vault total supply', error); + } + } + + const normalizedAllocators = (item?.allocatorAddresses.length + ? item.allocatorAddresses + : defaultAllocators) + .map((addr) => normalize(addr)) + .filter((addr): addr is string => Boolean(addr)); + + const allocatorAddresses = Array.from(new Set(normalizedAllocators)); + + const owner = normalizedOwner; + const curator = item?.curatorAddress ?? undefined; + const curatorDisplay = curator ? getSlicedAddress(curator as `0x${string}`) : '--'; + + return { + displayName, + displaySymbol, + assetAddress, + tokenSymbol: token?.symbol, + tokenDecimals: token?.decimals ?? assetDecimals, + totalSupplyDisplay, + totalSupplyRaw: item?.totalSupply, + allocatorAddresses, + allocatorCount: allocatorAddresses.length, + ownerAddress: owner, + curatorAddress: curator, + guardianAddresses: [], + curatorDisplay, + }; + }, + [chainId, defaultAllocators, fallbackName, fallbackSymbol, findToken, normalizedOwner, onChainName, onChainSymbol], + ); + + const [data, setData] = useState(() => + buildData({ + name: onChainName ?? undefined, + symbol: onChainSymbol ?? undefined, + totalSupply: undefined, + assetAddress: undefined, + assetDecimals: undefined, + allocatorAddresses: defaultAllocators, + curatorAddress: undefined, + }), + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + + try { + if (!vaultAddress) { + setData( + buildData({ + name: onChainName ?? undefined, + symbol: onChainSymbol ?? undefined, + totalSupply: undefined, + assetAddress: undefined, + assetDecimals: undefined, + allocatorAddresses: defaultAllocators, + curatorAddress: undefined, + }), + ); + return; + } + + const result = await fetchVaultV2({ address: vaultAddress, chainId }); + + if (!result) { + setData( + buildData({ + name: onChainName ?? undefined, + symbol: onChainSymbol ?? undefined, + totalSupply: undefined, + assetAddress: undefined, + assetDecimals: undefined, + allocatorAddresses: defaultAllocators, + curatorAddress: undefined, + }), + ); + return; + } + + setData( + buildData({ + name: result.name ?? undefined, + symbol: result.symbol ?? undefined, + totalSupply: result.totalSupply ?? undefined, + assetAddress: normalize(result.asset?.id), + assetDecimals: result.asset?.decimals ?? undefined, + allocatorAddresses: (result.allocators ?? []) + .map((entry) => normalize(entry?.allocator?.address)) + .filter((addr): addr is string => Boolean(addr)), + curatorAddress: normalize(result.curator?.address), + }), + ); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to fetch vault data')); + setData( + buildData({ + name: onChainName ?? undefined, + symbol: onChainSymbol ?? undefined, + totalSupply: undefined, + assetAddress: undefined, + assetDecimals: undefined, + allocatorAddresses: defaultAllocators, + curatorAddress: undefined, + }), + ); + } finally { + setLoading(false); + } + }, [buildData, chainId, defaultAllocators, normalizedOwner, onChainName, onChainSymbol, vaultAddress]); + + useEffect(() => { + void load(); + }, [load]); + + return useMemo( + () => ({ + data, + loading, + error, + refetch: load, + }), + [data, error, load, loading], + ); +} From f79e759dfdef4e01f19e124600e4496e5ba375a9 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 8 Oct 2025 09:52:12 +0800 Subject: [PATCH 08/29] feat: modal layout --- .../components/VaultSettingsModal.tsx | 126 ++++---- .../[chainId]/[vaultAddress]/content.tsx | 273 +++++++++--------- src/components/common/AddressDisplay.tsx | 55 +++- src/data-sources/rpc/vaults.ts | 116 ++++++++ src/hooks/useVaultV2Data.ts | 84 ++++-- 5 files changed, 439 insertions(+), 215 deletions(-) create mode 100644 src/data-sources/rpc/vaults.ts diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx index 5107d8bd..b010555e 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx @@ -1,8 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState, useId } from 'react'; +import { Address } from 'viem'; import { Input } from '@heroui/react'; import { createPortal } from 'react-dom'; import { LuX } from 'react-icons/lu'; import { Button } from '@/components/common/Button'; +import { AddressDisplay } from '@/components/common/AddressDisplay'; import { Spinner } from '@/components/common/Spinner'; type SettingsTab = 'general' | 'agents' | 'allocations'; @@ -85,30 +87,68 @@ export function VaultSettingsModal({ return hasNewName || hasNewSymbol; }, [previousName, previousSymbol, trimmedName, trimmedSymbol]); - const renderAddress = (address: string | undefined, emptyLabel: string) => { - if (!address) { - return {emptyLabel}; - } + const renderSingleRole = ( + label: string, + description: string, + addressValue?: string, + ) => { + const normalized = addressValue ? (addressValue as Address) : undefined; return ( - - {address} - +
+
+

{label}

+

{description}

+
+ {normalized ? ( + + ) : ( + Not assigned + )} +
); }; - const renderAddressGroup = (addresses: string[], emptyLabel: string) => { + const renderRoleList = ( + label: string, + description: string, + addresses: string[], + emptyLabel: string, + ) => { if (!addresses.length) { - return {emptyLabel}; + return ( +
+
+

{label}

+

{description}

+
+ {emptyLabel} +
+ ); } return ( -
- {addresses.map((address) => ( - - {address} - - ))} +
+
+

{label}

+

{description}

+
+
+ {addresses.map((entry) => ( + + ))} +
); }; @@ -205,44 +245,20 @@ export function VaultSettingsModal({ const renderAgentTab = () => (
-
-

Automation agent

-

- Authorize the allocator address that executes deposits and withdrawals between enabled adapters. -

-
-
-
-

Authorized allocators

- {renderAddressGroup(allocatorAddresses, 'No allocators assigned')} -
-
-

Authorized allocators

- {renderAddressGroup(allocatorAddresses, 'No allocators assigned')} -
-

- Allocators handle on-chain execution based on the curator’s guardrails. Add your automation agent or desk wallet - here so it can rebalance adapters. -

-
- -
-

Role assignments

-
-
-

Owner

- {renderAddress(ownerAddress, 'Owner not assigned')} -
-
-

Risk curator

- {renderAddress(curatorAddress, 'Curator not assigned')} -
-
-

Guardian(s)

- {renderAddressGroup(guardianAddresses, 'No guardians configured')} -
-
-
+ {renderSingleRole('Owner', 'Primary controller of vault permissions.', ownerAddress)} + {renderSingleRole('Curator', 'Defines risk guardrails for automation.', curatorAddress)} + {renderRoleList( + 'Allocators', + 'Automation agents executing the configured strategy.', + allocatorAddresses, + 'No allocators assigned', + )} + {renderRoleList( + 'Guardians', + 'Sentinels able to pause automation when safeguards trigger.', + guardianAddresses, + 'No guardians configured', + )}
); @@ -319,7 +335,7 @@ export function VaultSettingsModal({ onMouseDown={onClose} >
event.stopPropagation()} >
@@ -355,7 +371,7 @@ export function VaultSettingsModal({
-
{renderActiveTab()}
+
{renderActiveTab()}
diff --git a/app/autovault/[chainId]/[vaultAddress]/content.tsx b/app/autovault/[chainId]/[vaultAddress]/content.tsx index 2babdde8..b6cee030 100644 --- a/app/autovault/[chainId]/[vaultAddress]/content.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/content.tsx @@ -76,14 +76,12 @@ export default function VaultContent() { const marketAllocations: VaultAllocation[] = vaultDetails.allocations ?? []; const vaultAssetSymbol = marketAllocations[0]?.assetSymbol ?? '—'; - const vaultName = vaultDetails.name?.trim(); - const vaultSymbol = vaultDetails.symbol?.trim(); const fallbackTitle = `Vault ${getSlicedAddress(vaultAddressValue)}`; const { data: vaultData, loading: vaultDataLoading } = useVaultV2Data({ vaultAddress: vaultAddressValue, chainId: supportedChainId, - fallbackName: vaultName, - fallbackSymbol: vaultSymbol, + fallbackName: vaultDetails.name?.trim(), + fallbackSymbol: vaultDetails.symbol?.trim(), onChainName, onChainSymbol, ownerAddress: vaultDetails.owner, @@ -91,8 +89,8 @@ export default function VaultContent() { }); const isFetchingSummary = isLoading || vaultDataLoading; - const title = vaultName || fallbackTitle; - const symbolToDisplay = vaultSymbol ? vaultSymbol : ''; + const title = vaultData.displayName || fallbackTitle; + const symbolToDisplay = vaultData.displaySymbol; const allocatorAddresses = vaultData.allocatorAddresses; const guardianAddresses = vaultData.guardianAddresses; const allocatorCount = vaultData.allocatorCount; @@ -104,24 +102,24 @@ export default function VaultContent() { }, [allocatorCount, needsSetup, vaultData.curatorAddress]); const assetAddress = vaultData.assetAddress; + const totalSupplyLabel = useMemo(() => { - if (!vaultData.totalSupplyRaw || vaultData.tokenDecimals === undefined) { - return '--'; - } + if (!vaultData.totalSupplyRaw || vaultData.tokenDecimals === undefined) return '--'; try { - const rawValue = BigInt(vaultData.totalSupplyRaw); - const numericSupply = formatBalance(rawValue, vaultData.tokenDecimals); - const formatted = new Intl.NumberFormat('en-US', { + const rawSupply = BigInt(vaultData.totalSupplyRaw); + const numericSupply = formatBalance(rawSupply, vaultData.tokenDecimals); + const formattedSupply = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2, }).format(numericSupply); - return `${formatted}${vaultData.tokenSymbol ? ` ${vaultData.tokenSymbol}` : ''}`.trim(); + + return `${formattedSupply}${vaultData.tokenSymbol ? ` ${vaultData.tokenSymbol}` : ''}`.trim(); } catch (_error) { return '--'; } }, [vaultData.tokenDecimals, vaultData.tokenSymbol, vaultData.totalSupplyRaw]); - const apyDisplay = vaultDetails.currentApy ? `${vaultDetails.currentApy.toFixed(2)}%` : '0%'; + const apyLabel = vaultDetails.currentApy ? `${vaultDetails.currentApy.toFixed(2)}%` : '0%'; if (isError) { return ( @@ -147,141 +145,138 @@ export default function VaultContent() {
- {isFetchingSummary && ( -
+ {isFetchingSummary ? ( +
- )} + ) : ( + <> +
+
+

{title}

+ {symbolToDisplay && ( + {symbolToDisplay} + )} +
+
+ + {isOwner && ( + + )} +
+
-
-
-

{title}

- {symbolToDisplay && ( - {symbolToDisplay} - )} - {assetAddress && ( - - )} -
-
- - {isOwner && ( - + {needsSetup && networkConfig?.vaultConfig?.marketV1AdapterFactory && ( +
+
+

Adapter not configured

+

+ Finish the initialization process to begin configuring strategies for this vault. +

+
+ +
)} -
-
- - {needsSetup && networkConfig?.vaultConfig?.marketV1AdapterFactory && ( -
-
-

Adapter not configured

-

- Finish the initialization process to begin configuring strategies for this vault. -

-
- -
- )} - {!isFetchingSummary && ( - -
- Total supply -
- {totalSupplyLabel} - {assetAddress && ( - - )} + +
+ Total supply +
+ {totalSupplyLabel} + {assetAddress && ( + + )} +
+
+ {vaultData.tokenSymbol ? `${vaultData.tokenSymbol} vault supply` : 'Vault token supply'} +
-
- {vaultData.tokenSymbol ? `${vaultData.tokenSymbol} vault supply` : 'Vault token supply'} +
+ Current APY +
{apyLabel}
+
Live APY coming soon
-
-
- Current APY -
{apyDisplay}
-
Live APY coming soon
-
-
- Allocators -
{allocatorCount}
-
- {allocatorCount > 0 ? 'Active automation agents' : 'Add an allocator to enable automation'} +
+ Allocators +
{allocatorCount}
+
+ {allocatorCount > 0 ? 'Active automation agents' : 'Add an allocator to enable automation'} +
-
- - )} + - { - if (needsSetup && networkConfig?.vaultConfig?.marketV1AdapterFactory) { - setShowInitializationModal(true); - return; - } - setSettingsTab('agents'); - setShowSettings(true); - }} - onManageAllocations={() => { - setSettingsTab('allocations'); - setShowSettings(true); - }} - /> + { + if (needsSetup && networkConfig?.vaultConfig?.marketV1AdapterFactory) { + setShowInitializationModal(true); + return; + } + setSettingsTab('agents'); + setShowSettings(true); + }} + onManageAllocations={() => { + setSettingsTab('allocations'); + setShowSettings(true); + }} + /> - - - setShowSettings(false)} - initialTab={settingsTab} - isOwner={isOwner} - onUpdateMetadata={updateNameAndSymbol} - updatingMetadata={isUpdatingMetadata} - defaultName={vaultName || ''} - defaultSymbol={vaultSymbol || ''} - currentName={onChainName ?? ''} - currentSymbol={onChainSymbol ?? ''} - ownerAddress={vaultData.ownerAddress} - curatorAddress={vaultData.curatorAddress} - allocatorAddresses={allocatorAddresses} - guardianAddresses={guardianAddresses} - /> + + + setShowSettings(false)} + initialTab={settingsTab} + isOwner={isOwner} + onUpdateMetadata={updateNameAndSymbol} + updatingMetadata={isUpdatingMetadata} + defaultName={vaultData.displayName} + defaultSymbol={vaultData.displaySymbol} + currentName={onChainName ?? ''} + currentSymbol={onChainSymbol ?? ''} + ownerAddress={vaultData.ownerAddress} + curatorAddress={vaultData.curatorAddress} + allocatorAddresses={allocatorAddresses} + guardianAddresses={guardianAddresses} + /> + + )}
diff --git a/src/components/common/AddressDisplay.tsx b/src/components/common/AddressDisplay.tsx index 07bc9ba5..dd8a3a91 100644 --- a/src/components/common/AddressDisplay.tsx +++ b/src/components/common/AddressDisplay.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState, useEffect } from 'react'; +import { useMemo, useState, useEffect, useCallback } from 'react'; import clsx from 'clsx'; import { FaCircle } from 'react-icons/fa'; import { LuExternalLink } from 'react-icons/lu'; @@ -10,6 +10,7 @@ import { Avatar } from '@/components/Avatar/Avatar'; import { Name } from '@/components/common/Name'; import { getExplorerURL } from '@/utils/external'; import { SupportedNetworks } from '@/utils/networks'; +import { useStyledToast } from '@/hooks/useStyledToast'; type AddressDisplayProps = { address: Address; @@ -17,6 +18,7 @@ type AddressDisplayProps = { size?: 'md' | 'sm'; showExplorerLink?: boolean; className?: string; + copyable?: boolean; }; export function AddressDisplay({ @@ -25,9 +27,11 @@ export function AddressDisplay({ size = 'md', showExplorerLink = false, className, + copyable = false, }: AddressDisplayProps) { const { address: connectedAddress, isConnected } = useAccount(); const [mounted, setMounted] = useState(false); + const { success: toastSuccess } = useStyledToast(); useEffect(() => { setMounted(true); @@ -44,9 +48,36 @@ export function AddressDisplay({ return getExplorerURL(address as `0x${string}`, numericChainId as SupportedNetworks); }, [address, chainId, showExplorerLink]); + const handleCopy = useCallback(async () => { + if (!copyable) return; + + try { + await navigator.clipboard.writeText(address); + toastSuccess('Address copied', `${address.slice(0, 6)}...${address.slice(-4)}`); + } catch (error) { + console.error('Failed to copy address', error); + } + }, [address, copyable, toastSuccess]); + if (size === 'sm') { return ( -
+
{ + if (!copyable) return; + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + void handleCopy(); + } + }} + > event.stopPropagation()} > @@ -72,7 +104,23 @@ export function AddressDisplay({ } return ( -
+
{ + if (!copyable) return; + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + void handleCopy(); + } + }} + >
{mounted && isOwner && isConnected && ( @@ -101,6 +149,7 @@ export function AddressDisplay({ rel="noreferrer" className="text-secondary transition-colors hover:text-primary" aria-label="View on explorer" + onClick={(event) => event.stopPropagation()} > diff --git a/src/data-sources/rpc/vaults.ts b/src/data-sources/rpc/vaults.ts new file mode 100644 index 00000000..da7a5340 --- /dev/null +++ b/src/data-sources/rpc/vaults.ts @@ -0,0 +1,116 @@ +import { Address, erc20Abi } from 'viem'; +import { vaultv2Abi } from '@/abis/vaultv2'; +import { SupportedNetworks } from '@/utils/networks'; +import { getClient } from '@/utils/rpc'; + +type VaultV2RpcResult = { + name?: string; + symbol?: string; + totalSupply?: bigint; + assetAddress?: Address; + assetDecimals?: number; + owner?: Address; + curator?: Address; +}; + +/** + * Lightweight RPC fallback for Vault V2 metadata. This should only be used when + * the primary Morpho API endpoint is unavailable, as it performs direct RPC + * calls against the current chain. + */ +export async function fetchVaultV2ViaRpc({ + address, + chainId, +}: { + address: Address; + chainId: SupportedNetworks; +}): Promise { + try { + const client = getClient(chainId); + + const [ + nameResult, + symbolResult, + ownerResult, + curatorResult, + totalSupplyResult, + assetResult, + ] = await Promise.allSettled([ + client.readContract({ + address, + abi: vaultv2Abi, + functionName: 'name', + }), + client.readContract({ + address, + abi: vaultv2Abi, + functionName: 'symbol', + }), + client.readContract({ + address, + abi: vaultv2Abi, + functionName: 'owner', + }), + client.readContract({ + address, + abi: vaultv2Abi, + functionName: 'curator', + }), + client.readContract({ + address, + abi: vaultv2Abi, + functionName: 'totalSupply', + }), + client.readContract({ + address, + abi: vaultv2Abi, + functionName: 'asset', + }), + ]); + + const assetAddress = + assetResult.status === 'fulfilled' + ? (assetResult.value as Address) + : undefined; + + const ownerAddress = + ownerResult.status === 'fulfilled' + ? ((ownerResult.value as Address) ?? undefined) + : undefined; + + const curatorAddress = + curatorResult.status === 'fulfilled' + ? ((curatorResult.value as Address) ?? undefined) + : undefined; + + let assetDecimals: number | undefined; + if (assetAddress) { + try { + const decimals = await client.readContract({ + address: assetAddress, + abi: erc20Abi, + functionName: 'decimals', + }); + assetDecimals = Number(decimals); + } catch (error) { + console.error('Failed to read asset decimals via RPC', error); + } + } + + return { + name: nameResult.status === 'fulfilled' ? (nameResult.value as string) : undefined, + symbol: symbolResult.status === 'fulfilled' ? (symbolResult.value as string) : undefined, + totalSupply: + totalSupplyResult.status === 'fulfilled' + ? (totalSupplyResult.value as bigint) + : undefined, + assetAddress, + assetDecimals, + owner: ownerAddress, + curator: curatorAddress, + }; + } catch (error) { + console.error('Failed to fetch vault data via RPC fallback', error); + return null; + } +} diff --git a/src/hooks/useVaultV2Data.ts b/src/hooks/useVaultV2Data.ts index f460912e..d83e22c9 100644 --- a/src/hooks/useVaultV2Data.ts +++ b/src/hooks/useVaultV2Data.ts @@ -2,8 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Address, formatUnits } from 'viem'; import { useTokens } from '@/components/providers/TokenProvider'; import { fetchVaultV2 } from '@/data-sources/morpho-api/vaults'; -import { getSlicedAddress } from '@/utils/address'; +import { fetchVaultV2ViaRpc } from '@/data-sources/rpc/vaults'; import { formatReadable } from '@/utils/balance'; +import { getSlicedAddress } from '@/utils/address'; +import { SupportedNetworks } from '@/utils/networks'; const normalize = (value?: string | null) => value?.toLowerCase().trim() ?? undefined; @@ -75,6 +77,7 @@ export function useVaultV2Data({ assetDecimals?: number; allocatorAddresses: string[]; curatorAddress?: string; + ownerAddress?: string; } | null): VaultV2ComputedData => { const assetAddress = item?.assetAddress; const token = assetAddress ? findToken(assetAddress, chainId) : undefined; @@ -102,7 +105,7 @@ export function useVaultV2Data({ const allocatorAddresses = Array.from(new Set(normalizedAllocators)); - const owner = normalizedOwner; + const owner = item?.ownerAddress ? normalize(item.ownerAddress) : normalizedOwner; const curator = item?.curatorAddress ?? undefined; const curatorDisplay = curator ? getSlicedAddress(curator as `0x${string}`) : '--'; @@ -161,16 +164,39 @@ export function useVaultV2Data({ const result = await fetchVaultV2({ address: vaultAddress, chainId }); - if (!result) { + if (result) { setData( buildData({ - name: onChainName ?? undefined, - symbol: onChainSymbol ?? undefined, - totalSupply: undefined, - assetAddress: undefined, - assetDecimals: undefined, + name: result.name ?? undefined, + symbol: result.symbol ?? undefined, + totalSupply: result.totalSupply ?? undefined, + assetAddress: normalize(result.asset?.id), + assetDecimals: result.asset?.decimals ?? undefined, + allocatorAddresses: (result.allocators ?? []) + .map((entry) => normalize(entry?.allocator?.address)) + .filter((addr): addr is string => Boolean(addr)), + curatorAddress: normalize(result.curator?.address), + }), + ); + return; + } + + const rpcFallback = await fetchVaultV2ViaRpc({ + address: vaultAddress, + chainId: chainId as SupportedNetworks, + }); + + if (rpcFallback) { + setData( + buildData({ + name: rpcFallback.name, + symbol: rpcFallback.symbol, + totalSupply: rpcFallback.totalSupply?.toString(), + assetAddress: normalize(rpcFallback.assetAddress), + assetDecimals: rpcFallback.assetDecimals, allocatorAddresses: defaultAllocators, - curatorAddress: undefined, + curatorAddress: normalize(rpcFallback.curator), + ownerAddress: rpcFallback.owner, }), ); return; @@ -178,19 +204,41 @@ export function useVaultV2Data({ setData( buildData({ - name: result.name ?? undefined, - symbol: result.symbol ?? undefined, - totalSupply: result.totalSupply ?? undefined, - assetAddress: normalize(result.asset?.id), - assetDecimals: result.asset?.decimals ?? undefined, - allocatorAddresses: (result.allocators ?? []) - .map((entry) => normalize(entry?.allocator?.address)) - .filter((addr): addr is string => Boolean(addr)), - curatorAddress: normalize(result.curator?.address), + name: onChainName ?? undefined, + symbol: onChainSymbol ?? undefined, + totalSupply: undefined, + assetAddress: undefined, + assetDecimals: undefined, + allocatorAddresses: defaultAllocators, + curatorAddress: undefined, }), ); } catch (err) { setError(err instanceof Error ? err : new Error('Failed to fetch vault data')); + + if (vaultAddress) { + const rpcFallback = await fetchVaultV2ViaRpc({ + address: vaultAddress, + chainId: chainId as SupportedNetworks, + }); + + if (rpcFallback) { + setData( + buildData({ + name: rpcFallback.name, + symbol: rpcFallback.symbol, + totalSupply: rpcFallback.totalSupply?.toString(), + assetAddress: normalize(rpcFallback.assetAddress), + assetDecimals: rpcFallback.assetDecimals, + allocatorAddresses: defaultAllocators, + curatorAddress: normalize(rpcFallback.curator), + ownerAddress: rpcFallback.owner, + }), + ); + return; + } + } + setData( buildData({ name: onChainName ?? undefined, From f8bb418dcab7ac5e43865211b121572d8f0ab46a Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 9 Oct 2025 17:46:39 +0800 Subject: [PATCH 09/29] feat: table --- .claude/settings.local.json | 7 +- .../components/VaultInitializationModal.tsx | 156 +++- .../components/VaultSettingsModal.tsx | 338 ++------ .../components/settings/AgentsTab.tsx | 268 +++++++ .../components/settings/AllocationsTab.tsx | 313 ++++++++ .../components/settings/GeneralTab.tsx | 132 ++++ .../components/settings/index.ts | 4 + .../components/settings/types.ts | 43 + .../[chainId]/[vaultAddress]/content.tsx | 159 ++-- .../[chainId]/[vaultAddress]/page.tsx | 4 +- src/components/common/AddressDisplay.tsx | 2 +- src/components/common/AllocatorCard.tsx | 59 ++ .../common/MarketCapInputCompact.tsx | 191 +++++ src/components/common/MarketCapTable.tsx | 274 +++++++ src/components/common/MarketSelector.tsx | 70 ++ .../common/MarketsTableWithSameLoanAsset.tsx | 738 ++++++++++++++++++ src/components/common/PendingMarketCap.tsx | 130 +++ src/data-sources/morpho-api/vaults.ts | 67 -- src/data-sources/rpc/vaults.ts | 116 --- src/data-sources/subgraph/v2-vaults.ts | 100 ++- src/graphql/morpho-v2-subgraph-queries.ts | 28 + src/hooks/useVaultV2.ts | 215 ++++- src/hooks/useVaultV2Data.ts | 247 +----- src/utils/monarch-agent.ts | 10 +- 24 files changed, 2908 insertions(+), 763 deletions(-) create mode 100644 app/autovault/[chainId]/[vaultAddress]/components/settings/AgentsTab.tsx create mode 100644 app/autovault/[chainId]/[vaultAddress]/components/settings/AllocationsTab.tsx create mode 100644 app/autovault/[chainId]/[vaultAddress]/components/settings/GeneralTab.tsx create mode 100644 app/autovault/[chainId]/[vaultAddress]/components/settings/index.ts create mode 100644 app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts create mode 100644 src/components/common/AllocatorCard.tsx create mode 100644 src/components/common/MarketCapInputCompact.tsx create mode 100644 src/components/common/MarketCapTable.tsx create mode 100644 src/components/common/MarketSelector.tsx create mode 100644 src/components/common/MarketsTableWithSameLoanAsset.tsx create mode 100644 src/components/common/PendingMarketCap.tsx delete mode 100644 src/data-sources/morpho-api/vaults.ts delete mode 100644 src/data-sources/rpc/vaults.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 542a19ba..6b39303e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -19,8 +19,11 @@ "WebFetch(domain:www.heroui.com)", "WebSearch", "Bash(pnpm generate:chainlink:*)", - "Bash(pnpm lint:check:*)" + "Bash(pnpm lint:check:*)", + "Bash(pnpm exec tsc:*)", + "Bash(npx eslint:*)", + "Bash(git rm:*)" ], "deny": [] } -} \ No newline at end of file +} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultInitializationModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultInitializationModal.tsx index d1a629bf..8a4e1390 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultInitializationModal.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultInitializationModal.tsx @@ -3,10 +3,12 @@ import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@herou import { Address, zeroAddress } from 'viem'; import { Button } from '@/components/common'; import { AddressDisplay } from '@/components/common/AddressDisplay'; +import { AllocatorCard } from '@/components/common/AllocatorCard'; import { Spinner } from '@/components/common/Spinner'; import { useDeployMorphoMarketV1Adapter } from '@/hooks/useDeployMorphoMarketV1Adapter'; import { useMorphoMarketV1Adapters } from '@/hooks/useMorphoMarketV1Adapters'; import { useVaultV2 } from '@/hooks/useVaultV2'; +import { v2AgentsBase } from '@/utils/monarch-agent'; import { getMorphoAddress } from '@/utils/morpho'; import { SupportedNetworks, getNetworkConfig } from '@/utils/networks'; @@ -14,7 +16,7 @@ const ZERO_ADDRESS = zeroAddress; const shortenAddress = (value: Address | string) => value === ZERO_ADDRESS ? '0x0000…0000' : `${value.slice(0, 6)}…${value.slice(-4)}`; -const STEP_SEQUENCE = ['deploy', 'finalize'] as const; +const STEP_SEQUENCE = ['deploy', 'finalize', 'agents'] as const; type StepId = (typeof STEP_SEQUENCE)[number]; function StepIndicator({ currentStep }: { currentStep: StepId }) { @@ -68,21 +70,21 @@ function DeployAdapterStep({ function FinalizeSetupStep({ adapter, registryAddress, - isFinalizing, + isInitializing, }: { adapter: Address; registryAddress: Address; - isFinalizing: boolean; + isInitializing: boolean; }) { const adapterIsReady = adapter !== ZERO_ADDRESS; return (
- {isFinalizing && } + {isInitializing && } - Finalize setup to link the vault to the adapter and commit to the Morpho registry. This permanently - opts the vault into Morpho-approved adapters. + Link the vault to the adapter and commit to the Morpho registry. This permanently opts + the vault into Morpho-approved adapters.
@@ -101,13 +103,48 @@ function FinalizeSetupStep({
  • Only Morpho-approved adapters can be enabled after this step.
  • Registry configuration is abdicated and cannot be reversed.
  • -
  • This step also registers the adapter on the vault.
); } +function AgentSelectionStep({ + selectedAgent, + onSelectAgent, +}: { + selectedAgent: Address | null; + onSelectAgent: (agent: Address | null) => void; +}) { + return ( +
+

+ Choose an agent to automate your vault's allocations. You can change this later in settings. +

+
+ {v2AgentsBase.map((agent) => ( + + onSelectAgent( + selectedAgent === (agent.address as Address) ? null : (agent.address as Address), + ) + } + /> + ))} +
+

+ 💡 Tip: Agents help maximize returns by rebalancing between markets. You can skip this and + configure later. +

+
+ ); +} + export function VaultInitializationModal({ isOpen, onClose, @@ -121,9 +158,9 @@ export function VaultInitializationModal({ chainId: SupportedNetworks; onAdapterConfigured: () => void; }) { - const [stepIndex, setStepIndex] = useState(0); const [statusVisible, setStatusVisible] = useState(false); + const [selectedAgent, setSelectedAgent] = useState
(null); const currentStep = STEP_SEQUENCE[stepIndex]; const morphoAddress = useMemo(() => getMorphoAddress(chainId), [chainId]); @@ -141,8 +178,8 @@ export function VaultInitializationModal({ const { adapter: onChainAdapter, refetch: refetchVault, - finalizeSetup, - isFinalizing, + completeInitialization, + isInitializing, } = useVaultV2({ vaultAddress, chainId, @@ -174,11 +211,15 @@ export function VaultInitializationModal({ onAdapterConfigured(); }, [onAdapterConfigured, refetchVault]); - const handleFinalize = useCallback(async () => { + const handleCompleteInitialization = useCallback(async () => { if (unifiedAdapter === ZERO_ADDRESS || registryAddress === ZERO_ADDRESS) return; try { - const success = await finalizeSetup(registryAddress, unifiedAdapter); + const success = await completeInitialization( + registryAddress, + unifiedAdapter, + selectedAgent ?? undefined, + ); if (!success) { return; } @@ -187,14 +228,23 @@ export function VaultInitializationModal({ onAdapterConfigured(); onClose(); } catch (error) { - console.error('Failed to finalize setup', error); + console.error('Failed to complete initialization', error); } - }, [finalizeSetup, onAdapterConfigured, onClose, refetchVault, registryAddress, unifiedAdapter]); + }, [ + completeInitialization, + onAdapterConfigured, + onClose, + refetchVault, + registryAddress, + selectedAgent, + unifiedAdapter, + ]); useEffect(() => { if (!isOpen) { setStepIndex(0); setStatusVisible(false); + setSelectedAgent(null); } }, [isOpen]); @@ -210,16 +260,20 @@ export function VaultInitializationModal({ case 'deploy': return 'Deploy Morpho Market adapter'; case 'finalize': - return 'Finalize setup'; + return 'Configure vault registry'; + case 'agents': + return 'Choose an agent (optional)'; default: return ''; } }, [currentStep]); - const canFinalize = adapterDetected && registryAddress !== ZERO_ADDRESS; + const canProceedToAgents = adapterDetected && registryAddress !== ZERO_ADDRESS; const showLoading = statusVisible && (isDeploying || adaptersLoading); - const showBackButton = stepIndex > 0; + const showBackButton = stepIndex > 0 && stepIndex < 2; + const renderCta = () => { + // Step 0: Deploy adapter if (stepIndex === 0) { return ( + ); + } + + // Step 2: Agent selection -> complete with optional agent return ( - + <> + + + ); }; @@ -274,7 +355,11 @@ export function VaultInitializationModal({

{stepTitle}

-

Initialize this vault once before configuring strategies.

+

+ {stepIndex < 2 + ? 'Complete these steps to activate your vault.' + : 'Optionally choose an agent now, or configure later in settings.'} +

@@ -290,9 +375,12 @@ export function VaultInitializationModal({ )} + {currentStep === 'agents' && ( + + )} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx index b010555e..ae964d15 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx @@ -1,13 +1,10 @@ -import { useCallback, useEffect, useMemo, useRef, useState, useId } from 'react'; -import { Address } from 'viem'; -import { Input } from '@heroui/react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { LuX } from 'react-icons/lu'; -import { Button } from '@/components/common/Button'; -import { AddressDisplay } from '@/components/common/AddressDisplay'; -import { Spinner } from '@/components/common/Spinner'; - -type SettingsTab = 'general' | 'agents' | 'allocations'; +import { Address } from 'viem'; +import { VaultV2Cap } from '@/data-sources/subgraph/v2-vaults'; +import { SupportedNetworks } from '@/utils/networks'; +import { GeneralTab, AgentsTab, AllocationsTab, SettingsTab } from './settings'; const TABS: { id: SettingsTab; label: string }[] = [ { id: 'general', label: 'General' }, @@ -26,10 +23,17 @@ type VaultSettingsModalProps = { defaultSymbol: string; currentName: string; currentSymbol: string; - ownerAddress?: string; - curatorAddress?: string; - allocatorAddresses: string[]; - guardianAddresses?: string[]; + owner?: string; + curator?: string; + allocators: string[]; + sentinels?: string[]; + chainId: SupportedNetworks; + vaultAsset?: Address; + existingCaps?: VaultV2Cap[]; + onSetAllocator: (allocator: Address, isAllocator: boolean) => Promise; + onUpdateCaps: (caps: VaultV2Cap[]) => Promise; + isUpdatingAllocator: boolean; + isUpdatingCaps: boolean; }; export function VaultSettingsModal({ @@ -43,22 +47,23 @@ export function VaultSettingsModal({ defaultSymbol, currentName, currentSymbol, - ownerAddress, - curatorAddress, - allocatorAddresses, - guardianAddresses = [], + owner, + curator, + allocators, + sentinels = [], + chainId, + vaultAsset, + existingCaps = [], + onSetAllocator, + onUpdateCaps, + isUpdatingAllocator, + isUpdatingCaps, }: VaultSettingsModalProps) { const [activeTab, setActiveTab] = useState(initialTab); - const nameInputId = useId(); - const symbolInputId = useId(); - const previousName = useMemo(() => currentName.trim(), [currentName]); - const previousSymbol = useMemo(() => currentSymbol.trim(), [currentSymbol]); - const [nameInput, setNameInput] = useState(previousName || defaultName); - const [symbolInput, setSymbolInput] = useState(previousSymbol || defaultSymbol); - const [metadataError, setMetadataError] = useState(null); - + const [mounted, setMounted] = useState(false); const wasOpenRef = useRef(false); + // Reset to initial tab when modal opens useEffect(() => { const wasOpen = wasOpenRef.current; @@ -66,240 +71,15 @@ export function VaultSettingsModal({ setActiveTab(initialTab); } - if (!isOpen && wasOpen) { - setMetadataError(null); - setNameInput(previousName || defaultName); - setSymbolInput(previousSymbol || defaultSymbol); - } - wasOpenRef.current = isOpen; - }, [defaultName, defaultSymbol, initialTab, isOpen, previousName, previousSymbol]); - - const handleTabChange = useCallback((tab: SettingsTab) => { - setActiveTab(tab); - }, []); - - const trimmedName = nameInput.trim(); - const trimmedSymbol = symbolInput.trim(); - const metadataChanged = useMemo(() => { - const hasNewName = trimmedName !== previousName; - const hasNewSymbol = trimmedSymbol !== previousSymbol; - return hasNewName || hasNewSymbol; - }, [previousName, previousSymbol, trimmedName, trimmedSymbol]); - - const renderSingleRole = ( - label: string, - description: string, - addressValue?: string, - ) => { - const normalized = addressValue ? (addressValue as Address) : undefined; - - return ( -
-
-

{label}

-

{description}

-
- {normalized ? ( - - ) : ( - Not assigned - )} -
- ); - }; - - const renderRoleList = ( - label: string, - description: string, - addresses: string[], - emptyLabel: string, - ) => { - if (!addresses.length) { - return ( -
-
-

{label}

-

{description}

-
- {emptyLabel} -
- ); - } - - return ( -
-
-

{label}

-

{description}

-
-
- {addresses.map((entry) => ( - - ))} -
-
- ); - }; - - useEffect(() => { - if (metadataError && metadataChanged) { - setMetadataError(null); - } - }, [metadataChanged, metadataError]); - - const handleMetadataSubmit = useCallback(async () => { - if (!metadataChanged) { - setMetadataError('No changes detected.'); - return; - } - - setMetadataError(null); - - const success = await onUpdateMetadata({ - name: trimmedName !== previousName ? trimmedName || undefined : undefined, - symbol: trimmedSymbol !== previousSymbol ? trimmedSymbol || undefined : undefined, - }); - - if (success) { - setMetadataError(null); - } - }, [metadataChanged, onUpdateMetadata, previousName, previousSymbol, trimmedName, trimmedSymbol]); - - const renderGeneralTab = () => ( -
-
-
- - setNameInput(event.target.value)} - placeholder={defaultName} - isDisabled={!isOwner} - id={nameInputId} - classNames={{ - input: 'text-sm', - inputWrapper: - 'bg-hovered/60 border-transparent shadow-none focus-within:border-transparent focus-within:bg-hovered/80', - }} - /> -
-
- - setSymbolInput(event.target.value)} - placeholder={defaultSymbol} - maxLength={16} - isDisabled={!isOwner} - id={symbolInputId} - classNames={{ - input: 'text-sm', - inputWrapper: - 'bg-hovered/60 border-transparent shadow-none focus-within:border-transparent focus-within:bg-hovered/80', - }} - /> -
- - {metadataError &&

{metadataError}

} - - -
-
- ); - - const renderAgentTab = () => ( -
- {renderSingleRole('Owner', 'Primary controller of vault permissions.', ownerAddress)} - {renderSingleRole('Curator', 'Defines risk guardrails for automation.', curatorAddress)} - {renderRoleList( - 'Allocators', - 'Automation agents executing the configured strategy.', - allocatorAddresses, - 'No allocators assigned', - )} - {renderRoleList( - 'Guardians', - 'Sentinels able to pause automation when safeguards trigger.', - guardianAddresses, - 'No guardians configured', - )} -
- ); - - const renderAllocationsTab = () => ( -
-
-

Allocation caps

-

Configure market-level caps and guardrails for the automation agent.

-
-
- Allocation management coming soon. You’ll be able to set per-market caps and minimum cash buffers here. -
-
- ); - - const renderActiveTab = () => { - switch (activeTab) { - case 'general': - return renderGeneralTab(); - case 'agents': - return renderAgentTab(); - case 'allocations': - return renderAllocationsTab(); - default: - return null; - } - }; - - const [mounted, setMounted] = useState(false); + }, [initialTab, isOpen]); + // Handle mounting useEffect(() => { setMounted(true); }, []); - useEffect(() => { - if (isOpen) { - setNameInput(previousName || defaultName); - setSymbolInput(previousSymbol || defaultSymbol); - } - }, [defaultName, defaultSymbol, isOpen, previousName, previousSymbol]); - + // Prevent body scroll when modal is open useEffect(() => { if (!isOpen) return; const originalOverflow = document.body.style.overflow; @@ -309,6 +89,7 @@ export function VaultSettingsModal({ }; }, [isOpen]); + // Handle ESC key useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape' && isOpen) { @@ -325,6 +106,53 @@ export function VaultSettingsModal({ }; }, [isOpen, onClose]); + const handleTabChange = useCallback((tab: SettingsTab) => { + setActiveTab(tab); + }, []); + + const renderActiveTab = () => { + switch (activeTab) { + case 'general': + return ( + + ); + case 'agents': + return ( + + ); + case 'allocations': + return ( + + ); + default: + return null; + } + }; + if (!mounted || !isOpen) { return null; } @@ -335,10 +163,11 @@ export function VaultSettingsModal({ onMouseDown={onClose} >
event.stopPropagation()} >
+ {/* Header */}

Vault Settings

+ {/* Content */}
+ {/* Sidebar */} + {/* Tab Content */}
{renderActiveTab()}
diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentsTab.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentsTab.tsx new file mode 100644 index 00000000..35f2b72f --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentsTab.tsx @@ -0,0 +1,268 @@ +import { useCallback, useState } from 'react'; +import { Address } from 'viem'; +import { AddressDisplay } from '@/components/common/AddressDisplay'; +import { Button } from '@/components/common/Button'; +import { Spinner } from '@/components/common/Spinner'; +import { v2AgentsBase } from '@/utils/monarch-agent'; +import { AgentsTabProps } from './types'; + +export function AgentsTab({ + isOwner, + owner, + curator, + allocators, + sentinels = [], + onSetAllocator, + isUpdatingAllocator, +}: AgentsTabProps) { + const [allocatorToAdd, setAllocatorToAdd] = useState
(null); + const [allocatorToRemove, setAllocatorToRemove] = useState
(null); + const [isEditingAllocators, setIsEditingAllocators] = useState(false); + + const handleAddAllocator = useCallback( + async (allocator: Address) => { + setAllocatorToAdd(allocator); + const success = await onSetAllocator(allocator, true); + if (success) { + setAllocatorToAdd(null); + } + }, + [onSetAllocator], + ); + + const handleRemoveAllocator = useCallback( + async (allocator: Address) => { + setAllocatorToRemove(allocator); + const success = await onSetAllocator(allocator, false); + if (success) { + setAllocatorToRemove(null); + } + }, + [onSetAllocator], + ); + + const renderSingleRole = ( + label: string, + description: string, + addressValue?: string, + ) => { + const normalized = addressValue ? (addressValue as Address) : undefined; + + return ( +
+
+

{label}

+

{description}

+
+ {normalized ? ( + + ) : ( + Not assigned + )} +
+ ); + }; + + const renderRoleList = ( + label: string, + description: string, + addresses: string[], + emptyLabel: string, + ) => { + if (!addresses.length) { + return ( +
+
+

{label}

+

{description}

+
+ {emptyLabel} +
+ ); + } + + return ( +
+
+

{label}

+

{description}

+
+
+ {addresses.map((entry) => ( + + ))} +
+
+ ); + }; + + const currentAllocatorAddresses = allocators.map((a) => a.toLowerCase()); + const availableAllocators = v2AgentsBase.filter( + (agent) => !currentAllocatorAddresses.includes(agent.address.toLowerCase()), + ); + + return ( +
+ {renderSingleRole('Owner', 'Primary controller of vault permissions.', owner)} + {renderSingleRole('Curator', 'Defines risk guardrails for automation.', curator)} + +
+
+
+

Allocators

+

+ Automation agents executing the configured strategy. +

+
+ {!isEditingAllocators && ( + + )} +
+ + {!isEditingAllocators ? ( + // Read-only view + allocators.length === 0 ? ( +

No allocators assigned

+ ) : ( +
+ {allocators.map((address) => { + const agent = v2AgentsBase.find((a) => a.address.toLowerCase() === address.toLowerCase()); + return ( +
+ {agent ? ( +
+ {agent.name} + +
+ ) : ( + + )} +
+ ); + })} +
+ ) + ) : ( + // Edit mode +
+ {allocators.length > 0 && ( +
+

Current Allocators

+ {allocators.map((address) => { + const agent = v2AgentsBase.find((a) => a.address.toLowerCase() === address.toLowerCase()); + return ( +
+
+ {agent ? ( + <> + {agent.name} + + + ) : ( + + )} +
+ +
+ ); + })} +
+ )} + + {availableAllocators.length > 0 && ( +
+

+ {allocators.length > 0 ? 'Available to Add' : 'Select Allocator'} +

+ {availableAllocators.map((agent) => ( +
+
+ {agent.name} + +

{agent.strategyDescription}

+
+ +
+ ))} +
+ )} + +
+ +
+
+ )} +
+ + {renderRoleList( + 'Sentinels', + 'Sentinels able to pause automation when safeguards trigger.', + sentinels, + 'No sentinels configured', + )} +
+ ); +} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/AllocationsTab.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/AllocationsTab.tsx new file mode 100644 index 00000000..9ac082e3 --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/AllocationsTab.tsx @@ -0,0 +1,313 @@ +import { useCallback, useEffect, useState } from 'react'; +import { formatUnits, parseUnits } from 'viem'; +import { Button } from '@/components/common/Button'; +import { MarketsTableWithSameLoanAsset } from '@/components/common/MarketsTableWithSameLoanAsset'; +import { Spinner } from '@/components/common/Spinner'; +import { TokenIcon } from '@/components/TokenIcon'; +import { VaultV2Cap } from '@/data-sources/subgraph/v2-vaults'; +import { useMarkets } from '@/hooks/useMarkets'; +import { AllocationsTabProps, MarketCapState } from './types'; + +export function AllocationsTab({ + isOwner, + chainId, + vaultAsset, + existingCaps, + onUpdateCaps, + isUpdatingCaps, +}: AllocationsTabProps) { + const [marketCaps, setMarketCaps] = useState([]); + const [isEditingCaps, setIsEditingCaps] = useState(false); + const { markets, loading: marketsLoading } = useMarkets(); + + // Initialize market caps from existing data + useEffect(() => { + if (!markets || !vaultAsset) return; + + // Don't re-initialize while editing + if (isEditingCaps) return; + + const filteredMarkets = markets.filter( + (m) => + m.loanAsset.address.toLowerCase() === vaultAsset.toLowerCase() && + m.morphoBlue.chain.id === chainId, + ); + + setMarketCaps( + filteredMarkets.map((market) => { + const existingCap = existingCaps.find((c) => c.marketId === market.uniqueKey); + return { + market, + relativeCap: existingCap + ? (parseFloat(existingCap.relativeCap) / 1e16).toString() + : '', + absoluteCap: existingCap + ? ( + parseFloat( + ((BigInt(existingCap.absoluteCap) * 10000n) / + BigInt(10 ** market.loanAsset.decimals)).toString(), + ) / 10000 + ).toString() + : '', + isSelected: !!existingCap, + }; + }), + ); + }, [markets, vaultAsset, chainId, isEditingCaps, existingCaps]); + + const handleToggleMarket = useCallback((marketId: string) => { + setMarketCaps((prev) => + prev.map((c) => { + if (c.market.uniqueKey === marketId) { + const newIsSelected = !c.isSelected; + return { + ...c, + isSelected: newIsSelected, + relativeCap: newIsSelected && !c.relativeCap ? '100' : c.relativeCap, + }; + } + return c; + }), + ); + }, []); + + const handleUpdateCapField = useCallback( + (marketId: string, field: 'relativeCap' | 'absoluteCap', value: string) => { + setMarketCaps((prev) => + prev.map((c) => (c.market.uniqueKey === marketId ? { ...c, [field]: value } : c)), + ); + }, + [], + ); + + const handleSaveCaps = useCallback(async () => { + const capsToUpdate = marketCaps + .filter((c) => c.isSelected) + .map((c) => { + const relativeCapBigInt = + c.relativeCap && parseFloat(c.relativeCap) > 0 + ? parseUnits(c.relativeCap, 16) + : 0n; + + const absoluteCapBigInt = + c.absoluteCap && parseFloat(c.absoluteCap) > 0 + ? parseUnits(c.absoluteCap, c.market.loanAsset.decimals) + : 0n; + + return { + marketId: c.market.uniqueKey, + relativeCap: relativeCapBigInt.toString(), + absoluteCap: absoluteCapBigInt.toString(), + } as VaultV2Cap; + }); + + if (capsToUpdate.length === 0) return; + + const success = await onUpdateCaps(capsToUpdate); + if (success) { + setIsEditingCaps(false); + } + }, [marketCaps, onUpdateCaps]); + + if (marketsLoading) { + return ( +
+ +
+ ); + } + + if (marketCaps.length === 0) { + return ( +
+

+ No markets found for this vault's asset. Caps can be configured once markets are + available. +

+
+ ); + } + + const currentCaps = existingCaps; + const hasAnyCaps = currentCaps.length > 0; + + const hasChanges = marketCaps.some((c) => { + const existingCap = existingCaps.find((ec) => ec.marketId === c.market.uniqueKey); + if (c.isSelected !== !!existingCap) return true; + if (c.isSelected) { + const existingRelative = existingCap + ? (parseFloat(existingCap.relativeCap) / 1e16).toString() + : ''; + const existingAbsolute = existingCap + ? ( + parseFloat( + ((BigInt(existingCap.absoluteCap) * 10000n) / + BigInt(10 ** c.market.loanAsset.decimals)).toString(), + ) / 10000 + ).toString() + : ''; + return c.relativeCap !== existingRelative || c.absoluteCap !== existingAbsolute; + } + return false; + }); + + const selectedCount = marketCaps.filter((c) => c.isSelected).length; + + return ( +
+ {/* Header */} +
+
+

Market Caps

+

+ Maximum allocation per market +

+
+ {!isEditingCaps && ( + + )} +
+ + {!isEditingCaps ? ( + // Read-only view - Current caps + !hasAnyCaps ? ( +

No market caps configured

+ ) : ( +
+ {currentCaps.map((cap) => { + const market = marketCaps.find((m) => m.market.uniqueKey === cap.marketId)?.market; + if (!market) return null; + + const relativeCapPercent = (parseFloat(cap.relativeCap) / 1e16).toFixed(2); + + return ( +
+
+
+
+ +
+
+ +
+
+
+
+ + {market.loanAsset.symbol} / {market.collateralAsset.symbol} + + + {formatUnits(BigInt(market.lltv), 16)}% LTV + +
+
+
+
+

{relativeCapPercent}%

+

max

+
+
+ ); + })} +
+ ) + ) : ( + // Edit mode - Market selection +
+

+ Select markets and set caps. Total can exceed 100%. +

+ + ({ + market: c.market, + isSelected: c.isSelected, + }))} + onToggleMarket={handleToggleMarket} + disabled={!isOwner} + renderCartItemExtra={(market) => { + const capState = marketCaps.find((c) => c.market.uniqueKey === market.uniqueKey); + if (!capState) return null; + + return ( +
+ { + const value = e.target.value; + if (value === '' || /^\d*\.?\d*$/.test(value)) { + const numValue = parseFloat(value); + if (value === '' || (numValue >= 0 && numValue <= 100)) { + handleUpdateCapField(market.uniqueKey, 'relativeCap', value); + } + } + }} + placeholder="100" + disabled={!isOwner} + className="w-16 rounded border border-gray-200 bg-background px-2 py-1 text-right text-sm focus:border-primary focus:outline-none dark:border-gray-700" + /> + % max +
+ ); + }} + /> + + {/* Action buttons */} +
+
+ {selectedCount} market{selectedCount !== 1 ? 's' : ''} selected +
+
+ + +
+
+
+ )} +
+ ); +} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/GeneralTab.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/GeneralTab.tsx new file mode 100644 index 00000000..c68831aa --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/GeneralTab.tsx @@ -0,0 +1,132 @@ +import { useCallback, useEffect, useId, useMemo, useState } from 'react'; +import { Input } from '@heroui/react'; +import { Button } from '@/components/common/Button'; +import { Spinner } from '@/components/common/Spinner'; +import { GeneralTabProps } from './types'; + +export function GeneralTab({ + isOwner, + defaultName, + defaultSymbol, + currentName, + currentSymbol, + onUpdateMetadata, + updatingMetadata, +}: GeneralTabProps) { + const nameInputId = useId(); + const symbolInputId = useId(); + + const previousName = useMemo(() => currentName.trim(), [currentName]); + const previousSymbol = useMemo(() => currentSymbol.trim(), [currentSymbol]); + + const [nameInput, setNameInput] = useState(previousName || defaultName); + const [symbolInput, setSymbolInput] = useState(previousSymbol || defaultSymbol); + const [metadataError, setMetadataError] = useState(null); + + // Reset inputs when current values change + useEffect(() => { + setNameInput(previousName || defaultName); + setSymbolInput(previousSymbol || defaultSymbol); + }, [previousName, previousSymbol, defaultName, defaultSymbol]); + + const trimmedName = nameInput.trim(); + const trimmedSymbol = symbolInput.trim(); + + const metadataChanged = useMemo(() => { + const hasNewName = trimmedName !== previousName; + const hasNewSymbol = trimmedSymbol !== previousSymbol; + return hasNewName || hasNewSymbol; + }, [previousName, previousSymbol, trimmedName, trimmedSymbol]); + + // Clear error when inputs change + useEffect(() => { + if (metadataError && metadataChanged) { + setMetadataError(null); + } + }, [metadataChanged, metadataError]); + + const handleMetadataSubmit = useCallback(async () => { + if (!metadataChanged) { + setMetadataError('No changes detected.'); + return; + } + + setMetadataError(null); + + const success = await onUpdateMetadata({ + name: trimmedName !== previousName ? trimmedName || undefined : undefined, + symbol: trimmedSymbol !== previousSymbol ? trimmedSymbol || undefined : undefined, + }); + + if (success) { + setMetadataError(null); + } + }, [metadataChanged, onUpdateMetadata, previousName, previousSymbol, trimmedName, trimmedSymbol]); + + return ( +
+
+
+ + setNameInput(event.target.value)} + placeholder={defaultName} + isDisabled={!isOwner} + id={nameInputId} + classNames={{ + input: 'text-sm', + inputWrapper: + 'bg-hovered/60 border-transparent shadow-none focus-within:border-transparent focus-within:bg-hovered/80', + }} + /> +
+ +
+ + setSymbolInput(event.target.value)} + placeholder={defaultSymbol} + maxLength={16} + isDisabled={!isOwner} + id={symbolInputId} + classNames={{ + input: 'text-sm', + inputWrapper: + 'bg-hovered/60 border-transparent shadow-none focus-within:border-transparent focus-within:bg-hovered/80', + }} + /> +
+ + {metadataError &&

{metadataError}

} + + +
+
+ ); +} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/index.ts b/app/autovault/[chainId]/[vaultAddress]/components/settings/index.ts new file mode 100644 index 00000000..aeca8ffc --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/index.ts @@ -0,0 +1,4 @@ +export { GeneralTab } from './GeneralTab'; +export { AgentsTab } from './AgentsTab'; +export { AllocationsTab } from './AllocationsTab'; +export * from './types'; diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts b/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts new file mode 100644 index 00000000..3733980e --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts @@ -0,0 +1,43 @@ +import { Address } from 'viem'; +import { VaultV2Cap } from '@/data-sources/subgraph/v2-vaults'; +import { SupportedNetworks } from '@/utils/networks'; +import { Market } from '@/utils/types'; + +export type SettingsTab = 'general' | 'agents' | 'allocations'; + +export type MarketCapState = { + market: Market; + relativeCap: string; + absoluteCap: string; + isSelected: boolean; +}; + +export type GeneralTabProps = { + isOwner: boolean; + defaultName: string; + defaultSymbol: string; + currentName: string; + currentSymbol: string; + onUpdateMetadata: (values: { name?: string; symbol?: string }) => Promise; + updatingMetadata: boolean; +}; + +export type AgentsTabProps = { + isOwner: boolean; + owner?: string; + curator?: string; + allocators: string[]; + sentinels?: string[]; + onSetAllocator: (allocator: Address, isAllocator: boolean) => Promise; + isUpdatingAllocator: boolean; +}; + +export type AllocationsTabProps = { + isOwner: boolean; + chainId: SupportedNetworks; + vaultAsset?: Address; + existingCaps: VaultV2Cap[]; + onUpdateCaps: (caps: VaultV2Cap[]) => Promise; + isUpdatingCaps: boolean; + isOpen: boolean; +}; diff --git a/app/autovault/[chainId]/[vaultAddress]/content.tsx b/app/autovault/[chainId]/[vaultAddress]/content.tsx index b6cee030..87b57a08 100644 --- a/app/autovault/[chainId]/[vaultAddress]/content.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/content.tsx @@ -11,7 +11,7 @@ import { AddressDisplay } from '@/components/common/AddressDisplay'; import Header from '@/components/layout/header/Header'; import LoadingScreen from '@/components/Status/LoadingScreen'; import { TokenIcon } from '@/components/TokenIcon'; -import { VaultAllocation, useVaultDetails } from '@/hooks/useAutovaultData'; +// Removed useVaultDetails (was mock data) import { useVaultV2 } from '@/hooks/useVaultV2'; import { useVaultV2Data } from '@/hooks/useVaultV2Data'; import { getSlicedAddress } from '@/utils/address'; @@ -20,7 +20,7 @@ import { ALL_SUPPORTED_NETWORKS, SupportedNetworks, getNetworkConfig } from '@/u import { VaultAgentSummary } from './components/VaultAgentSummary'; import { VaultApyHistory } from './components/VaultApyHistory'; import { VaultInitializationModal } from './components/VaultInitializationModal'; -import { VaultMarketAllocations } from './components/VaultMarketAllocations'; +// Removed VaultMarketAllocations - will be re-added when real data is available import { VaultSettingsModal } from './components/VaultSettingsModal'; import { VaultSummaryMetrics } from './components/VaultSummaryMetrics'; @@ -59,55 +59,63 @@ export default function VaultContent() { isUpdatingMetadata, name: onChainName, symbol: onChainSymbol, + setAllocator, + isUpdatingAllocator, + updateCaps, + isUpdatingCaps, } = useVaultV2({ vaultAddress: vaultAddressValue, chainId: supportedChainId, + onTransactionSuccess: () => void refetchVaultData(), }); const [settingsTab, setSettingsTab] = useState<'general' | 'agents' | 'allocations'>('general'); const [showSettings, setShowSettings] = useState(false); const [showInitializationModal, setShowInitializationModal] = useState(false); - const { vault: vaultDetails, isLoading, isError } = useVaultDetails(vaultAddressValue); - - const isOwner = Boolean( - vaultDetails.owner && connectedAddress && vaultDetails.owner.toLowerCase() === connectedAddress.toLowerCase(), - ); - - const marketAllocations: VaultAllocation[] = vaultDetails.allocations ?? []; - const vaultAssetSymbol = marketAllocations[0]?.assetSymbol ?? '—'; const fallbackTitle = `Vault ${getSlicedAddress(vaultAddressValue)}`; - const { data: vaultData, loading: vaultDataLoading } = useVaultV2Data({ + const { + data: vaultData, + loading: vaultDataLoading, + error: vaultDataError, + refetch: refetchVaultData, + } = useVaultV2Data({ vaultAddress: vaultAddressValue, chainId: supportedChainId, - fallbackName: vaultDetails.name?.trim(), - fallbackSymbol: vaultDetails.symbol?.trim(), - onChainName, - onChainSymbol, - ownerAddress: vaultDetails.owner, - defaultAllocatorAddresses: vaultDetails.agents.map((agent) => agent.id), }); - const isFetchingSummary = isLoading || vaultDataLoading; - const title = vaultData.displayName || fallbackTitle; - const symbolToDisplay = vaultData.displaySymbol; - const allocatorAddresses = vaultData.allocatorAddresses; - const guardianAddresses = vaultData.guardianAddresses; - const allocatorCount = vaultData.allocatorCount; + // Use vaultData for owner check (from subgraph) + const isOwner = Boolean( + vaultData?.owner && connectedAddress && vaultData.owner.toLowerCase() === connectedAddress.toLowerCase(), + ); + + const isFetchingSummary = vaultDataLoading; + const isError = !!vaultDataError; + + const title = vaultData?.displayName ?? fallbackTitle; + const symbolToDisplay = vaultData?.displaySymbol; + const allocators = vaultData?.allocators ?? []; + const sentinels = vaultData?.sentinels ?? []; + const caps = vaultData?.caps ?? []; + const allocatorCount = allocators.length; + const hasNoAllocators = !needsSetup && allocatorCount === 0; + const hasNoCaps = !needsSetup && allocatorCount > 0 && caps.length === 0; + const roleStatusText = useMemo(() => { if (needsSetup) return 'Adapter pending deployment'; - if (!vaultData.curatorAddress) return 'Curator not assigned yet'; - if (allocatorCount === 0) return 'Add an allocator to enable automation'; - return 'All critical roles are assigned to safe wallets.'; - }, [allocatorCount, needsSetup, vaultData.curatorAddress]); + if (hasNoAllocators) return 'Choose agents to enable automation'; + if (hasNoCaps) return 'Set market caps to complete strategy'; + if (!vaultData?.curator) return 'Curator not assigned yet'; + return 'Vault is configured and ready'; + }, [hasNoAllocators, hasNoCaps, needsSetup, vaultData?.curator]); - const assetAddress = vaultData.assetAddress; + const assetAddress = vaultData?.assetAddress; const totalSupplyLabel = useMemo(() => { - if (!vaultData.totalSupplyRaw || vaultData.tokenDecimals === undefined) return '--'; + if (!vaultData?.totalSupply || vaultData?.tokenDecimals === undefined) return '--'; try { - const rawSupply = BigInt(vaultData.totalSupplyRaw); + const rawSupply = BigInt(vaultData.totalSupply); const numericSupply = formatBalance(rawSupply, vaultData.tokenDecimals); const formattedSupply = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2, @@ -117,9 +125,10 @@ export default function VaultContent() { } catch (_error) { return '--'; } - }, [vaultData.tokenDecimals, vaultData.tokenSymbol, vaultData.totalSupplyRaw]); + }, [vaultData?.tokenDecimals, vaultData?.tokenSymbol, vaultData?.totalSupply]); - const apyLabel = vaultDetails.currentApy ? `${vaultDetails.currentApy.toFixed(2)}%` : '0%'; + // TODO: Get real APY from subgraph or calculate from market allocations + const apyLabel = '0%'; if (isError) { return ( @@ -185,9 +194,10 @@ export default function VaultContent() { {needsSetup && networkConfig?.vaultConfig?.marketV1AdapterFactory && (
-

Adapter not configured

+

Complete vault initialization

- Finish the initialization process to begin configuring strategies for this vault. + Deploy adapter, configure registry, and optionally choose an agent to automate + this vault.

+
+ )} + + {hasNoCaps && isOwner && ( +
+
+

Set market caps

+

+ Define caps for markets to complete your vault strategy and activate + automation. +

+
+ +
+ )} +
Total supply @@ -212,7 +267,7 @@ export default function VaultContent() { )}
- {vaultData.tokenSymbol ? `${vaultData.tokenSymbol} vault supply` : 'Vault token supply'} + {vaultData?.tokenSymbol ? `${vaultData.tokenSymbol} vault supply` : 'Vault token supply'}
@@ -230,12 +285,12 @@ export default function VaultContent() { 0 && caps.length > 0} activeAgents={allocatorCount} description={ needsSetup ? 'Deploy the vault adapter before allocating capital.' - : vaultDetails.status === 'active' + : allocatorCount > 0 && caps.length > 0 ? 'Allocators are authorized and rebalancing within curator caps.' : 'Authorize an allocator to resume automated portfolio management.' } @@ -254,10 +309,8 @@ export default function VaultContent() { }} /> - + {/* TODO: Get real market allocations from subgraph */} + {/* */} )} @@ -286,7 +346,10 @@ export default function VaultContent() { onClose={() => setShowInitializationModal(false)} vaultAddress={vaultAddressValue} chainId={supportedChainId} - onAdapterConfigured={() => void refetchAdapter()} + onAdapterConfigured={() => { + void refetchAdapter(); + void refetchVaultData(); + }} /> )}
diff --git a/app/autovault/[chainId]/[vaultAddress]/page.tsx b/app/autovault/[chainId]/[vaultAddress]/page.tsx index 0a91b05b..3a3a902d 100644 --- a/app/autovault/[chainId]/[vaultAddress]/page.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/page.tsx @@ -5,9 +5,9 @@ import VaultContent from './content'; export async function generateMetadata({ params, }: { - params: { chainId: string; vaultAddress: string }; + params: Promise<{ chainId: string; vaultAddress: string }>; }) { - const { chainId, vaultAddress } = params; + const { chainId, vaultAddress } = await params; return buildMetadata({ title: 'Vault Details | Monarch', diff --git a/src/components/common/AddressDisplay.tsx b/src/components/common/AddressDisplay.tsx index dd8a3a91..829d4653 100644 --- a/src/components/common/AddressDisplay.tsx +++ b/src/components/common/AddressDisplay.tsx @@ -8,9 +8,9 @@ import { Address } from 'viem'; import { useAccount } from 'wagmi'; import { Avatar } from '@/components/Avatar/Avatar'; import { Name } from '@/components/common/Name'; +import { useStyledToast } from '@/hooks/useStyledToast'; import { getExplorerURL } from '@/utils/external'; import { SupportedNetworks } from '@/utils/networks'; -import { useStyledToast } from '@/hooks/useStyledToast'; type AddressDisplayProps = { address: Address; diff --git a/src/components/common/AllocatorCard.tsx b/src/components/common/AllocatorCard.tsx new file mode 100644 index 00000000..54c05bd4 --- /dev/null +++ b/src/components/common/AllocatorCard.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { Address } from 'viem'; +import { AddressDisplay } from './AddressDisplay'; + +type AllocatorCardProps = { + name: string; + address: Address; + description: string; + isSelected?: boolean; + onSelect?: () => void; + disabled?: boolean; +}; + +export function AllocatorCard({ + name, + address, + description, + isSelected = false, + onSelect, + disabled = false, +}: AllocatorCardProps): JSX.Element { + return ( + + ); +} diff --git a/src/components/common/MarketCapInputCompact.tsx b/src/components/common/MarketCapInputCompact.tsx new file mode 100644 index 00000000..46656b7c --- /dev/null +++ b/src/components/common/MarketCapInputCompact.tsx @@ -0,0 +1,191 @@ +import React, { useState } from 'react'; +import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons'; +import { motion, AnimatePresence } from 'framer-motion'; +import { formatUnits } from 'viem'; +import { getTruncatedAssetName } from '@/utils/oracle'; +import { Market } from '@/utils/types'; +import OracleVendorBadge from '../OracleVendorBadge'; +import { TokenIcon } from '../TokenIcon'; + +type MarketCapInputCompactProps = { + market: Market; + relativeCap: string; + absoluteCap: string; + onRelativeCapChange: (value: string) => void; + onAbsoluteCapChange: (value: string) => void; + isSelected?: boolean; + onToggle?: () => void; + disabled?: boolean; +}; + +export function MarketCapInputCompact({ + market, + relativeCap, + absoluteCap, + onRelativeCapChange, + onAbsoluteCapChange, + isSelected = false, + onToggle, + disabled = false, +}: MarketCapInputCompactProps): JSX.Element { + const [relativeCapError, setRelativeCapError] = useState(''); + const [isExpanded, setIsExpanded] = useState(false); + + const handleRelativeCapChange = (e: React.ChangeEvent) => { + const value = e.target.value; + + // Allow empty or valid decimal numbers + if (value === '' || /^\d*\.?\d*$/.test(value)) { + onRelativeCapChange(value); + + // Validate percentage (0-100) + if (value !== '') { + const numValue = parseFloat(value); + if (numValue > 100) { + setRelativeCapError('Max 100%'); + } else if (numValue < 0) { + setRelativeCapError('Must be positive'); + } else { + setRelativeCapError(''); + } + } else { + setRelativeCapError(''); + } + } + }; + + const handleAbsoluteCapChange = (e: React.ChangeEvent) => { + const value = e.target.value; + + // Allow empty or valid numbers + if (value === '' || /^\d*\.?\d*$/.test(value)) { + onAbsoluteCapChange(value); + } + }; + + return ( +
+ {/* Compact Header */} +
+
+ +
+
+ +
+
+ +
+
+
+ + {getTruncatedAssetName(market.loanAsset.symbol)} + + + / {getTruncatedAssetName(market.collateralAsset.symbol)} + +
+ {!isExpanded && ( +
+ · + + · + {market.state?.supplyApy ? (market.state.supplyApy * 100).toFixed(2) : '0.00'}% APY + · + {formatUnits(BigInt(market.lltv), 16)}% LTV +
+ )} +
+ + +
+ + {/* Expanded Details with Cap Inputs */} + + {isExpanded && isSelected && ( + +
+
+ + Relative Cap (% of vault) + +
+ + % +
+ {relativeCapError &&

{relativeCapError}

} +
+ +
+ + Absolute Cap ({market.loanAsset.symbol}) + + +
+
+
+ )} +
+
+ ); +} diff --git a/src/components/common/MarketCapTable.tsx b/src/components/common/MarketCapTable.tsx new file mode 100644 index 00000000..45f6bd5a --- /dev/null +++ b/src/components/common/MarketCapTable.tsx @@ -0,0 +1,274 @@ +import React, { useMemo, useState } from 'react'; +import { formatUnits } from 'viem'; +import { formatBalance, formatReadable } from '@/utils/balance'; +import { getTruncatedAssetName } from '@/utils/oracle'; +import { Market } from '@/utils/types'; +import OracleVendorBadge from '../OracleVendorBadge'; +import { TokenIcon } from '../TokenIcon'; + +type MarketCapState = { + market: Market; + relativeCap: string; + absoluteCap: string; + isSelected: boolean; +}; + +type MarketCapTableProps = { + markets: MarketCapState[]; + onToggleMarket: (marketId: string) => void; + onRelativeCapChange: (marketId: string, value: string) => void; + disabled?: boolean; + collateralFilter: string[]; + onCollateralFilterChange: (collaterals: string[]) => void; +}; + +const ITEMS_PER_PAGE = 10; + +export function MarketCapTable({ + markets, + onToggleMarket, + onRelativeCapChange, + disabled = false, + collateralFilter, + onCollateralFilterChange, +}: MarketCapTableProps): JSX.Element { + const [currentPage, setCurrentPage] = useState(1); + const [searchQuery, setSearchQuery] = useState(''); + + // Get unique collaterals for filter + const availableCollaterals = useMemo(() => { + const collaterals = new Set(); + markets.forEach((m) => collaterals.add(m.market.collateralAsset.symbol)); + return Array.from(collaterals).sort(); + }, [markets]); + + // Filter markets + const filteredMarkets = useMemo(() => { + let filtered = markets; + + // Apply collateral filter + if (collateralFilter.length > 0) { + filtered = filtered.filter((m) => + collateralFilter.includes(m.market.collateralAsset.symbol), + ); + } + + // Apply search query + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + filtered = filtered.filter( + (m) => + m.market.collateralAsset.symbol.toLowerCase().includes(query) || + m.market.uniqueKey.toLowerCase().includes(query), + ); + } + + return filtered; + }, [markets, collateralFilter, searchQuery]); + + // Pagination + const totalPages = Math.ceil(filteredMarkets.length / ITEMS_PER_PAGE); + const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; + const paginatedMarkets = filteredMarkets.slice(startIndex, startIndex + ITEMS_PER_PAGE); + + // Reset to page 1 when filters change + React.useEffect(() => { + setCurrentPage(1); + }, [collateralFilter, searchQuery]); + + const handleCapChange = (marketId: string, value: string) => { + // Allow empty or valid decimal numbers + if (value === '' || /^\d*\.?\d*$/.test(value)) { + const numValue = parseFloat(value); + if (value === '' || (numValue >= 0 && numValue <= 100)) { + onRelativeCapChange(marketId, value); + } + } + }; + + return ( +
+ {/* Filters */} +
+ setSearchQuery(e.target.value)} + className="flex-1 rounded border border-gray-200 bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none dark:border-gray-700" + /> + +
+ + {/* Table */} +
+ + + + + + + + + + + + + + {paginatedMarkets.length === 0 ? ( + + + + ) : ( + paginatedMarkets.map((capState) => ( + + + + + + + + + + )) + )} + +
+ + + Collateral + + Oracle + + LTV + + APY + + Liquidity + + Max % +
+ No markets found +
+ onToggleMarket(capState.market.uniqueKey)} + disabled={disabled} + className="h-4 w-4 cursor-pointer rounded border-gray-300 text-primary focus:ring-2 focus:ring-primary dark:border-gray-600" + /> + +
+ + + {getTruncatedAssetName(capState.market.collateralAsset.symbol)} + +
+
+ + + + {formatUnits(BigInt(capState.market.lltv), 16)}% + + + {capState.market.state?.supplyApy + ? `${(capState.market.state.supplyApy * 100).toFixed(2)}%` + : '—'} + + {formatReadable( + formatBalance( + capState.market.state.liquidityAssets, + capState.market.loanAsset.decimals, + ), + )} + + {capState.isSelected ? ( +
+ + handleCapChange(capState.market.uniqueKey, e.target.value) + } + placeholder="100" + disabled={disabled} + className="w-16 rounded border border-gray-200 bg-background px-2 py-1 text-right text-sm focus:border-primary focus:outline-none dark:border-gray-700" + /> + % +
+ ) : ( +
+ )} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+
+ Showing {startIndex + 1}-{Math.min(startIndex + ITEMS_PER_PAGE, filteredMarkets.length)}{' '} + of {filteredMarkets.length} +
+
+ + + Page {currentPage} of {totalPages} + + +
+
+ )} +
+ ); +} diff --git a/src/components/common/MarketSelector.tsx b/src/components/common/MarketSelector.tsx new file mode 100644 index 00000000..2227096c --- /dev/null +++ b/src/components/common/MarketSelector.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { formatUnits } from 'viem'; +import { getTruncatedAssetName } from '@/utils/oracle'; +import { Market } from '@/utils/types'; +import OracleVendorBadge from '../OracleVendorBadge'; +import { TokenIcon } from '../TokenIcon'; + +type MarketSelectorProps = { + market: Market; + onAdd: () => void; + disabled?: boolean; +}; + +export function MarketSelector({ market, onAdd, disabled = false }: MarketSelectorProps): JSX.Element { + return ( + + ); +} diff --git a/src/components/common/MarketsTableWithSameLoanAsset.tsx b/src/components/common/MarketsTableWithSameLoanAsset.tsx new file mode 100644 index 00000000..d7ae61a6 --- /dev/null +++ b/src/components/common/MarketsTableWithSameLoanAsset.tsx @@ -0,0 +1,738 @@ +import React, { useMemo, useState, useRef, useEffect } from 'react'; +import { ArrowDownIcon, ArrowUpIcon, ChevronDownIcon, TrashIcon } from '@radix-ui/react-icons'; +import { motion, AnimatePresence } from 'framer-motion'; +import Image from 'next/image'; +import { IoHelpCircleOutline } from 'react-icons/io5'; +import { LuX } from 'react-icons/lu'; +import { formatUnits } from 'viem'; +import { formatBalance, formatReadable } from '@/utils/balance'; +import { getViemChain } from '@/utils/networks'; +import { getTruncatedAssetName } from '@/utils/oracle'; +import { getOracleType, parsePriceFeedVendors, PriceFeedVendors, OracleVendorIcons } from '@/utils/oracle'; +import { ERC20Token, UnknownERC20Token, infoToKey, findToken } from '@/utils/tokens'; +import { Market } from '@/utils/types'; +import { Pagination } from '../../../app/markets/components/Pagination'; +import { MarketAssetIndicator, MarketOracleIndicator, MarketDebtIndicator } from '../../../app/markets/components/RiskIndicator'; +import OracleVendorBadge from '../OracleVendorBadge'; +import { TokenIcon } from '../TokenIcon'; + +export type MarketWithSelection = { + market: Market; + isSelected: boolean; +}; + +type MarketsTableWithSameLoanAssetProps = { + markets: MarketWithSelection[]; + onToggleMarket: (marketId: string) => void; + disabled?: boolean; + // Optional: Render additional content for selected markets in the cart + renderCartItemExtra?: (market: Market) => React.ReactNode; + // Optional: Pass unique tokens for better filter performance + uniqueCollateralTokens?: ERC20Token[]; +}; + +enum SortColumn { + Collateral = 0, + Oracle = 1, + LLTV = 2, + Supply = 3, + APY = 4, + Liquidity = 5, +} + +const ITEMS_PER_PAGE = 8; + +function HTSortable({ + label, + column, + sortColumn, + sortDirection, + onSort, +}: { + label: string; + column: SortColumn; + sortColumn: SortColumn; + sortDirection: 1 | -1; + onSort: (column: SortColumn) => void; +}) { + const isSorting = sortColumn === column; + return ( + onSort(column)} + > +
+
{label}
+ {isSorting && + (sortDirection === 1 ? : )} +
+ + ); +} + +// Compact Collateral Filter +function CollateralFilter({ + selectedCollaterals, + setSelectedCollaterals, + availableCollaterals, +}: { + selectedCollaterals: string[]; + setSelectedCollaterals: (collaterals: string[]) => void; + availableCollaterals: (ERC20Token | UnknownERC20Token)[]; +}) { + const [query, setQuery] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const toggleDropdown = () => setIsOpen(!isOpen); + + const selectOption = (token: ERC20Token | UnknownERC20Token) => { + const tokenKey = token.networks.map((n) => infoToKey(n.address, n.chain.id)).join('|'); + if (selectedCollaterals.includes(tokenKey)) { + setSelectedCollaterals(selectedCollaterals.filter((c) => c !== tokenKey)); + } else { + setSelectedCollaterals([...selectedCollaterals, tokenKey]); + } + }; + + const clearSelection = () => { + setSelectedCollaterals([]); + setQuery(''); + setIsOpen(false); + }; + + const filteredItems = availableCollaterals.filter((token) => + token.symbol.toLowerCase().includes(query.toLowerCase()), + ); + + return ( +
+
{ + if (e.key === 'Enter' || e.key === ' ') { + toggleDropdown(); + } + }} + aria-haspopup="listbox" + aria-expanded={isOpen} + > +
+ {selectedCollaterals.length > 0 ? ( +
+ {selectedCollaterals.map((key) => { + const token = availableCollaterals.find( + (item) => item.networks.map((n) => infoToKey(n.address, n.chain.id)).join('|') === key, + ); + return token ? ( + token.img ? ( + {token.symbol} + ) : ( +
+ ? +
+ ) + ) : null; + })} +
+ ) : ( + Filter collaterals + )} + + + +
+
+ + {isOpen && ( + + setQuery(e.target.value)} + placeholder="Search..." + className="w-full border-none bg-transparent p-2 text-xs focus:outline-none" + /> +
+
    + {filteredItems.map((token) => { + const tokenKey = token.networks.map((n) => infoToKey(n.address, n.chain.id)).join('|'); + return ( +
  • selectOption(token)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + selectOption(token); + } + }} + role="option" + aria-selected={selectedCollaterals.includes(tokenKey)} + tabIndex={0} + > + + {token.symbol.length > 8 ? `${token.symbol.slice(0, 8)}...` : token.symbol} + + {token.img ? ( + {token.symbol} + ) : ( +
    + ? +
    + )} +
  • + ); + })} +
+
+ +
+
+
+ )} +
+
+ ); +} + +// Compact Oracle Filter +function OracleFilterComponent({ + selectedOracles, + setSelectedOracles, +}: { + selectedOracles: PriceFeedVendors[]; + setSelectedOracles: (oracles: PriceFeedVendors[]) => void; +}) { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const toggleDropdown = () => setIsOpen(!isOpen); + + const toggleOracle = (oracle: PriceFeedVendors) => { + if (selectedOracles.includes(oracle)) { + setSelectedOracles(selectedOracles.filter((o) => o !== oracle)); + } else { + setSelectedOracles([...selectedOracles, oracle]); + } + }; + + return ( +
+
{ + if (e.key === 'Enter' || e.key === ' ') { + toggleDropdown(); + } + }} + aria-haspopup="listbox" + aria-expanded={isOpen} + > +
+ {selectedOracles.length > 0 ? ( +
+ {selectedOracles.map((oracle, index) => ( +
+ {OracleVendorIcons[oracle] ? ( + {oracle} + ) : ( + + )} +
+ ))} +
+ ) : ( + Filter oracles + )} + + + +
+
+
+
    + {Object.values(PriceFeedVendors).map((oracle) => ( +
  • toggleOracle(oracle)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + toggleOracle(oracle); + } + }} + role="option" + aria-selected={selectedOracles.includes(oracle)} + tabIndex={0} + > +
    + {OracleVendorIcons[oracle] ? ( + {oracle} + ) : ( + + )} + {oracle === PriceFeedVendors.Unknown ? 'Unknown Feed' : oracle} +
    +
  • + ))} +
+
+
+ ); +} + +function MarketRow({ + marketWithSelection, + onToggle, + disabled, +}: { + marketWithSelection: MarketWithSelection; + onToggle: () => void; + disabled: boolean; +}) { + const { market, isSelected } = marketWithSelection; + + return ( + { + // Don't toggle if clicking on input + if ((e.target as HTMLElement).tagName !== 'INPUT') { + onToggle(); + } + }} + > + +
+ e.stopPropagation()} + /> + + {market.uniqueKey.slice(2, 8)} + +
+ + +
+ +

+ {market.collateralAsset.symbol.length > 8 + ? `${market.collateralAsset.symbol.slice(0, 8)}...` + : market.collateralAsset.symbol} +

+
+ + +
+ +
+ + +

{formatUnits(BigInt(market.lltv), 16)}%

+ + +

+ {formatReadable(formatBalance(market.state.supplyAssets, market.loanAsset.decimals))} +

+ + +

+ {market.state.supplyApy ? `${(market.state.supplyApy * 100).toFixed(2)}%` : '—'} +

+ + +

+ {formatReadable(formatBalance(market.state.liquidityAssets, market.loanAsset.decimals))} +

+ + +
+ + + +
+ + + ); +} + +export function MarketsTableWithSameLoanAsset({ + markets, + onToggleMarket, + disabled = false, + renderCartItemExtra, + uniqueCollateralTokens, +}: MarketsTableWithSameLoanAssetProps): JSX.Element { + const [currentPage, setCurrentPage] = useState(1); + const [sortColumn, setSortColumn] = useState(SortColumn.Supply); + const [sortDirection, setSortDirection] = useState<1 | -1>(-1); // -1 = desc, 1 = asc + const [collateralFilter, setCollateralFilter] = useState([]); + const [oracleFilter, setOracleFilter] = useState([]); + + const handleSort = (column: SortColumn) => { + if (sortColumn === column) { + setSortDirection((prev) => (prev === 1 ? -1 : 1)); + } else { + setSortColumn(column); + setSortDirection(-1); + } + }; + + // Get unique collaterals with full token data + const availableCollaterals = useMemo(() => { + // If uniqueCollateralTokens is provided, use it (preferred approach from RiskSelection) + if (uniqueCollateralTokens) { + return uniqueCollateralTokens; + } + + // Fallback: build tokens manually from markets + const tokenMap = new Map(); + + markets.forEach((m) => { + const key = infoToKey(m.market.collateralAsset.address, m.market.morphoBlue.chain.id); + + if (!tokenMap.has(key)) { + // Check if token exists in supportedTokens + const existingToken = findToken(m.market.collateralAsset.address, m.market.morphoBlue.chain.id); + + if (existingToken) { + tokenMap.set(key, existingToken); + } else { + const token: UnknownERC20Token = { + symbol: m.market.collateralAsset.symbol, + img: undefined, + decimals: m.market.collateralAsset.decimals ?? 18, + networks: [{ + address: m.market.collateralAsset.address, + chain: getViemChain(m.market.morphoBlue.chain.id), + }], + }; + tokenMap.set(key, token); + } + } + }); + + return Array.from(tokenMap.values()).sort((a, b) => a.symbol.localeCompare(b.symbol)); + }, [markets, uniqueCollateralTokens]); + + // Filter and sort markets + const processedMarkets = useMemo(() => { + let filtered = [...markets]; + + // Apply collateral filter + if (collateralFilter.length > 0) { + filtered = filtered.filter((m) => { + const key = infoToKey(m.market.collateralAsset.address, m.market.morphoBlue.chain.id); + return collateralFilter.some((filterKey) => filterKey.includes(key)); + }); + } + + // Apply oracle filter + if (oracleFilter.length > 0) { + filtered = filtered.filter((m) => { + const vendorInfo = parsePriceFeedVendors(m.market.oracle?.data, m.market.morphoBlue.chain.id); + return vendorInfo.coreVendors.some((v) => oracleFilter.includes(v)); + }); + } + + // Sort + filtered.sort((a, b) => { + let comparison = 0; + switch (sortColumn) { + case SortColumn.Collateral: + comparison = a.market.collateralAsset.symbol.localeCompare( + b.market.collateralAsset.symbol, + ); + break; + case SortColumn.Oracle: + const oracleA = getOracleType(a.market.oracle?.data) ?? ''; + const oracleB = getOracleType(b.market.oracle?.data) ?? ''; + comparison = oracleA.localeCompare(oracleB); + break; + case SortColumn.LLTV: + comparison = Number(a.market.lltv) - Number(b.market.lltv); + break; + case SortColumn.Supply: + comparison = + Number(a.market.state.supplyAssetsUsd) - Number(b.market.state.supplyAssetsUsd); + break; + case SortColumn.APY: + comparison = (a.market.state.supplyApy ?? 0) - (b.market.state.supplyApy ?? 0); + break; + case SortColumn.Liquidity: + comparison = + Number(a.market.state.liquidityAssets) - Number(b.market.state.liquidityAssets); + break; + } + return comparison * sortDirection; + }); + + return filtered; + }, [markets, collateralFilter, oracleFilter, sortColumn, sortDirection]); + + // Get selected markets + const selectedMarkets = useMemo(() => { + return markets.filter((m) => m.isSelected); + }, [markets]); + + // Pagination + const totalPages = Math.ceil(processedMarkets.length / ITEMS_PER_PAGE); + const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; + const paginatedMarkets = processedMarkets.slice(startIndex, startIndex + ITEMS_PER_PAGE); + + React.useEffect(() => { + setCurrentPage(1); + }, [collateralFilter, oracleFilter]); + + return ( +
+ {/* Cart/Staging Area - MarketDetailsBlock Style */} + {selectedMarkets.length > 0 && ( +
+ {selectedMarkets.map(({ market }) => ( +
+
+
+
+
+ +
+
+ +
+
+
+ + {getTruncatedAssetName(market.loanAsset.symbol)} + + + / {getTruncatedAssetName(market.collateralAsset.symbol)} + +
+
+ · + + · + {(Number(market.lltv) / 1e16).toFixed(0)}% LLTV +
+
+ +
+ {renderCartItemExtra && renderCartItemExtra(market)} + +
+
+
+ ))} +
+ )} + + {/* Filters */} +
+ + +
+ + {/* Table */} +
+ + + + + + + + + + + + + + + {paginatedMarkets.length === 0 ? ( + + + + ) : ( + paginatedMarkets.map((marketWithSelection) => ( + onToggleMarket(marketWithSelection.market.uniqueKey)} + disabled={disabled} + /> + )) + )} + +
MarketRisk
+ No markets found +
+
+ + {/* Pagination */} + +
+ ); +} diff --git a/src/components/common/PendingMarketCap.tsx b/src/components/common/PendingMarketCap.tsx new file mode 100644 index 00000000..c975284f --- /dev/null +++ b/src/components/common/PendingMarketCap.tsx @@ -0,0 +1,130 @@ +import React, { useState } from 'react'; +import { LuX } from 'react-icons/lu'; +import { formatUnits } from 'viem'; +import { getTruncatedAssetName } from '@/utils/oracle'; +import { Market } from '@/utils/types'; +import OracleVendorBadge from '../OracleVendorBadge'; +import { TokenIcon } from '../TokenIcon'; + +type PendingMarketCapProps = { + market: Market; + relativeCap: string; + onRelativeCapChange: (value: string) => void; + onRemove: () => void; + disabled?: boolean; +}; + +export function PendingMarketCap({ + market, + relativeCap, + onRelativeCapChange, + onRemove, + disabled = false, +}: PendingMarketCapProps): JSX.Element { + const [error, setError] = useState(''); + + const handleChange = (e: React.ChangeEvent) => { + const value = e.target.value; + + // Allow empty or valid decimal numbers + if (value === '' || /^\d*\.?\d*$/.test(value)) { + onRelativeCapChange(value); + + // Validate percentage (0-100) + if (value !== '') { + const numValue = parseFloat(value); + if (numValue > 100) { + setError('Max 100%'); + } else if (numValue < 0) { + setError('Must be positive'); + } else { + setError(''); + } + } else { + setError(''); + } + } + }; + + return ( +
+
+ {/* Market Info */} +
+
+
+ +
+
+ +
+
+
+
+ + {getTruncatedAssetName(market.loanAsset.symbol)} / {getTruncatedAssetName(market.collateralAsset.symbol)} + + + {formatUnits(BigInt(market.lltv), 16)}% LTV + +
+
+ + · + {market.state?.supplyApy ? (market.state.supplyApy * 100).toFixed(2) : '0.00'}% APY +
+
+
+ + {/* Cap Input */} +
+
+ Max allocation +
+ + % +
+ {error &&

{error}

} +
+ + {/* Remove Button */} + +
+
+
+ ); +} diff --git a/src/data-sources/morpho-api/vaults.ts b/src/data-sources/morpho-api/vaults.ts deleted file mode 100644 index 75647cb6..00000000 --- a/src/data-sources/morpho-api/vaults.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { morphoGraphqlFetcher } from './fetchers'; - -type VaultV2GraphItem = { - id: string; - name?: string | null; - symbol?: string | null; - totalSupply?: string | null; - asset?: { - id?: string | null; - decimals?: number | null; - } | null; - curator?: { - address?: string | null; - } | null; - allocators?: { - allocator?: { - address?: string | null; - } | null; - }[] | null; -}; - -type VaultV2GraphResponse = { - vaultV2s: { - items: VaultV2GraphItem[]; - }; -}; - -const VAULT_V2_QUERY = /* GraphQL */ ` - query VaultV2Query($address: String!, $chainId: Int!) { - vaultV2s(where: { chainId_in: [$chainId], address_in: [$address] }) { - items { - id - name - symbol - totalSupply - asset { - id - decimals - } - curator { - address - } - allocators { - allocator { - address - } - } - } - } - } -`; - -export async function fetchVaultV2({ - address, - chainId, -}: { - address: string; - chainId: number; -}): Promise { - const response = await morphoGraphqlFetcher(VAULT_V2_QUERY, { - address: address.toLowerCase(), - chainId, - }); - - const item = response?.vaultV2s?.items?.[0]; - return item ?? null; -} diff --git a/src/data-sources/rpc/vaults.ts b/src/data-sources/rpc/vaults.ts deleted file mode 100644 index da7a5340..00000000 --- a/src/data-sources/rpc/vaults.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { Address, erc20Abi } from 'viem'; -import { vaultv2Abi } from '@/abis/vaultv2'; -import { SupportedNetworks } from '@/utils/networks'; -import { getClient } from '@/utils/rpc'; - -type VaultV2RpcResult = { - name?: string; - symbol?: string; - totalSupply?: bigint; - assetAddress?: Address; - assetDecimals?: number; - owner?: Address; - curator?: Address; -}; - -/** - * Lightweight RPC fallback for Vault V2 metadata. This should only be used when - * the primary Morpho API endpoint is unavailable, as it performs direct RPC - * calls against the current chain. - */ -export async function fetchVaultV2ViaRpc({ - address, - chainId, -}: { - address: Address; - chainId: SupportedNetworks; -}): Promise { - try { - const client = getClient(chainId); - - const [ - nameResult, - symbolResult, - ownerResult, - curatorResult, - totalSupplyResult, - assetResult, - ] = await Promise.allSettled([ - client.readContract({ - address, - abi: vaultv2Abi, - functionName: 'name', - }), - client.readContract({ - address, - abi: vaultv2Abi, - functionName: 'symbol', - }), - client.readContract({ - address, - abi: vaultv2Abi, - functionName: 'owner', - }), - client.readContract({ - address, - abi: vaultv2Abi, - functionName: 'curator', - }), - client.readContract({ - address, - abi: vaultv2Abi, - functionName: 'totalSupply', - }), - client.readContract({ - address, - abi: vaultv2Abi, - functionName: 'asset', - }), - ]); - - const assetAddress = - assetResult.status === 'fulfilled' - ? (assetResult.value as Address) - : undefined; - - const ownerAddress = - ownerResult.status === 'fulfilled' - ? ((ownerResult.value as Address) ?? undefined) - : undefined; - - const curatorAddress = - curatorResult.status === 'fulfilled' - ? ((curatorResult.value as Address) ?? undefined) - : undefined; - - let assetDecimals: number | undefined; - if (assetAddress) { - try { - const decimals = await client.readContract({ - address: assetAddress, - abi: erc20Abi, - functionName: 'decimals', - }); - assetDecimals = Number(decimals); - } catch (error) { - console.error('Failed to read asset decimals via RPC', error); - } - } - - return { - name: nameResult.status === 'fulfilled' ? (nameResult.value as string) : undefined, - symbol: symbolResult.status === 'fulfilled' ? (symbolResult.value as string) : undefined, - totalSupply: - totalSupplyResult.status === 'fulfilled' - ? (totalSupplyResult.value as bigint) - : undefined, - assetAddress, - assetDecimals, - owner: ownerAddress, - curator: curatorAddress, - }; - } catch (error) { - console.error('Failed to fetch vault data via RPC fallback', error); - return null; - } -} diff --git a/src/data-sources/subgraph/v2-vaults.ts b/src/data-sources/subgraph/v2-vaults.ts index 4f8d63c4..4fe15366 100644 --- a/src/data-sources/subgraph/v2-vaults.ts +++ b/src/data-sources/subgraph/v2-vaults.ts @@ -1,4 +1,4 @@ -import { userVaultsV2Query } from '@/graphql/morpho-v2-subgraph-queries'; +import { userVaultsV2Query, vaultV2Query } from '@/graphql/morpho-v2-subgraph-queries'; import { SupportedNetworks, getAgentConfig, networks, isAgentAvailable } from '@/utils/networks'; import { subgraphGraphqlFetcher } from './fetchers'; @@ -24,6 +24,46 @@ export type UserVaultV2 = SubgraphVaultV2 & { balance?: bigint; // vault total assets }; +// Vault V2 details from subgraph +export type VaultV2Cap = { + relativeCap: string; + absoluteCap: string; + marketId: string; +}; + +export type VaultV2Details = { + id: string; + asset: string; + symbol: string; + name: string; + curator: string; + owner: string; + allocators: string[]; + sentinels: string[]; + caps: VaultV2Cap[]; + totalSupply: string; + adopters: string[]; +}; + +type SubgraphVaultV2Response = { + data: { + vaultV2: { + id: string; + asset: string; + symbol: string; + name: string; + curator: string; + owner: string; + allocators: { account: string }[]; + sentinels: { account: string }[]; + caps: VaultV2Cap[]; + totalSupply: string; + adopters: { address: string }[]; + } | null; + }; + errors?: any[]; +}; + export const fetchUserVaultsV2 = async ( owner: string, network: SupportedNetworks, @@ -91,4 +131,62 @@ export const fetchUserVaultsV2AllNetworks = async (owner: string): Promise => { + const agentConfig = getAgentConfig(network); + + // fetch from the adapter + if (!agentConfig?.adapterSubgraphEndpoint) { + console.log(`No subgraph endpoint configured for network ${network}`); + return null; + } + + const subgraphUrl = agentConfig.adapterSubgraphEndpoint; + + try { + const variables = { + id: vaultAddress.toLowerCase(), + }; + + const response = await subgraphGraphqlFetcher( + subgraphUrl, + vaultV2Query, + variables, + ); + + if (response.errors) { + console.error('GraphQL errors:', response.errors); + return null; + } + + const vault = response.data?.vaultV2; + if (!vault) { + console.log(`No V2 vault found for address ${vaultAddress} on network ${network}`); + return null; + } + + return { + id: vault.id, + asset: vault.asset, + symbol: vault.symbol, + name: vault.name, + curator: vault.curator, + owner: vault.owner, + allocators: vault.allocators.map((a) => a.account), + sentinels: vault.sentinels.map((s) => s.account), + caps: vault.caps, + totalSupply: vault.totalSupply, + adopters: vault.adopters.map((a) => a.address), + }; + } catch (error) { + console.error( + `Error fetching V2 vault details for ${vaultAddress} on network ${network}:`, + error, + ); + return null; + } }; \ No newline at end of file diff --git a/src/graphql/morpho-v2-subgraph-queries.ts b/src/graphql/morpho-v2-subgraph-queries.ts index 3377e4c6..53c94bbd 100644 --- a/src/graphql/morpho-v2-subgraph-queries.ts +++ b/src/graphql/morpho-v2-subgraph-queries.ts @@ -11,4 +11,32 @@ export const userVaultsV2Query = ` newVaultV2 } } +`; + +export const vaultV2Query = ` + query VaultV2($id: String!) { + vaultV2(id: $id) { + id + asset + symbol + name + curator + owner + allocators(where: {isAllocator: true}) { + account + } + sentinels(where: {isSentinel: true}) { + account + } + caps { + relativeCap + absoluteCap + marketId + } + totalSupply + adopters(where: {isAdopter: true}) { + address + } + } + } `; \ No newline at end of file diff --git a/src/hooks/useVaultV2.ts b/src/hooks/useVaultV2.ts index 7db7b89b..e6fefc06 100644 --- a/src/hooks/useVaultV2.ts +++ b/src/hooks/useVaultV2.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react'; import { Address, encodeFunctionData, toFunctionSelector, zeroAddress } from 'viem'; import { useAccount, useChainId, useReadContract } from 'wagmi'; import { vaultv2Abi } from '@/abis/vaultv2'; +import { VaultV2Cap } from '@/data-sources/subgraph/v2-vaults'; import { SupportedNetworks } from '@/utils/networks'; import { useTransactionWithToast } from './useTransactionWithToast'; @@ -10,9 +11,11 @@ const ADAPTER_INDEX = 0n; export function useVaultV2({ vaultAddress, chainId, + onTransactionSuccess, }: { vaultAddress?: Address; chainId?: SupportedNetworks | number; + onTransactionSuccess?: () => void; }) { const connectedChainId = useChainId(); const chainIdToUse = (chainId ?? connectedChainId) as SupportedNetworks; @@ -70,19 +73,20 @@ export function useVaultV2({ const currentCurator = useMemo(() => (curator as Address | undefined) ?? zeroAddress, [curator]); - const handleFinalizeSuccess = useCallback(() => { + const handleInitializationSuccess = useCallback(() => { void refetch(); - }, [refetch]); - - const { isConfirming: isFinalizing, sendTransactionAsync: sendFinalizeTx } = useTransactionWithToast({ - toastId: 'finalizeSetup', - pendingText: 'Finalizing setup', - successText: 'Setup finalized', - errorText: 'Failed to finalize setup', - pendingDescription: 'Finalizing setup', - successDescription: 'Setup finalized', + onTransactionSuccess?.(); + }, [refetch, onTransactionSuccess]); + + const { isConfirming: isInitializing, sendTransactionAsync: sendInitializationTx } = useTransactionWithToast({ + toastId: 'completeInitialization', + pendingText: 'Completing vault initialization', + successText: 'Vault initialized successfully', + errorText: 'Failed to initialize vault', + pendingDescription: 'Setting up adapter, registry, and optional allocator', + successDescription: 'Vault is ready to use', chainId: chainIdToUse, - onSuccess: handleFinalizeSuccess, + onSuccess: handleInitializationSuccess, }); const { isConfirming: isUpdatingMetadata, sendTransactionAsync: sendMetadataTx } = useTransactionWithToast({ @@ -95,10 +99,41 @@ export function useVaultV2({ chainId: chainIdToUse, }); - + const handleAllocatorOrCapSuccess = useCallback(() => { + void refetch(); + onTransactionSuccess?.(); + }, [refetch, onTransactionSuccess]); + + const { isConfirming: isUpdatingAllocator, sendTransactionAsync: sendAllocatorTx } = useTransactionWithToast({ + toastId: 'update-allocator', + pendingText: 'Updating allocator', + successText: 'Allocator updated', + errorText: 'Failed to update allocator', + pendingDescription: 'Updating allocator status', + successDescription: 'Allocator status changed', + chainId: chainIdToUse, + onSuccess: handleAllocatorOrCapSuccess, + }); + + const { isConfirming: isUpdatingCaps, sendTransactionAsync: sendCapsTx } = useTransactionWithToast({ + toastId: 'update-caps', + pendingText: 'Updating market caps', + successText: 'Market caps updated', + errorText: 'Failed to update caps', + pendingDescription: 'Applying new market caps', + successDescription: 'Caps updated successfully', + chainId: chainIdToUse, + onSuccess: handleAllocatorOrCapSuccess, + }); + + // All morpho v2 vault operations have to be proposed first, and then execute - const finalizeSetup = useCallback( - async (morphoRegistry: Address, marketV1Adapter: Address): Promise => { + const completeInitialization = useCallback( + async ( + morphoRegistry: Address, + marketV1Adapter: Address, + allocator?: Address, + ): Promise => { if (!account || !vaultAddress || marketV1Adapter === zeroAddress) return false; const txs: `0x${string}`[] = []; @@ -160,7 +195,24 @@ export function useVaultV2({ txs.push(submitAbdicateSetAdapterRegistryTx, abdicateSetAdapterRegistryTx); - // Step 5. Execute multicall with all steps. + // Step 5 (Optional). Set initial allocator if provided. + if (allocator && allocator !== zeroAddress) { + const setAllocatorTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'setIsAllocator', + args: [allocator, true], + }); + + const submitSetAllocatorTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'submit', + args: [setAllocatorTx], + }); + + txs.push(submitSetAllocatorTx, setAllocatorTx); + } + + // Step 6. Execute multicall with all steps. const multicallTx = encodeFunctionData({ abi: vaultv2Abi, functionName: 'multicall', @@ -168,26 +220,26 @@ export function useVaultV2({ }); try { - await sendFinalizeTx({ + await sendInitializationTx({ account, to: vaultAddress, data: multicallTx, chainId: chainIdToUse, }); return true; - } catch (finalizeError) { + } catch (initError) { if ( - finalizeError instanceof Error && - finalizeError.message.toLowerCase().includes('reject') + initError instanceof Error && + initError.message.toLowerCase().includes('reject') ) { // user rejected the transaction; treat as graceful cancellation return false; } - console.error('Failed to finalize vault setup', finalizeError); - throw finalizeError; + console.error('Failed to complete vault initialization', initError); + throw initError; } }, - [account, chainIdToUse, currentCurator, sendFinalizeTx, vaultAddress], + [account, chainIdToUse, currentCurator, sendInitializationTx, vaultAddress], ); const updateNameAndSymbol = useCallback( @@ -254,6 +306,117 @@ export function useVaultV2({ [account, chainIdToUse, sendMetadataTx, vaultAddress], ); + const setAllocator = useCallback( + async (allocator: Address, isAllocator: boolean): Promise => { + if (!account || !vaultAddress) return false; + + const setAllocatorTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'setIsAllocator', + args: [allocator, isAllocator], + }); + + const submitSetAllocatorTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'submit', + args: [setAllocatorTx], + }); + + const multicallTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'multicall', + args: [[submitSetAllocatorTx, setAllocatorTx]], + }); + + try { + await sendAllocatorTx({ + account, + to: vaultAddress, + data: multicallTx, + chainId: chainIdToUse, + }); + return true; + } catch (allocatorError) { + if (allocatorError instanceof Error && allocatorError.message.toLowerCase().includes('reject')) { + return false; + } + console.error('Failed to update allocator', allocatorError); + throw allocatorError; + } + }, + [account, chainIdToUse, sendAllocatorTx, vaultAddress], + ); + + const updateCaps = useCallback( + async (caps: VaultV2Cap[]): Promise => { + if (!account || !vaultAddress) return false; + + const txs: `0x${string}`[] = []; + + caps.forEach((cap) => { + const relativeCapBigInt = BigInt(cap.relativeCap); + const absoluteCapBigInt = BigInt(cap.absoluteCap); + const idData = cap.marketId as `0x${string}`; + + // For updates, we always increase caps (curator can decrease if needed) + if (relativeCapBigInt > 0n) { + const increaseRelativeCapTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'increaseRelativeCap', + args: [idData, relativeCapBigInt], + }); + + const submitIncreaseRelativeCapTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'submit', + args: [increaseRelativeCapTx], + }); + + txs.push(submitIncreaseRelativeCapTx, increaseRelativeCapTx); + } + + if (absoluteCapBigInt > 0n) { + const increaseAbsoluteCapTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'increaseAbsoluteCap', + args: [idData, absoluteCapBigInt], + }); + + const submitIncreaseAbsoluteCapTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'submit', + args: [increaseAbsoluteCapTx], + }); + + txs.push(submitIncreaseAbsoluteCapTx, increaseAbsoluteCapTx); + } + }); + + const multicallTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'multicall', + args: [txs], + }); + + try { + await sendCapsTx({ + account, + to: vaultAddress, + data: multicallTx, + chainId: chainIdToUse, + }); + return true; + } catch (capsError) { + if (capsError instanceof Error && capsError.message.toLowerCase().includes('reject')) { + return false; + } + console.error('Failed to update caps', capsError); + throw capsError; + } + }, + [account, chainIdToUse, sendCapsTx, vaultAddress], + ); + const adapter = useMemo(() => { if (!data) return zeroAddress; return data as Address; @@ -277,11 +440,15 @@ export function useVaultV2({ isLoading: isLoading || isFetching, refetch, error: error as Error | null, - finalizeSetup, - isFinalizing, + completeInitialization, + isInitializing, name, symbol, updateNameAndSymbol, isUpdatingMetadata, + setAllocator, + isUpdatingAllocator, + updateCaps, + isUpdatingCaps, }; } diff --git a/src/hooks/useVaultV2Data.ts b/src/hooks/useVaultV2Data.ts index d83e22c9..1d4a7a5f 100644 --- a/src/hooks/useVaultV2Data.ts +++ b/src/hooks/useVaultV2Data.ts @@ -1,43 +1,35 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Address, formatUnits } from 'viem'; +import { Address } from 'viem'; import { useTokens } from '@/components/providers/TokenProvider'; -import { fetchVaultV2 } from '@/data-sources/morpho-api/vaults'; -import { fetchVaultV2ViaRpc } from '@/data-sources/rpc/vaults'; -import { formatReadable } from '@/utils/balance'; +import { fetchVaultV2Details, VaultV2Cap } from '@/data-sources/subgraph/v2-vaults'; import { getSlicedAddress } from '@/utils/address'; import { SupportedNetworks } from '@/utils/networks'; -const normalize = (value?: string | null) => value?.toLowerCase().trim() ?? undefined; - type UseVaultV2DataArgs = { vaultAddress?: Address; - chainId: number; + chainId: SupportedNetworks; fallbackName?: string; fallbackSymbol?: string; - onChainName?: string | null; - onChainSymbol?: string | null; - ownerAddress?: Address; - defaultAllocatorAddresses?: string[]; }; -export type VaultV2ComputedData = { +export type VaultV2Data = { displayName: string; displaySymbol: string; - assetAddress?: string; + assetAddress: string; tokenSymbol?: string; tokenDecimals?: number; - totalSupplyDisplay: string; - totalSupplyRaw?: string; - allocatorAddresses: string[]; - allocatorCount: number; - ownerAddress?: string; - curatorAddress?: string; - guardianAddresses: string[]; + totalSupply: string; + allocators: string[]; + sentinels: string[]; + owner: string; + curator: string; + caps: VaultV2Cap[]; + adopters: string[]; curatorDisplay: string; }; type UseVaultV2DataReturn = { - data: VaultV2ComputedData; + data: VaultV2Data | null; loading: boolean; error: Error | null; refetch: () => Promise; @@ -48,212 +40,55 @@ export function useVaultV2Data({ chainId, fallbackName = '', fallbackSymbol = '', - onChainName, - onChainSymbol, - ownerAddress, - defaultAllocatorAddresses, }: UseVaultV2DataArgs): UseVaultV2DataReturn { const { findToken } = useTokens(); - const normalizedOwner = useMemo(() => normalize(ownerAddress), [ownerAddress]); - const allocatorInputKey = useMemo(() => { - if (!defaultAllocatorAddresses?.length) return ''; - return defaultAllocatorAddresses.map((addr) => normalize(addr) ?? '').join('|'); - }, [defaultAllocatorAddresses]); - - const defaultAllocators = useMemo(() => { - if (!defaultAllocatorAddresses?.length) return []; - return defaultAllocatorAddresses - .map((addr) => normalize(addr)) - .filter((addr): addr is string => Boolean(addr)); - }, [allocatorInputKey]); - - const buildData = useCallback( - (item: { - name?: string; - symbol?: string; - totalSupply?: string; - assetAddress?: string; - assetDecimals?: number; - allocatorAddresses: string[]; - curatorAddress?: string; - ownerAddress?: string; - } | null): VaultV2ComputedData => { - const assetAddress = item?.assetAddress; - const token = assetAddress ? findToken(assetAddress, chainId) : undefined; - const assetDecimals = item?.assetDecimals; - - const displayName = (item?.name?.trim() || onChainName?.trim() || fallbackName).trim(); - const displaySymbol = (item?.symbol?.trim() || onChainSymbol?.trim() || fallbackSymbol).trim(); - - let totalSupplyDisplay = '--'; - if (item?.totalSupply) { - try { - const decimals = token?.decimals ?? assetDecimals ?? 18; - const parsed = Number(formatUnits(BigInt(item.totalSupply), decimals)); - totalSupplyDisplay = `${formatReadable(parsed)} ${token?.symbol ?? displaySymbol}`; - } catch (error) { - console.error('Failed to format vault total supply', error); - } - } - - const normalizedAllocators = (item?.allocatorAddresses.length - ? item.allocatorAddresses - : defaultAllocators) - .map((addr) => normalize(addr)) - .filter((addr): addr is string => Boolean(addr)); - - const allocatorAddresses = Array.from(new Set(normalizedAllocators)); - - const owner = item?.ownerAddress ? normalize(item.ownerAddress) : normalizedOwner; - const curator = item?.curatorAddress ?? undefined; - const curatorDisplay = curator ? getSlicedAddress(curator as `0x${string}`) : '--'; - - return { - displayName, - displaySymbol, - assetAddress, - tokenSymbol: token?.symbol, - tokenDecimals: token?.decimals ?? assetDecimals, - totalSupplyDisplay, - totalSupplyRaw: item?.totalSupply, - allocatorAddresses, - allocatorCount: allocatorAddresses.length, - ownerAddress: owner, - curatorAddress: curator, - guardianAddresses: [], - curatorDisplay, - }; - }, - [chainId, defaultAllocators, fallbackName, fallbackSymbol, findToken, normalizedOwner, onChainName, onChainSymbol], - ); - - const [data, setData] = useState(() => - buildData({ - name: onChainName ?? undefined, - symbol: onChainSymbol ?? undefined, - totalSupply: undefined, - assetAddress: undefined, - assetDecimals: undefined, - allocatorAddresses: defaultAllocators, - curatorAddress: undefined, - }), - ); + const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const load = useCallback(async () => { + if (!vaultAddress) { + setData(null); + return; + } + setLoading(true); setError(null); try { - if (!vaultAddress) { - setData( - buildData({ - name: onChainName ?? undefined, - symbol: onChainSymbol ?? undefined, - totalSupply: undefined, - assetAddress: undefined, - assetDecimals: undefined, - allocatorAddresses: defaultAllocators, - curatorAddress: undefined, - }), - ); - return; - } + const result = await fetchVaultV2Details(vaultAddress, chainId); - const result = await fetchVaultV2({ address: vaultAddress, chainId }); - - if (result) { - setData( - buildData({ - name: result.name ?? undefined, - symbol: result.symbol ?? undefined, - totalSupply: result.totalSupply ?? undefined, - assetAddress: normalize(result.asset?.id), - assetDecimals: result.asset?.decimals ?? undefined, - allocatorAddresses: (result.allocators ?? []) - .map((entry) => normalize(entry?.allocator?.address)) - .filter((addr): addr is string => Boolean(addr)), - curatorAddress: normalize(result.curator?.address), - }), - ); + if (!result) { + setData(null); return; } - const rpcFallback = await fetchVaultV2ViaRpc({ - address: vaultAddress, - chainId: chainId as SupportedNetworks, - }); - - if (rpcFallback) { - setData( - buildData({ - name: rpcFallback.name, - symbol: rpcFallback.symbol, - totalSupply: rpcFallback.totalSupply?.toString(), - assetAddress: normalize(rpcFallback.assetAddress), - assetDecimals: rpcFallback.assetDecimals, - allocatorAddresses: defaultAllocators, - curatorAddress: normalize(rpcFallback.curator), - ownerAddress: rpcFallback.owner, - }), - ); - return; - } + const token = result.asset ? findToken(result.asset, chainId) : undefined; + const curatorDisplay = result.curator ? getSlicedAddress(result.curator as Address) : '--'; - setData( - buildData({ - name: onChainName ?? undefined, - symbol: onChainSymbol ?? undefined, - totalSupply: undefined, - assetAddress: undefined, - assetDecimals: undefined, - allocatorAddresses: defaultAllocators, - curatorAddress: undefined, - }), - ); + setData({ + displayName: result.name || fallbackName, + displaySymbol: result.symbol || fallbackSymbol, + assetAddress: result.asset, + tokenSymbol: token?.symbol, + tokenDecimals: token?.decimals, + totalSupply: result.totalSupply, + allocators: result.allocators, + sentinels: result.sentinels, + owner: result.owner, + curator: result.curator, + caps: result.caps, + adopters: result.adopters, + curatorDisplay, + }); } catch (err) { setError(err instanceof Error ? err : new Error('Failed to fetch vault data')); - - if (vaultAddress) { - const rpcFallback = await fetchVaultV2ViaRpc({ - address: vaultAddress, - chainId: chainId as SupportedNetworks, - }); - - if (rpcFallback) { - setData( - buildData({ - name: rpcFallback.name, - symbol: rpcFallback.symbol, - totalSupply: rpcFallback.totalSupply?.toString(), - assetAddress: normalize(rpcFallback.assetAddress), - assetDecimals: rpcFallback.assetDecimals, - allocatorAddresses: defaultAllocators, - curatorAddress: normalize(rpcFallback.curator), - ownerAddress: rpcFallback.owner, - }), - ); - return; - } - } - - setData( - buildData({ - name: onChainName ?? undefined, - symbol: onChainSymbol ?? undefined, - totalSupply: undefined, - assetAddress: undefined, - assetDecimals: undefined, - allocatorAddresses: defaultAllocators, - curatorAddress: undefined, - }), - ); + setData(null); } finally { setLoading(false); } - }, [buildData, chainId, defaultAllocators, normalizedOwner, onChainName, onChainSymbol, vaultAddress]); + }, [vaultAddress, chainId, fallbackName, fallbackSymbol, findToken]); useEffect(() => { void load(); diff --git a/src/utils/monarch-agent.ts b/src/utils/monarch-agent.ts index c54dc82f..b9705f7a 100644 --- a/src/utils/monarch-agent.ts +++ b/src/utils/monarch-agent.ts @@ -18,14 +18,6 @@ export enum KnownAgents { MAX_APY = '0xe0e04468A54937244BEc3bc6C1CA8Bc36ECE6704', } -// v1 rebalancer EOA -export const agents: AgentMetadata[] = [ - { - name: 'Max APY Agent', - address: KnownAgents.MAX_APY, - strategyDescription: 'Rebalance every 8 hours, always move to the highest APY', - }, -]; // v2 rebalancer EOA // identical now export const v2AgentsBase: AgentMetadata[] = [ @@ -38,5 +30,5 @@ export const v2AgentsBase: AgentMetadata[] = [ export const findAgent = (address: string): AgentMetadata | undefined => { - return agents.find((agent) => agent.address.toLowerCase() === address.toLowerCase()); + return v2AgentsBase.find((agent) => agent.address.toLowerCase() === address.toLowerCase()); }; From 33b4b202688669603814ba81299c0d1039220160 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 12 Oct 2025 11:21:11 +0800 Subject: [PATCH 10/29] chore: stash --- .../components/VaultSettingsModal.tsx | 1 - .../components/settings/AllocationsTab.tsx | 40 ++++--------------- .../components/settings/types.ts | 2 - 3 files changed, 8 insertions(+), 35 deletions(-) diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx index ae964d15..51d231a6 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx @@ -145,7 +145,6 @@ export function VaultSettingsModal({ existingCaps={existingCaps} onUpdateCaps={onUpdateCaps} isUpdatingCaps={isUpdatingCaps} - isOpen={isOpen} /> ); default: diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/AllocationsTab.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/AllocationsTab.tsx index 9ac082e3..6d78b716 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/AllocationsTab.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/AllocationsTab.tsx @@ -41,14 +41,6 @@ export function AllocationsTab({ relativeCap: existingCap ? (parseFloat(existingCap.relativeCap) / 1e16).toString() : '', - absoluteCap: existingCap - ? ( - parseFloat( - ((BigInt(existingCap.absoluteCap) * 10000n) / - BigInt(10 ** market.loanAsset.decimals)).toString(), - ) / 10000 - ).toString() - : '', isSelected: !!existingCap, }; }), @@ -71,14 +63,11 @@ export function AllocationsTab({ ); }, []); - const handleUpdateCapField = useCallback( - (marketId: string, field: 'relativeCap' | 'absoluteCap', value: string) => { - setMarketCaps((prev) => - prev.map((c) => (c.market.uniqueKey === marketId ? { ...c, [field]: value } : c)), - ); - }, - [], - ); + const handleUpdateCapField = useCallback((marketId: string, value: string) => { + setMarketCaps((prev) => + prev.map((c) => (c.market.uniqueKey === marketId ? { ...c, relativeCap: value } : c)), + ); + }, []); const handleSaveCaps = useCallback(async () => { const capsToUpdate = marketCaps @@ -89,15 +78,10 @@ export function AllocationsTab({ ? parseUnits(c.relativeCap, 16) : 0n; - const absoluteCapBigInt = - c.absoluteCap && parseFloat(c.absoluteCap) > 0 - ? parseUnits(c.absoluteCap, c.market.loanAsset.decimals) - : 0n; - return { marketId: c.market.uniqueKey, relativeCap: relativeCapBigInt.toString(), - absoluteCap: absoluteCapBigInt.toString(), + absoluteCap: '0', } as VaultV2Cap; }); @@ -138,15 +122,7 @@ export function AllocationsTab({ const existingRelative = existingCap ? (parseFloat(existingCap.relativeCap) / 1e16).toString() : ''; - const existingAbsolute = existingCap - ? ( - parseFloat( - ((BigInt(existingCap.absoluteCap) * 10000n) / - BigInt(10 ** c.market.loanAsset.decimals)).toString(), - ) / 10000 - ).toString() - : ''; - return c.relativeCap !== existingRelative || c.absoluteCap !== existingAbsolute; + return c.relativeCap !== existingRelative; } return false; }); @@ -261,7 +237,7 @@ export function AllocationsTab({ if (value === '' || /^\d*\.?\d*$/.test(value)) { const numValue = parseFloat(value); if (value === '' || (numValue >= 0 && numValue <= 100)) { - handleUpdateCapField(market.uniqueKey, 'relativeCap', value); + handleUpdateCapField(market.uniqueKey, value); } } }} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts b/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts index 3733980e..595752ef 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts @@ -8,7 +8,6 @@ export type SettingsTab = 'general' | 'agents' | 'allocations'; export type MarketCapState = { market: Market; relativeCap: string; - absoluteCap: string; isSelected: boolean; }; @@ -39,5 +38,4 @@ export type AllocationsTabProps = { existingCaps: VaultV2Cap[]; onUpdateCaps: (caps: VaultV2Cap[]) => Promise; isUpdatingCaps: boolean; - isOpen: boolean; }; From 906f36c852881394be569d135f81270d9c6705a6 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 16 Oct 2025 12:42:27 +0800 Subject: [PATCH 11/29] refactor: allocation tab --- .../components/VaultAgentSummary.tsx | 4 +- .../components/VaultApyHistory.tsx | 30 -- .../components/VaultSettingsModal.tsx | 2 + .../components/settings/AgentsTab.tsx | 26 +- .../components/settings/AllocationsTab.tsx | 304 ++---------------- .../settings/CurrentAllocations.tsx | 94 ++++++ .../components/settings/EditAllocations.tsx | 231 +++++++++++++ .../components/settings/GeneralTab.tsx | 16 +- .../components/settings/types.ts | 2 + .../[chainId]/[vaultAddress]/content.tsx | 4 +- src/components/common/Button.tsx | 1 + src/hooks/useUserVaultsV2.ts | 84 ++++- 12 files changed, 481 insertions(+), 317 deletions(-) delete mode 100644 app/autovault/[chainId]/[vaultAddress]/components/VaultApyHistory.tsx create mode 100644 app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx create mode 100644 app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultAgentSummary.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultAgentSummary.tsx index 53493b28..a017cb63 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultAgentSummary.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultAgentSummary.tsx @@ -58,11 +58,11 @@ export function VaultAgentSummary({

{roleStatusText}

- {onManageAllocations && ( - )} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultApyHistory.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultApyHistory.tsx deleted file mode 100644 index 645b00a6..00000000 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultApyHistory.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Button } from '@/components/common'; - -type VaultApyHistoryProps = { - timeframes: string[]; -}; - -export function VaultApyHistory({ timeframes }: VaultApyHistoryProps) { - return ( -
-
-
-

Historical APY

-

Performance data updates every epoch.

-
-
- {timeframes.map((frame) => ( - - ))} -
-
-
-
- APY chart coming soon -
-
-
- ); -} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx index 51d231a6..13aac056 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx @@ -122,6 +122,7 @@ export function VaultSettingsModal({ currentSymbol={currentSymbol} onUpdateMetadata={onUpdateMetadata} updatingMetadata={updatingMetadata} + chainId={chainId} /> ); case 'agents': @@ -134,6 +135,7 @@ export function VaultSettingsModal({ sentinels={sentinels} onSetAllocator={onSetAllocator} isUpdatingAllocator={isUpdatingAllocator} + chainId={chainId} /> ); case 'allocations': diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentsTab.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentsTab.tsx index 35f2b72f..7c65c0df 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentsTab.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentsTab.tsx @@ -3,6 +3,7 @@ import { Address } from 'viem'; import { AddressDisplay } from '@/components/common/AddressDisplay'; import { Button } from '@/components/common/Button'; import { Spinner } from '@/components/common/Spinner'; +import { useMarketNetwork } from '@/hooks/useMarketNetwork'; import { v2AgentsBase } from '@/utils/monarch-agent'; import { AgentsTabProps } from './types'; @@ -14,31 +15,48 @@ export function AgentsTab({ sentinels = [], onSetAllocator, isUpdatingAllocator, + chainId, }: AgentsTabProps) { const [allocatorToAdd, setAllocatorToAdd] = useState
(null); const [allocatorToRemove, setAllocatorToRemove] = useState
(null); const [isEditingAllocators, setIsEditingAllocators] = useState(false); + const { needSwitchChain, switchToNetwork } = useMarketNetwork({ + targetChainId: chainId, + }); + const handleAddAllocator = useCallback( async (allocator: Address) => { + // Switch network if needed + if (needSwitchChain) { + switchToNetwork(); + return; + } + setAllocatorToAdd(allocator); const success = await onSetAllocator(allocator, true); if (success) { setAllocatorToAdd(null); } }, - [onSetAllocator], + [onSetAllocator, needSwitchChain, switchToNetwork], ); const handleRemoveAllocator = useCallback( async (allocator: Address) => { + // Switch network if needed + if (needSwitchChain) { + switchToNetwork(); + return; + } + setAllocatorToRemove(allocator); const success = await onSetAllocator(allocator, false); if (success) { setAllocatorToRemove(null); } }, - [onSetAllocator], + [onSetAllocator, needSwitchChain, switchToNetwork], ); const renderSingleRole = ( @@ -198,6 +216,8 @@ export function AgentsTab({ Removing... + ) : needSwitchChain ? ( + 'Switch Network' ) : ( 'Remove' )} @@ -235,6 +255,8 @@ export function AgentsTab({ Adding... + ) : needSwitchChain ? ( + 'Switch Network' ) : ( 'Add' )} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/AllocationsTab.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/AllocationsTab.tsx index 6d78b716..ee6ab1ff 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/AllocationsTab.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/AllocationsTab.tsx @@ -1,12 +1,7 @@ -import { useCallback, useEffect, useState } from 'react'; -import { formatUnits, parseUnits } from 'viem'; -import { Button } from '@/components/common/Button'; -import { MarketsTableWithSameLoanAsset } from '@/components/common/MarketsTableWithSameLoanAsset'; -import { Spinner } from '@/components/common/Spinner'; -import { TokenIcon } from '@/components/TokenIcon'; -import { VaultV2Cap } from '@/data-sources/subgraph/v2-vaults'; -import { useMarkets } from '@/hooks/useMarkets'; -import { AllocationsTabProps, MarketCapState } from './types'; +import { useState } from 'react'; +import { CurrentAllocations } from './CurrentAllocations'; +import { EditAllocations } from './EditAllocations'; +import { AllocationsTabProps } from './types'; export function AllocationsTab({ isOwner, @@ -16,274 +11,29 @@ export function AllocationsTab({ onUpdateCaps, isUpdatingCaps, }: AllocationsTabProps) { - const [marketCaps, setMarketCaps] = useState([]); - const [isEditingCaps, setIsEditingCaps] = useState(false); - const { markets, loading: marketsLoading } = useMarkets(); - - // Initialize market caps from existing data - useEffect(() => { - if (!markets || !vaultAsset) return; - - // Don't re-initialize while editing - if (isEditingCaps) return; - - const filteredMarkets = markets.filter( - (m) => - m.loanAsset.address.toLowerCase() === vaultAsset.toLowerCase() && - m.morphoBlue.chain.id === chainId, - ); - - setMarketCaps( - filteredMarkets.map((market) => { - const existingCap = existingCaps.find((c) => c.marketId === market.uniqueKey); - return { - market, - relativeCap: existingCap - ? (parseFloat(existingCap.relativeCap) / 1e16).toString() - : '', - isSelected: !!existingCap, - }; - }), - ); - }, [markets, vaultAsset, chainId, isEditingCaps, existingCaps]); - - const handleToggleMarket = useCallback((marketId: string) => { - setMarketCaps((prev) => - prev.map((c) => { - if (c.market.uniqueKey === marketId) { - const newIsSelected = !c.isSelected; - return { - ...c, - isSelected: newIsSelected, - relativeCap: newIsSelected && !c.relativeCap ? '100' : c.relativeCap, - }; + const [isEditing, setIsEditing] = useState(false); + + return isEditing ? ( + setIsEditing(false)} + onSave={async (caps) => { + const success = await onUpdateCaps(caps); + if (success) { + setIsEditing(false); } - return c; - }), - ); - }, []); - - const handleUpdateCapField = useCallback((marketId: string, value: string) => { - setMarketCaps((prev) => - prev.map((c) => (c.market.uniqueKey === marketId ? { ...c, relativeCap: value } : c)), - ); - }, []); - - const handleSaveCaps = useCallback(async () => { - const capsToUpdate = marketCaps - .filter((c) => c.isSelected) - .map((c) => { - const relativeCapBigInt = - c.relativeCap && parseFloat(c.relativeCap) > 0 - ? parseUnits(c.relativeCap, 16) - : 0n; - - return { - marketId: c.market.uniqueKey, - relativeCap: relativeCapBigInt.toString(), - absoluteCap: '0', - } as VaultV2Cap; - }); - - if (capsToUpdate.length === 0) return; - - const success = await onUpdateCaps(capsToUpdate); - if (success) { - setIsEditingCaps(false); - } - }, [marketCaps, onUpdateCaps]); - - if (marketsLoading) { - return ( -
- -
- ); - } - - if (marketCaps.length === 0) { - return ( -
-

- No markets found for this vault's asset. Caps can be configured once markets are - available. -

-
- ); - } - - const currentCaps = existingCaps; - const hasAnyCaps = currentCaps.length > 0; - - const hasChanges = marketCaps.some((c) => { - const existingCap = existingCaps.find((ec) => ec.marketId === c.market.uniqueKey); - if (c.isSelected !== !!existingCap) return true; - if (c.isSelected) { - const existingRelative = existingCap - ? (parseFloat(existingCap.relativeCap) / 1e16).toString() - : ''; - return c.relativeCap !== existingRelative; - } - return false; - }); - - const selectedCount = marketCaps.filter((c) => c.isSelected).length; - - return ( -
- {/* Header */} -
-
-

Market Caps

-

- Maximum allocation per market -

-
- {!isEditingCaps && ( - - )} -
- - {!isEditingCaps ? ( - // Read-only view - Current caps - !hasAnyCaps ? ( -

No market caps configured

- ) : ( -
- {currentCaps.map((cap) => { - const market = marketCaps.find((m) => m.market.uniqueKey === cap.marketId)?.market; - if (!market) return null; - - const relativeCapPercent = (parseFloat(cap.relativeCap) / 1e16).toFixed(2); - - return ( -
-
-
-
- -
-
- -
-
-
-
- - {market.loanAsset.symbol} / {market.collateralAsset.symbol} - - - {formatUnits(BigInt(market.lltv), 16)}% LTV - -
-
-
-
-

{relativeCapPercent}%

-

max

-
-
- ); - })} -
- ) - ) : ( - // Edit mode - Market selection -
-

- Select markets and set caps. Total can exceed 100%. -

- - ({ - market: c.market, - isSelected: c.isSelected, - }))} - onToggleMarket={handleToggleMarket} - disabled={!isOwner} - renderCartItemExtra={(market) => { - const capState = marketCaps.find((c) => c.market.uniqueKey === market.uniqueKey); - if (!capState) return null; - - return ( -
- { - const value = e.target.value; - if (value === '' || /^\d*\.?\d*$/.test(value)) { - const numValue = parseFloat(value); - if (value === '' || (numValue >= 0 && numValue <= 100)) { - handleUpdateCapField(market.uniqueKey, value); - } - } - }} - placeholder="100" - disabled={!isOwner} - className="w-16 rounded border border-gray-200 bg-background px-2 py-1 text-right text-sm focus:border-primary focus:outline-none dark:border-gray-700" - /> - % max -
- ); - }} - /> - - {/* Action buttons */} -
-
- {selectedCount} market{selectedCount !== 1 ? 's' : ''} selected -
-
- - -
-
-
- )} -
+ return success; + }} + /> + ) : ( + setIsEditing(true)} + /> ); } diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx new file mode 100644 index 00000000..58d904ab --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx @@ -0,0 +1,94 @@ +import { useMemo } from 'react'; +import { Button } from '@/components/common/Button'; +import { MarketDetailsBlock } from '@/components/common/MarketDetailsBlock'; +import { Spinner } from '@/components/common/Spinner'; +import { VaultV2Cap } from '@/data-sources/subgraph/v2-vaults'; +import { useMarkets } from '@/hooks/useMarkets'; + +type CurrentAllocationsProps = { + existingCaps: VaultV2Cap[]; + isOwner: boolean; + onStartEdit: () => void; +}; + +export function CurrentAllocations({ + existingCaps, + isOwner, + onStartEdit, +}: CurrentAllocationsProps) { + const { markets, loading: marketsLoading } = useMarkets(); + const hasAnyCaps = existingCaps.length > 0; + + // Map existing caps to their market data + const marketsWithCaps = useMemo(() => { + return existingCaps + .map((cap) => { + // Use case-insensitive matching for marketId + const market = markets.find( + (m) => m.uniqueKey.toLowerCase() === cap.marketId.toLowerCase() + ); + if (!market) return null; + return { + market, + capPercent: (parseFloat(cap.relativeCap) / 1e16).toFixed(2), + }; + }) + .filter((item) => item !== null); + }, [existingCaps, markets]); + + if (marketsLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+

Market Allocation Caps

+

Maximum allocation percentage per market

+
+ {isOwner && ( + + )} +
+ + {!hasAnyCaps ? ( +
+

No market caps configured yet

+

+ Set caps to control how agents allocate funds across markets +

+
+ ) : ( +
+ {marketsWithCaps.map((item) => { + if (!item) return null; + const { market, capPercent } = item; + + return ( +
+ +
+ Maximum allocation cap + {capPercent}% +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx new file mode 100644 index 00000000..5ee1be1a --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx @@ -0,0 +1,231 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Address, parseUnits } from 'viem'; +import { Button } from '@/components/common/Button'; +import { MarketsTableWithSameLoanAsset } from '@/components/common/MarketsTableWithSameLoanAsset'; +import { Spinner } from '@/components/common/Spinner'; +import { VaultV2Cap } from '@/data-sources/subgraph/v2-vaults'; +import { useMarketNetwork } from '@/hooks/useMarketNetwork'; +import { useMarkets } from '@/hooks/useMarkets'; +import { SupportedNetworks } from '@/utils/networks'; +import { MarketCapState } from './types'; + +type EditAllocationsProps = { + existingCaps: VaultV2Cap[]; + vaultAsset?: Address; + chainId: SupportedNetworks; + isOwner: boolean; + isUpdating: boolean; + onCancel: () => void; + onSave: (caps: VaultV2Cap[]) => Promise; +}; + +export function EditAllocations({ + existingCaps, + vaultAsset, + chainId, + isOwner, + isUpdating, + onCancel, + onSave, +}: EditAllocationsProps) { + const [marketCaps, setMarketCaps] = useState([]); + const { markets, loading: marketsLoading } = useMarkets(); + const { needSwitchChain, switchToNetwork } = useMarketNetwork({ targetChainId: chainId }); + + // Initialize market caps from markets and existing data + useEffect(() => { + if (!markets || !vaultAsset) return; + + const filteredMarkets = markets.filter( + (m) => + m.loanAsset.address.toLowerCase() === vaultAsset.toLowerCase() && + m.morphoBlue.chain.id === chainId, + ); + + setMarketCaps( + filteredMarkets.map((market) => { + const existingCap = existingCaps.find( + (c) => c.marketId.toLowerCase() === market.uniqueKey.toLowerCase() + ); + return { + market, + relativeCap: existingCap ? (parseFloat(existingCap.relativeCap) / 1e16).toString() : '', + isSelected: !!existingCap, + }; + }), + ); + }, [markets, vaultAsset, chainId, existingCaps]); + + const handleToggleMarket = useCallback((marketId: string) => { + setMarketCaps((prev) => + prev.map((c) => { + if (c.market.uniqueKey === marketId) { + const newIsSelected = !c.isSelected; + return { + ...c, + isSelected: newIsSelected, + relativeCap: newIsSelected && !c.relativeCap ? '100' : c.relativeCap, + }; + } + return c; + }), + ); + }, []); + + const handleUpdateCapField = useCallback((marketId: string, value: string) => { + setMarketCaps((prev) => + prev.map((c) => (c.market.uniqueKey === marketId ? { ...c, relativeCap: value } : c)), + ); + }, []); + + const hasChanges = useMemo(() => { + return marketCaps.some((c) => { + const existingCap = existingCaps.find( + (ec) => ec.marketId.toLowerCase() === c.market.uniqueKey.toLowerCase() + ); + if (c.isSelected !== !!existingCap) return true; + if (c.isSelected) { + const existingRelative = existingCap + ? (parseFloat(existingCap.relativeCap) / 1e16).toString() + : ''; + return c.relativeCap !== existingRelative; + } + return false; + }); + }, [marketCaps, existingCaps]); + + const selectedCount = useMemo(() => { + return marketCaps.filter((c) => c.isSelected).length; + }, [marketCaps]); + + const handleSave = useCallback(async () => { + if (needSwitchChain) { + switchToNetwork(); + return; + } + + const capsToUpdate = marketCaps + .filter((c) => c.isSelected) + .map((c) => { + const relativeCapBigInt = + c.relativeCap && parseFloat(c.relativeCap) > 0 ? parseUnits(c.relativeCap, 16) : 0n; + + return { + marketId: c.market.uniqueKey, + relativeCap: relativeCapBigInt.toString(), + absoluteCap: '0', + } as VaultV2Cap; + }); + + if (capsToUpdate.length === 0) return; + + const success = await onSave(capsToUpdate); + if (success) { + // Parent will handle switching back to read mode + } + }, [marketCaps, needSwitchChain, switchToNetwork, onSave]); + + if (marketsLoading) { + return ( +
+ +
+ ); + } + + if (marketCaps.length === 0) { + return ( +
+

+ No markets found for this vault's asset. Caps can be configured once markets are + available. +

+ +
+ ); + } + + return ( +
+
+
+

Edit Market Caps

+

Select markets and set allocation caps

+
+
+ +
+
+

+ 💡 Set maximum allocation percentage for each market. Total can exceed 100% - agents + will rebalance proportionally. +

+
+ + ({ + market: c.market, + isSelected: c.isSelected, + }))} + onToggleMarket={handleToggleMarket} + disabled={!isOwner} + renderCartItemExtra={(market) => { + const capState = marketCaps.find((c) => c.market.uniqueKey === market.uniqueKey); + if (!capState) return null; + + return ( +
+ { + const value = e.target.value; + if (value === '' || /^\d*\.?\d*$/.test(value)) { + const numValue = parseFloat(value); + if (value === '' || (numValue >= 0 && numValue <= 100)) { + handleUpdateCapField(market.uniqueKey, value); + } + } + }} + placeholder="100" + disabled={!isOwner} + className="w-16 rounded border border-divider/30 bg-surface px-2 py-1 text-right text-sm shadow-sm focus:border-primary focus:outline-none" + /> + % +
+ ); + }} + /> + +
+
+ {selectedCount} market{selectedCount !== 1 ? 's' : ''} selected +
+
+ + +
+
+
+
+ ); +} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/GeneralTab.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/GeneralTab.tsx index c68831aa..3e3dda6b 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/GeneralTab.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/GeneralTab.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useId, useMemo, useState } from 'react'; import { Input } from '@heroui/react'; import { Button } from '@/components/common/Button'; import { Spinner } from '@/components/common/Spinner'; +import { useMarketNetwork } from '@/hooks/useMarketNetwork'; import { GeneralTabProps } from './types'; export function GeneralTab({ @@ -12,6 +13,7 @@ export function GeneralTab({ currentSymbol, onUpdateMetadata, updatingMetadata, + chainId, }: GeneralTabProps) { const nameInputId = useId(); const symbolInputId = useId(); @@ -23,6 +25,10 @@ export function GeneralTab({ const [symbolInput, setSymbolInput] = useState(previousSymbol || defaultSymbol); const [metadataError, setMetadataError] = useState(null); + const { needSwitchChain, switchToNetwork } = useMarketNetwork({ + targetChainId: chainId, + }); + // Reset inputs when current values change useEffect(() => { setNameInput(previousName || defaultName); @@ -53,6 +59,12 @@ export function GeneralTab({ setMetadataError(null); + // Switch network if needed + if (needSwitchChain) { + switchToNetwork(); + return; + } + const success = await onUpdateMetadata({ name: trimmedName !== previousName ? trimmedName || undefined : undefined, symbol: trimmedSymbol !== previousSymbol ? trimmedSymbol || undefined : undefined, @@ -61,7 +73,7 @@ export function GeneralTab({ if (success) { setMetadataError(null); } - }, [metadataChanged, onUpdateMetadata, previousName, previousSymbol, trimmedName, trimmedSymbol]); + }, [metadataChanged, onUpdateMetadata, previousName, previousSymbol, trimmedName, trimmedSymbol, needSwitchChain, switchToNetwork]); return (
@@ -122,6 +134,8 @@ export function GeneralTab({ Saving... + ) : needSwitchChain ? ( + 'Switch Network' ) : ( 'Save changes' )} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts b/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts index 595752ef..6ebba6e2 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts @@ -19,6 +19,7 @@ export type GeneralTabProps = { currentSymbol: string; onUpdateMetadata: (values: { name?: string; symbol?: string }) => Promise; updatingMetadata: boolean; + chainId: SupportedNetworks; }; export type AgentsTabProps = { @@ -29,6 +30,7 @@ export type AgentsTabProps = { sentinels?: string[]; onSetAllocator: (allocator: Address, isAllocator: boolean) => Promise; isUpdatingAllocator: boolean; + chainId: SupportedNetworks; }; export type AllocationsTabProps = { diff --git a/app/autovault/[chainId]/[vaultAddress]/content.tsx b/app/autovault/[chainId]/[vaultAddress]/content.tsx index 87b57a08..afb74bbf 100644 --- a/app/autovault/[chainId]/[vaultAddress]/content.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/content.tsx @@ -18,7 +18,6 @@ import { getSlicedAddress } from '@/utils/address'; import { formatBalance } from '@/utils/balance'; import { ALL_SUPPORTED_NETWORKS, SupportedNetworks, getNetworkConfig } from '@/utils/networks'; import { VaultAgentSummary } from './components/VaultAgentSummary'; -import { VaultApyHistory } from './components/VaultApyHistory'; import { VaultInitializationModal } from './components/VaultInitializationModal'; // Removed VaultMarketAllocations - will be re-added when real data is available import { VaultSettingsModal } from './components/VaultSettingsModal'; @@ -176,7 +175,7 @@ export default function VaultContent() { /> {isOwner && ( + ); + } + + // Step 2: Finalize setup -> move to agent selection + if (stepIndex === 2) { return ( ); } - // Step 2: Agent selection -> complete with optional agent + // Step 3: Agent selection -> complete with optional agent return ( <> + )} + {isOwner && ( + + )}
- {isOwner && ( - - )}
{!hasAnyCaps ? (
-

No market caps configured yet

+

No caps configured yet

Set caps to control how agents allocate funds across markets

) : ( -
- {marketsWithCaps.map((item) => { - if (!item) return null; - const { market, capPercent } = item; +
+ {/* Adapter Cap (if exists) */} + {adapterCap && ( +
+

Adapter Cap

+
+
+
+

Total adapter allocation limit

+
+
+
+ {(parseFloat((adapterCap as VaultV2Cap).relativeCap) / 1e16).toFixed(2)}% +
+
Relative cap
+
+
+
+
+ )} + + {/* Collateral Caps or Market Caps based on toggle */} + {!showDetailed ? ( + // Collateral-level caps (default view) + collateralCaps.length > 0 && ( +
+

+ Collateral Caps ({collateralCaps.length}) +

+
+ {collateralCapsWithData.map((item, index) => ( +
+
+
+

+ Collateral {index + 1} +

+

+ {item.collateralToken} +

+
+
+
+ {item.capPercent}% +
+
+ Abs: {item.absoluteCapFormatted} +
+
+
+
+ ))} +
+
+ ) + ) : ( + // Market-level caps (detailed view) + marketCaps.length > 0 && ( +
+

+ Market Caps ({marketCaps.length}) +

+
+ {marketsWithCaps.map((item) => { + if (!item) return null; + const { market, capPercent, absoluteCapFormatted } = item; - return ( -
- -
- Maximum allocation cap - {capPercent}% + return ( +
+ +
+ Maximum allocation cap +
+ {capPercent}% +
Abs: {absoluteCapFormatted}
+
+
+
+ ); + })}
- ); - })} + ) + )}
)}
diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx index 5ee1be1a..2530cca7 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx @@ -6,6 +6,7 @@ import { Spinner } from '@/components/common/Spinner'; import { VaultV2Cap } from '@/data-sources/subgraph/v2-vaults'; import { useMarketNetwork } from '@/hooks/useMarketNetwork'; import { useMarkets } from '@/hooks/useMarkets'; +import { getMarketCapId, parseCapId } from '@/utils/morpho'; import { SupportedNetworks } from '@/utils/networks'; import { MarketCapState } from './types'; @@ -15,6 +16,7 @@ type EditAllocationsProps = { chainId: SupportedNetworks; isOwner: boolean; isUpdating: boolean; + adapterAddress?: Address; onCancel: () => void; onSave: (caps: VaultV2Cap[]) => Promise; }; @@ -25,6 +27,7 @@ export function EditAllocations({ chainId, isOwner, isUpdating, + adapterAddress, onCancel, onSave, }: EditAllocationsProps) { @@ -44,9 +47,10 @@ export function EditAllocations({ setMarketCaps( filteredMarkets.map((market) => { - const existingCap = existingCaps.find( - (c) => c.marketId.toLowerCase() === market.uniqueKey.toLowerCase() - ); + const existingCap = existingCaps.find((c) => { + const parsed = parseCapId(c.capId); + return parsed.marketId?.toLowerCase() === market.uniqueKey.toLowerCase(); + }); return { market, relativeCap: existingCap ? (parseFloat(existingCap.relativeCap) / 1e16).toString() : '', @@ -80,9 +84,10 @@ export function EditAllocations({ const hasChanges = useMemo(() => { return marketCaps.some((c) => { - const existingCap = existingCaps.find( - (ec) => ec.marketId.toLowerCase() === c.market.uniqueKey.toLowerCase() - ); + const existingCap = existingCaps.find((ec) => { + const parsed = parseCapId(ec.capId); + return parsed.marketId?.toLowerCase() === c.market.uniqueKey.toLowerCase(); + }); if (c.isSelected !== !!existingCap) return true; if (c.isSelected) { const existingRelative = existingCap @@ -104,14 +109,22 @@ export function EditAllocations({ return; } + if (!adapterAddress) { + console.error('Adapter address is required to save caps'); + return; + } + const capsToUpdate = marketCaps .filter((c) => c.isSelected) .map((c) => { const relativeCapBigInt = c.relativeCap && parseFloat(c.relativeCap) > 0 ? parseUnits(c.relativeCap, 16) : 0n; + const capId = getMarketCapId(adapterAddress, c.market.uniqueKey); + return { - marketId: c.market.uniqueKey, + capId, + idParams: c.market.uniqueKey, // Store the market ID as idParams for reference relativeCap: relativeCapBigInt.toString(), absoluteCap: '0', } as VaultV2Cap; diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts b/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts index 6ebba6e2..26b89929 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts @@ -37,6 +37,7 @@ export type AllocationsTabProps = { isOwner: boolean; chainId: SupportedNetworks; vaultAsset?: Address; + adapterAddress?: Address; existingCaps: VaultV2Cap[]; onUpdateCaps: (caps: VaultV2Cap[]) => Promise; isUpdatingCaps: boolean; diff --git a/app/autovault/[chainId]/[vaultAddress]/content.tsx b/app/autovault/[chainId]/[vaultAddress]/content.tsx index afb74bbf..a24b1424 100644 --- a/app/autovault/[chainId]/[vaultAddress]/content.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/content.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { GearIcon } from '@radix-ui/react-icons'; import Link from 'next/link'; import { useParams } from 'next/navigation'; @@ -50,7 +50,28 @@ export default function VaultContent() { } }, [supportedChainId]); + const [settingsTab, setSettingsTab] = useState<'general' | 'agents' | 'allocations'>('general'); + const [showSettings, setShowSettings] = useState(false); + const [showInitializationModal, setShowInitializationModal] = useState(false); + + const fallbackTitle = `Vault ${getSlicedAddress(vaultAddressValue)}`; + const { + data: vaultData, + loading: vaultDataLoading, + error: vaultDataError, + refetch: refetchVaultData, + } = useVaultV2Data({ + vaultAddress: vaultAddressValue, + chainId: supportedChainId, + }); + + // Stabilize the callback to prevent infinite re-renders + const handleTransactionSuccess = useCallback(() => { + void refetchVaultData(); + }, [refetchVaultData]); + const { + adapter, needsSetup, isLoading: adapterLoading, refetch: refetchAdapter, @@ -65,22 +86,7 @@ export default function VaultContent() { } = useVaultV2({ vaultAddress: vaultAddressValue, chainId: supportedChainId, - onTransactionSuccess: () => void refetchVaultData(), - }); - - const [settingsTab, setSettingsTab] = useState<'general' | 'agents' | 'allocations'>('general'); - const [showSettings, setShowSettings] = useState(false); - const [showInitializationModal, setShowInitializationModal] = useState(false); - - const fallbackTitle = `Vault ${getSlicedAddress(vaultAddressValue)}`; - const { - data: vaultData, - loading: vaultDataLoading, - error: vaultDataError, - refetch: refetchVaultData, - } = useVaultV2Data({ - vaultAddress: vaultAddressValue, - chainId: supportedChainId, + onTransactionSuccess: handleTransactionSuccess, }); // Use vaultData for owner check (from subgraph) @@ -100,6 +106,8 @@ export default function VaultContent() { const hasNoAllocators = !needsSetup && allocatorCount === 0; const hasNoCaps = !needsSetup && allocatorCount > 0 && caps.length === 0; + console.log('caps', caps) + const roleStatusText = useMemo(() => { if (needsSetup) return 'Adapter pending deployment'; if (hasNoAllocators) return 'Choose agents to enable automation'; @@ -327,6 +335,7 @@ export default function VaultContent() { sentinels={sentinels} chainId={supportedChainId} vaultAsset={assetAddress as Address | undefined} + adapterAddress={adapter} existingCaps={caps} onSetAllocator={setAllocator} onUpdateCaps={updateCaps} diff --git a/src/data-sources/subgraph/v2-vaults.ts b/src/data-sources/subgraph/v2-vaults.ts index 4fe15366..1968a099 100644 --- a/src/data-sources/subgraph/v2-vaults.ts +++ b/src/data-sources/subgraph/v2-vaults.ts @@ -28,7 +28,8 @@ export type UserVaultV2 = SubgraphVaultV2 & { export type VaultV2Cap = { relativeCap: string; absoluteCap: string; - marketId: string; + capId: string; + idParams: string; }; export type VaultV2Details = { diff --git a/src/hooks/useVaultV2.ts b/src/hooks/useVaultV2.ts index e6fefc06..10f91ef3 100644 --- a/src/hooks/useVaultV2.ts +++ b/src/hooks/useVaultV2.ts @@ -356,7 +356,7 @@ export function useVaultV2({ caps.forEach((cap) => { const relativeCapBigInt = BigInt(cap.relativeCap); const absoluteCapBigInt = BigInt(cap.absoluteCap); - const idData = cap.marketId as `0x${string}`; + const idData = cap.capId as `0x${string}`; // For updates, we always increase caps (curator can decrease if needed) if (relativeCapBigInt > 0n) { diff --git a/src/hooks/useVaultV2Data.ts b/src/hooks/useVaultV2Data.ts index 1d4a7a5f..59a01e1c 100644 --- a/src/hooks/useVaultV2Data.ts +++ b/src/hooks/useVaultV2Data.ts @@ -3,6 +3,7 @@ import { Address } from 'viem'; import { useTokens } from '@/components/providers/TokenProvider'; import { fetchVaultV2Details, VaultV2Cap } from '@/data-sources/subgraph/v2-vaults'; import { getSlicedAddress } from '@/utils/address'; +import { parseCapId } from '@/utils/morpho'; import { SupportedNetworks } from '@/utils/networks'; type UseVaultV2DataArgs = { @@ -26,6 +27,10 @@ export type VaultV2Data = { caps: VaultV2Cap[]; adopters: string[]; curatorDisplay: string; + // Parsed caps by level + adapterCap: VaultV2Cap | null; + collateralCaps: VaultV2Cap[]; + marketCaps: VaultV2Cap[]; }; type UseVaultV2DataReturn = { @@ -67,6 +72,24 @@ export function useVaultV2Data({ const token = result.asset ? findToken(result.asset, chainId) : undefined; const curatorDisplay = result.curator ? getSlicedAddress(result.curator as Address) : '--'; + // Parse caps by level using parseCapId + // TODO: User will implement the actual parsing logic in parseCapId function + let adapterCap: VaultV2Cap | null = null; + const collateralCaps: VaultV2Cap[] = []; + const marketCaps: VaultV2Cap[] = []; + + result.caps.forEach((cap) => { + const parsed = parseCapId(cap.capId); + + if (parsed.type === 'adapter') { + adapterCap = cap; + } else if (parsed.type === 'collateral') { + collateralCaps.push(cap); + } else if (parsed.type === 'market') { + marketCaps.push(cap); + } + }); + setData({ displayName: result.name || fallbackName, displaySymbol: result.symbol || fallbackSymbol, @@ -81,6 +104,9 @@ export function useVaultV2Data({ caps: result.caps, adopters: result.adopters, curatorDisplay, + adapterCap, + collateralCaps, + marketCaps, }); } catch (err) { setError(err instanceof Error ? err : new Error('Failed to fetch vault data')); @@ -88,19 +114,25 @@ export function useVaultV2Data({ } finally { setLoading(false); } - }, [vaultAddress, chainId, fallbackName, fallbackSymbol, findToken]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [vaultAddress, chainId]); useEffect(() => { void load(); }, [load]); + // Memoize the refetch function to prevent unnecessary re-renders in parent components + const refetch = useCallback(async () => { + await load(); + }, [load]); + return useMemo( () => ({ data, loading, error, - refetch: load, + refetch, }), - [data, error, load, loading], + [data, error, loading, refetch], ); } diff --git a/src/utils/morpho.ts b/src/utils/morpho.ts index 60638585..b00d034f 100644 --- a/src/utils/morpho.ts +++ b/src/utils/morpho.ts @@ -95,3 +95,88 @@ export function getMorphoGenesisDate(chainId: number): Date { return MAINNET_GENESIS_DATE; // default to mainnet } } + +// ============================================================================ +// Cap ID Utilities for Morpho Market Adapters +// ============================================================================ + +/** + * Generates the cap ID for an adapter-level cap. + * This is the highest level cap that applies to all markets under this adapter. + * + * @param adapterAddress - The address of the Morpho market adapter + * @returns The hashed cap ID for the adapter + * + * TODO: Implement the actual hashing logic + */ +export function getAdapterCapId(adapterAddress: string): string { + // TODO: Implement hashing logic for adapter cap ID + // This should hash the adapter address to create a unique cap ID + return `adapter-${adapterAddress}`; +} + +/** + * Generates the cap ID for a collateral-level cap. + * This aggregates all markets with the same collateral token. + * + * @param adapterAddress - The address of the Morpho market adapter + * @param collateralToken - The address of the collateral token + * @returns The hashed cap ID for the collateral + * + * TODO: Implement the actual hashing logic + */ +export function getCollateralCapId(adapterAddress: string, collateralToken: string): string { + // TODO: Implement hashing logic for collateral cap ID + // This should hash adapter + collateral token to create a unique cap ID + return `collateral-${adapterAddress}-${collateralToken}`; +} + +/** + * Generates the cap ID for a market-level cap. + * This is the most granular level, specific to individual markets. + * + * @param adapterAddress - The address of the Morpho market adapter + * @param marketId - The unique market identifier + * @returns The hashed cap ID for the market + * + * TODO: Implement the actual hashing logic + */ +export function getMarketCapId(adapterAddress: string, marketId: string): string { + // TODO: Implement hashing logic for market cap ID + // This should hash adapter + market ID to create a unique cap ID + return `market-${adapterAddress}-${marketId}`; +} + +/** + * Parses a cap ID to determine its type and extract the original parameters. + * + * @param capId - The hashed cap ID to parse + * @returns An object containing the cap type and extracted parameters + * + * TODO: Implement the actual parsing logic + */ +export function parseCapId(capId: string): { + type: 'adapter' | 'collateral' | 'market'; + adapterAddress?: string; + collateralToken?: string; + marketId?: string; +} { + // TODO: Implement parsing logic to reverse-engineer the cap ID + // This should determine what type of cap it is and extract the relevant addresses/IDs + + // Temporary placeholder logic for development + if (capId.startsWith('adapter-')) { + return { type: 'adapter', adapterAddress: capId.replace('adapter-', '') }; + } + if (capId.startsWith('collateral-')) { + const parts = capId.replace('collateral-', '').split('-'); + return { type: 'collateral', adapterAddress: parts[0], collateralToken: parts[1] }; + } + if (capId.startsWith('market-')) { + const parts = capId.replace('market-', '').split('-'); + return { type: 'market', adapterAddress: parts[0], marketId: parts[1] }; + } + + // Default fallback + return { type: 'market' }; +} From e7e8255fcd1e0fe77be1a5d5753ff7da45cd38df Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 16 Oct 2025 14:58:39 +0800 Subject: [PATCH 13/29] chore: parsing logic --- .../components/VaultSettingsModal.tsx | 2 +- .../settings/CurrentAllocations.tsx | 8 +- .../components/settings/EditAllocations.tsx | 2 +- .../components/settings/types.ts | 2 +- src/data-sources/morpho-api/v2-vaults.ts | 148 ++++++++++++++++++ src/graphql/morpho-api-queries.ts | 45 ++++++ src/hooks/useVaultV2.ts | 2 +- src/hooks/useVaultV2Data.ts | 4 +- src/utils/morpho.ts | 99 ++++++------ src/utils/types.ts | 8 + 10 files changed, 256 insertions(+), 64 deletions(-) create mode 100644 src/data-sources/morpho-api/v2-vaults.ts diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx index fd35583e..f3ebb092 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { LuX } from 'react-icons/lu'; import { Address } from 'viem'; -import { VaultV2Cap } from '@/data-sources/subgraph/v2-vaults'; +import { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { SupportedNetworks } from '@/utils/networks'; import { GeneralTab, AgentsTab, AllocationsTab, SettingsTab } from './settings'; diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx index d70f3ac5..4c92b0ee 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'; import { Button } from '@/components/common/Button'; import { MarketDetailsBlock } from '@/components/common/MarketDetailsBlock'; import { Spinner } from '@/components/common/Spinner'; -import { VaultV2Cap } from '@/data-sources/subgraph/v2-vaults'; +import { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { useMarkets } from '@/hooks/useMarkets'; import { parseCapId } from '@/utils/morpho'; @@ -27,7 +27,7 @@ export function CurrentAllocations({ const marketCaps: VaultV2Cap[] = []; existingCaps.forEach((cap) => { - const parsed = parseCapId(cap.capId); + const parsed = parseCapId(cap.idParams, cap.capId); if (parsed.type === 'adapter') { adapterCap = cap; } else if (parsed.type === 'collateral') { @@ -47,7 +47,7 @@ export function CurrentAllocations({ // Map collateral caps to display data const collateralCapsWithData = useMemo(() => { return collateralCaps.map((cap) => { - const parsed = parseCapId(cap.capId); + const parsed = parseCapId(cap.idParams, cap.capId) return { cap, collateralToken: parsed.collateralToken ?? 'Unknown', @@ -61,7 +61,7 @@ export function CurrentAllocations({ const marketsWithCaps = useMemo(() => { return marketCaps .map((cap) => { - const parsed = parseCapId(cap.capId); + const parsed = parseCapId(cap.idParams, cap.capId) // Use case-insensitive matching for marketId const market = markets.find( (m) => m.uniqueKey.toLowerCase() === (parsed.marketId ?? '').toLowerCase() diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx index 2530cca7..e9ad8d5f 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx @@ -3,7 +3,7 @@ import { Address, parseUnits } from 'viem'; import { Button } from '@/components/common/Button'; import { MarketsTableWithSameLoanAsset } from '@/components/common/MarketsTableWithSameLoanAsset'; import { Spinner } from '@/components/common/Spinner'; -import { VaultV2Cap } from '@/data-sources/subgraph/v2-vaults'; +import { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { useMarketNetwork } from '@/hooks/useMarketNetwork'; import { useMarkets } from '@/hooks/useMarkets'; import { getMarketCapId, parseCapId } from '@/utils/morpho'; diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts b/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts index 26b89929..d5d62d47 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts @@ -1,5 +1,5 @@ import { Address } from 'viem'; -import { VaultV2Cap } from '@/data-sources/subgraph/v2-vaults'; +import { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { SupportedNetworks } from '@/utils/networks'; import { Market } from '@/utils/types'; diff --git a/src/data-sources/morpho-api/v2-vaults.ts b/src/data-sources/morpho-api/v2-vaults.ts new file mode 100644 index 00000000..ee895b46 --- /dev/null +++ b/src/data-sources/morpho-api/v2-vaults.ts @@ -0,0 +1,148 @@ +import { vaultV2Query } from '@/graphql/morpho-api-queries'; +import { SupportedNetworks } from '@/utils/networks'; +import { morphoGraphqlFetcher } from './fetchers'; + +// Re-export types from subgraph to maintain compatibility +// These types match the API response structure +export type VaultV2Cap = { + relativeCap: string; + absoluteCap: string; + capId: string; + idParams: string; +}; + +export type VaultV2Details = { + id: string; + asset: string; + symbol: string; + name: string; + curator: string; + owner: string; + allocators: string[]; + sentinels: string[]; + caps: VaultV2Cap[]; + totalSupply: string; + adopters: string[]; + avgApy?: number; +}; + +// API response types +type ApiVaultV2Cap = { + id: string; + idData: string; + absoluteCap: number | string; + relativeCap: string; +}; + +type ApiVaultV2 = { + id: string; + address: string; + name: string; + symbol: string; + avgApy: number; + totalSupply: string | number; + asset: { + id: string; + address: string; + symbol: string; + name: string; + decimals: number; + }; + curator: { + address: string; + } | null; + owner: { + address: string; + } | null; + allocators: Array<{ + allocator: { + address: string; + }; + }>; + caps: { + items: ApiVaultV2Cap[]; + }; +}; + +type VaultV2ApiResponse = { + data: { + vaultV2s: { + items: ApiVaultV2[]; + }; + }; + errors?: Array<{ message: string }>; +}; + +/** + * Transforms API cap response to internal VaultV2Cap format + */ +function transformCap(apiCap: ApiVaultV2Cap): VaultV2Cap { + return { + capId: apiCap.id, + idParams: apiCap.idData, + absoluteCap: String(apiCap.absoluteCap), + relativeCap: apiCap.relativeCap, + }; +} + +/** + * Transforms API vault response to internal VaultV2Details format + */ +function transformVault(apiVault: ApiVaultV2): VaultV2Details { + return { + id: apiVault.id, + asset: apiVault.asset.address, + symbol: apiVault.symbol, + name: apiVault.name, + curator: apiVault.curator?.address ?? '', + owner: apiVault.owner?.address ?? '', + allocators: apiVault.allocators.map((a) => a.allocator.address), + sentinels: [], // Not available in API response + caps: apiVault.caps.items.map(transformCap), + totalSupply: String(apiVault.totalSupply), + adopters: [], // Not available in API response + avgApy: apiVault.avgApy, + }; +} + +/** + * Fetches VaultV2 details from Morpho API + * + * @param vaultAddress - The vault address + * @param network - The network/chain ID + * @returns VaultV2Details or null if not found + */ +export const fetchVaultV2Details = async ( + vaultAddress: string, + network: SupportedNetworks, +): Promise => { + try { + const variables = { + address: vaultAddress.toLowerCase(), + chainId: network, + }; + + const response = await morphoGraphqlFetcher(vaultV2Query, variables); + + if (response.errors && response.errors.length > 0) { + console.error('GraphQL errors:', response.errors); + return null; + } + + const vaults = response.data?.vaultV2s?.items; + if (!vaults || vaults.length === 0) { + console.log(`No V2 vault found for address ${vaultAddress} on network ${network}`); + return null; + } + + // Since we're querying by specific address, we should only get one result + const vault = vaults[0]; + return transformVault(vault); + } catch (error) { + console.error( + `Error fetching V2 vault details for ${vaultAddress} on network ${network}:`, + error, + ); + return null; + } +}; diff --git a/src/graphql/morpho-api-queries.ts b/src/graphql/morpho-api-queries.ts index faa31252..05eb91a8 100644 --- a/src/graphql/morpho-api-queries.ts +++ b/src/graphql/morpho-api-queries.ts @@ -493,3 +493,48 @@ export const marketBorrowsQuery = ` } } `; + +// Query for VaultV2 details from Morpho API +export const vaultV2Query = ` + query VaultV2Query($address: String!, $chainId: Int!) { + vaultV2s(where: { + chainId_in: [$chainId], + address_in: [$address] + }) { + items { + id + address + name + symbol + avgApy + totalSupply + asset { + id + address + symbol + name + decimals + } + curator { + address + } + owner { + address + } + allocators { + allocator { + address + } + } + caps { + items { + id + idData + absoluteCap + relativeCap + } + } + } + } + } +`; diff --git a/src/hooks/useVaultV2.ts b/src/hooks/useVaultV2.ts index 10f91ef3..b931373c 100644 --- a/src/hooks/useVaultV2.ts +++ b/src/hooks/useVaultV2.ts @@ -2,7 +2,7 @@ import { useCallback, useMemo } from 'react'; import { Address, encodeFunctionData, toFunctionSelector, zeroAddress } from 'viem'; import { useAccount, useChainId, useReadContract } from 'wagmi'; import { vaultv2Abi } from '@/abis/vaultv2'; -import { VaultV2Cap } from '@/data-sources/subgraph/v2-vaults'; +import { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { SupportedNetworks } from '@/utils/networks'; import { useTransactionWithToast } from './useTransactionWithToast'; diff --git a/src/hooks/useVaultV2Data.ts b/src/hooks/useVaultV2Data.ts index 59a01e1c..7323c10d 100644 --- a/src/hooks/useVaultV2Data.ts +++ b/src/hooks/useVaultV2Data.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Address } from 'viem'; import { useTokens } from '@/components/providers/TokenProvider'; -import { fetchVaultV2Details, VaultV2Cap } from '@/data-sources/subgraph/v2-vaults'; +import { fetchVaultV2Details, VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { getSlicedAddress } from '@/utils/address'; import { parseCapId } from '@/utils/morpho'; import { SupportedNetworks } from '@/utils/networks'; @@ -79,7 +79,7 @@ export function useVaultV2Data({ const marketCaps: VaultV2Cap[] = []; result.caps.forEach((cap) => { - const parsed = parseCapId(cap.capId); + const parsed = parseCapId(cap.idParams, cap.capId); if (parsed.type === 'adapter') { adapterCap = cap; diff --git a/src/utils/morpho.ts b/src/utils/morpho.ts index b00d034f..3268d2bc 100644 --- a/src/utils/morpho.ts +++ b/src/utils/morpho.ts @@ -1,6 +1,7 @@ -import { zeroAddress } from 'viem'; +import { Address, encodeAbiParameters, keccak256, parseAbiParameters, zeroAddress } from 'viem'; import { SupportedNetworks } from './networks'; -import { UserTxTypes } from './types'; +import { MarketParams, UserTxTypes } from './types'; +import abi from '@/abis/permit2'; // appended to the end of datahash to identify a monarch tx export const MONARCH_TX_IDENTIFIER = 'beef'; @@ -100,69 +101,59 @@ export function getMorphoGenesisDate(chainId: number): Date { // Cap ID Utilities for Morpho Market Adapters // ============================================================================ -/** - * Generates the cap ID for an adapter-level cap. - * This is the highest level cap that applies to all markets under this adapter. - * - * @param adapterAddress - The address of the Morpho market adapter - * @returns The hashed cap ID for the adapter - * - * TODO: Implement the actual hashing logic - */ -export function getAdapterCapId(adapterAddress: string): string { - // TODO: Implement hashing logic for adapter cap ID - // This should hash the adapter address to create a unique cap ID - return `adapter-${adapterAddress}`; + +export function getAdapterCapId(adapterAddress: Address): {params: string, id: string} { + // Solidity + // adapterId = keccak256(abi.encode("this", address(this))); + const params = encodeAbiParameters( + [{ type: 'string' }, { type: 'address' }], + ["this", adapterAddress] + ) + + return { params, id: keccak256(params)} } -/** - * Generates the cap ID for a collateral-level cap. - * This aggregates all markets with the same collateral token. - * - * @param adapterAddress - The address of the Morpho market adapter - * @param collateralToken - The address of the collateral token - * @returns The hashed cap ID for the collateral - * - * TODO: Implement the actual hashing logic - */ -export function getCollateralCapId(adapterAddress: string, collateralToken: string): string { - // TODO: Implement hashing logic for collateral cap ID - // This should hash adapter + collateral token to create a unique cap ID - return `collateral-${adapterAddress}-${collateralToken}`; +export function getCollateralCapId(collateralToken: Address): {params: string, id: string} { + // Solidity + // id = keccak256(abi.encode("collateralToken", marketParams.collateralToken)); + const params = encodeAbiParameters( + [{ type: 'string' }, { type: 'address' }], + ["collateralToken", collateralToken] + ) + + return { params, id: keccak256(params)} } -/** - * Generates the cap ID for a market-level cap. - * This is the most granular level, specific to individual markets. - * - * @param adapterAddress - The address of the Morpho market adapter - * @param marketId - The unique market identifier - * @returns The hashed cap ID for the market - * - * TODO: Implement the actual hashing logic - */ -export function getMarketCapId(adapterAddress: string, marketId: string): string { - // TODO: Implement hashing logic for market cap ID - // This should hash adapter + market ID to create a unique cap ID - return `market-${adapterAddress}-${marketId}`; +export function getMarketCapId(adopterAddress: Address, marketParams: MarketParams): {params: string, id: string} { + // Solidity + // id = keccak256(abi.encode("this/marketParams", address(this), marketParams)); + const marketParamsType = parseAbiParameters('(address loanToken, address collateralToken, address oracle, address irm, uint256 lltv)') + + const encoded = encodeAbiParameters( + [ + { type: 'string' }, + { type: 'address' }, + { type: 'tuple', components: marketParamsType } + ], + [ + 'this/marketParams', + adopterAddress, + [marketParams] + ] + ) + const id = keccak256(encoded) + + return { params: encoded, id } } -/** - * Parses a cap ID to determine its type and extract the original parameters. - * - * @param capId - The hashed cap ID to parse - * @returns An object containing the cap type and extracted parameters - * - * TODO: Implement the actual parsing logic - */ -export function parseCapId(capId: string): { +export function parseCapId(idParams: string, capId: string): { type: 'adapter' | 'collateral' | 'market'; adapterAddress?: string; collateralToken?: string; marketId?: string; } { - // TODO: Implement parsing logic to reverse-engineer the cap ID - // This should determine what type of cap it is and extract the relevant addresses/IDs + + // Temporary placeholder logic for development if (capId.startsWith('adapter-')) { diff --git a/src/utils/types.ts b/src/utils/types.ts index b7314237..fc53d334 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -1,6 +1,14 @@ import { Address } from 'viem'; import { SupportedNetworks } from './networks'; +export type MarketParams = { + loanToken: Address, + collateralToken: Address + oracle: Address, + irm: Address + lltv: bigint +} + export type MarketPosition = { state: { supplyShares: string; From ca87d15b107d7cb9d709b26986055f07ff1857c0 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 16 Oct 2025 15:19:37 +0800 Subject: [PATCH 14/29] chore: new parsing logic --- .../settings/CurrentAllocations.tsx | 8 +- .../components/settings/EditAllocations.tsx | 21 ++- src/hooks/useVaultV2Data.ts | 7 +- src/utils/morpho.ts | 123 ++++++++++++++---- 4 files changed, 120 insertions(+), 39 deletions(-) diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx index 4c92b0ee..d131b42b 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx @@ -4,7 +4,7 @@ import { MarketDetailsBlock } from '@/components/common/MarketDetailsBlock'; import { Spinner } from '@/components/common/Spinner'; import { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { useMarkets } from '@/hooks/useMarkets'; -import { parseCapId } from '@/utils/morpho'; +import { parseCapIdParams } from '@/utils/morpho'; type CurrentAllocationsProps = { existingCaps: VaultV2Cap[]; @@ -27,7 +27,7 @@ export function CurrentAllocations({ const marketCaps: VaultV2Cap[] = []; existingCaps.forEach((cap) => { - const parsed = parseCapId(cap.idParams, cap.capId); + const parsed = parseCapIdParams(cap.idParams); if (parsed.type === 'adapter') { adapterCap = cap; } else if (parsed.type === 'collateral') { @@ -47,7 +47,7 @@ export function CurrentAllocations({ // Map collateral caps to display data const collateralCapsWithData = useMemo(() => { return collateralCaps.map((cap) => { - const parsed = parseCapId(cap.idParams, cap.capId) + const parsed = parseCapIdParams(cap.idParams); return { cap, collateralToken: parsed.collateralToken ?? 'Unknown', @@ -61,7 +61,7 @@ export function CurrentAllocations({ const marketsWithCaps = useMemo(() => { return marketCaps .map((cap) => { - const parsed = parseCapId(cap.idParams, cap.capId) + const parsed = parseCapIdParams(cap.idParams); // Use case-insensitive matching for marketId const market = markets.find( (m) => m.uniqueKey.toLowerCase() === (parsed.marketId ?? '').toLowerCase() diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx index e9ad8d5f..c73457f4 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx @@ -6,7 +6,7 @@ import { Spinner } from '@/components/common/Spinner'; import { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { useMarketNetwork } from '@/hooks/useMarketNetwork'; import { useMarkets } from '@/hooks/useMarkets'; -import { getMarketCapId, parseCapId } from '@/utils/morpho'; +import { getMarketCapId, parseCapIdParams } from '@/utils/morpho'; import { SupportedNetworks } from '@/utils/networks'; import { MarketCapState } from './types'; @@ -48,7 +48,7 @@ export function EditAllocations({ setMarketCaps( filteredMarkets.map((market) => { const existingCap = existingCaps.find((c) => { - const parsed = parseCapId(c.capId); + const parsed = parseCapIdParams(c.idParams); return parsed.marketId?.toLowerCase() === market.uniqueKey.toLowerCase(); }); return { @@ -85,7 +85,7 @@ export function EditAllocations({ const hasChanges = useMemo(() => { return marketCaps.some((c) => { const existingCap = existingCaps.find((ec) => { - const parsed = parseCapId(ec.capId); + const parsed = parseCapIdParams(ec.idParams); return parsed.marketId?.toLowerCase() === c.market.uniqueKey.toLowerCase(); }); if (c.isSelected !== !!existingCap) return true; @@ -120,11 +120,20 @@ export function EditAllocations({ const relativeCapBigInt = c.relativeCap && parseFloat(c.relativeCap) > 0 ? parseUnits(c.relativeCap, 16) : 0n; - const capId = getMarketCapId(adapterAddress, c.market.uniqueKey); + // Create MarketParams from the market object + const marketParams = { + loanToken: c.market.loanAsset.address as Address, + collateralToken: c.market.collateralAsset.address as Address, + oracle: c.market.oracleAddress as Address, + irm: c.market.irmAddress as Address, + lltv: BigInt(c.market.lltv), + }; + + const { params, id } = getMarketCapId(adapterAddress, marketParams); return { - capId, - idParams: c.market.uniqueKey, // Store the market ID as idParams for reference + capId: id, + idParams: params, relativeCap: relativeCapBigInt.toString(), absoluteCap: '0', } as VaultV2Cap; diff --git a/src/hooks/useVaultV2Data.ts b/src/hooks/useVaultV2Data.ts index 7323c10d..3e57323f 100644 --- a/src/hooks/useVaultV2Data.ts +++ b/src/hooks/useVaultV2Data.ts @@ -3,7 +3,7 @@ import { Address } from 'viem'; import { useTokens } from '@/components/providers/TokenProvider'; import { fetchVaultV2Details, VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { getSlicedAddress } from '@/utils/address'; -import { parseCapId } from '@/utils/morpho'; +import { parseCapIdParams } from '@/utils/morpho'; import { SupportedNetworks } from '@/utils/networks'; type UseVaultV2DataArgs = { @@ -72,14 +72,13 @@ export function useVaultV2Data({ const token = result.asset ? findToken(result.asset, chainId) : undefined; const curatorDisplay = result.curator ? getSlicedAddress(result.curator as Address) : '--'; - // Parse caps by level using parseCapId - // TODO: User will implement the actual parsing logic in parseCapId function + // Parse caps by level using parseCapIdParams let adapterCap: VaultV2Cap | null = null; const collateralCaps: VaultV2Cap[] = []; const marketCaps: VaultV2Cap[] = []; result.caps.forEach((cap) => { - const parsed = parseCapId(cap.idParams, cap.capId); + const parsed = parseCapIdParams(cap.idParams); if (parsed.type === 'adapter') { adapterCap = cap; diff --git a/src/utils/morpho.ts b/src/utils/morpho.ts index 3268d2bc..902e9d95 100644 --- a/src/utils/morpho.ts +++ b/src/utils/morpho.ts @@ -1,4 +1,4 @@ -import { Address, encodeAbiParameters, keccak256, parseAbiParameters, zeroAddress } from 'viem'; +import { Address, decodeAbiParameters, encodeAbiParameters, keccak256, parseAbiParameters, zeroAddress } from 'viem'; import { SupportedNetworks } from './networks'; import { MarketParams, UserTxTypes } from './types'; import abi from '@/abis/permit2'; @@ -127,18 +127,31 @@ export function getCollateralCapId(collateralToken: Address): {params: string, i export function getMarketCapId(adopterAddress: Address, marketParams: MarketParams): {params: string, id: string} { // Solidity // id = keccak256(abi.encode("this/marketParams", address(this), marketParams)); - const marketParamsType = parseAbiParameters('(address loanToken, address collateralToken, address oracle, address irm, uint256 lltv)') - const encoded = encodeAbiParameters( [ { type: 'string' }, { type: 'address' }, - { type: 'tuple', components: marketParamsType } + { + type: 'tuple', + components: [ + { type: 'address', name: 'loanToken' }, + { type: 'address', name: 'collateralToken' }, + { type: 'address', name: 'oracle' }, + { type: 'address', name: 'irm' }, + { type: 'uint256', name: 'lltv' } + ] + } ], [ 'this/marketParams', adopterAddress, - [marketParams] + { + loanToken: marketParams.loanToken, + collateralToken: marketParams.collateralToken, + oracle: marketParams.oracle, + irm: marketParams.irm, + lltv: marketParams.lltv + } ] ) const id = keccak256(encoded) @@ -146,28 +159,88 @@ export function getMarketCapId(adopterAddress: Address, marketParams: MarketPara return { params: encoded, id } } -export function parseCapId(idParams: string, capId: string): { +/** + * Parses the encoded idParams to determine the cap type and extract relevant data. + * + * @param idParams - The encoded ABI parameters (hex string starting with 0x) + * @returns Object containing the cap type and extracted addresses/marketId + */ +export function parseCapIdParams(idParams: string): { type: 'adapter' | 'collateral' | 'market'; - adapterAddress?: string; - collateralToken?: string; + adapterAddress?: Address; + collateralToken?: Address; + marketParams?: MarketParams; marketId?: string; } { - - - - // Temporary placeholder logic for development - if (capId.startsWith('adapter-')) { - return { type: 'adapter', adapterAddress: capId.replace('adapter-', '') }; + try { + // First, try to decode as adapter cap: (string, address) + // Pattern: ("this", adapterAddress) + try { + const decoded = decodeAbiParameters( + [{ type: 'string' }, { type: 'address' }], + idParams as `0x${string}` + ); + + if (decoded[0] === 'this') { + return { + type: 'adapter', + adapterAddress: decoded[1] as Address, + }; + } + + if (decoded[0] === 'collateralToken') { + return { + type: 'collateral', + collateralToken: decoded[1] as Address, + }; + } + } catch { + // Not a simple (string, address) pattern, try market pattern + } + + // Try to decode as market cap: (string, address, marketParams) + // Pattern: ("this/marketParams", adapterAddress, marketParams) + try { + const marketParamsType = parseAbiParameters('(address loanToken, address collateralToken, address oracle, address irm, uint256 lltv)'); + + const decoded = decodeAbiParameters( + [ + { type: 'string' }, + { type: 'address' }, + { type: 'tuple', components: marketParamsType } + ], + idParams as `0x${string}` + ); + + if (decoded[0] === 'this/marketParams') { + const marketParams = decoded[2] as any; + const [loanToken, collateralToken, oracle, irm, lltv] = marketParams; + + // Create a market ID hash from the market params + const marketId = keccak256(encodeAbiParameters(marketParamsType, marketParams)); + + return { + type: 'market', + adapterAddress: decoded[1] as Address, + marketParams: { + loanToken: loanToken as Address, + collateralToken: collateralToken as Address, + oracle: oracle as Address, + irm: irm as Address, + lltv: lltv as bigint, + }, + marketId, + }; + } + } catch { + // Not a market pattern + } + + // Fallback: could not decode + console.warn('Could not decode idParams:', idParams); + return { type: 'market' }; + } catch (error) { + console.error('Error parsing idParams:', error); + return { type: 'market' }; } - if (capId.startsWith('collateral-')) { - const parts = capId.replace('collateral-', '').split('-'); - return { type: 'collateral', adapterAddress: parts[0], collateralToken: parts[1] }; - } - if (capId.startsWith('market-')) { - const parts = capId.replace('market-', '').split('-'); - return { type: 'market', adapterAddress: parts[0], marketId: parts[1] }; - } - - // Default fallback - return { type: 'market' }; } From 0abba73de1a8b339dcc4a4d174c9de81dabc4862 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 16 Oct 2025 16:06:19 +0800 Subject: [PATCH 15/29] feat: correct decode --- .../components/VaultSettingsModal.tsx | 7 ++--- .../settings/CurrentAllocations.tsx | 3 ++- .../components/settings/EditAllocations.tsx | 3 ++- .../components/settings/types.ts | 3 ++- .../[chainId]/[vaultAddress]/content.tsx | 17 ++++++------ src/hooks/useVaultV2Data.ts | 26 ++++++++++++------- src/utils/morpho.ts | 7 +++-- 7 files changed, 38 insertions(+), 28 deletions(-) diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx index f3ebb092..83119cb8 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx @@ -5,6 +5,7 @@ import { Address } from 'viem'; import { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { SupportedNetworks } from '@/utils/networks'; import { GeneralTab, AgentsTab, AllocationsTab, SettingsTab } from './settings'; +import { CapData } from '@/hooks/useVaultV2Data'; const TABS: { id: SettingsTab; label: string }[] = [ { id: 'general', label: 'General' }, @@ -30,7 +31,7 @@ type VaultSettingsModalProps = { chainId: SupportedNetworks; vaultAsset?: Address; adapterAddress?: Address; - existingCaps?: VaultV2Cap[]; + capData?: CapData; onSetAllocator: (allocator: Address, isAllocator: boolean) => Promise; onUpdateCaps: (caps: VaultV2Cap[]) => Promise; isUpdatingAllocator: boolean; @@ -55,7 +56,7 @@ export function VaultSettingsModal({ chainId, vaultAsset, adapterAddress, - existingCaps = [], + capData = undefined, onSetAllocator, onUpdateCaps, isUpdatingAllocator, @@ -147,7 +148,7 @@ export function VaultSettingsModal({ chainId={chainId} vaultAsset={vaultAsset} adapterAddress={adapterAddress} - existingCaps={existingCaps} + existingCaps={capData} onUpdateCaps={onUpdateCaps} isUpdatingCaps={isUpdatingCaps} /> diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx index d131b42b..8a57c35f 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx @@ -5,9 +5,10 @@ import { Spinner } from '@/components/common/Spinner'; import { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { useMarkets } from '@/hooks/useMarkets'; import { parseCapIdParams } from '@/utils/morpho'; +import { CapData } from '@/hooks/useVaultV2Data'; type CurrentAllocationsProps = { - existingCaps: VaultV2Cap[]; + existingCaps?: CapData; isOwner: boolean; onStartEdit: () => void; }; diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx index c73457f4..bfc4331f 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx @@ -9,9 +9,10 @@ import { useMarkets } from '@/hooks/useMarkets'; import { getMarketCapId, parseCapIdParams } from '@/utils/morpho'; import { SupportedNetworks } from '@/utils/networks'; import { MarketCapState } from './types'; +import { CapData } from '@/hooks/useVaultV2Data'; type EditAllocationsProps = { - existingCaps: VaultV2Cap[]; + existingCaps?: CapData; vaultAsset?: Address; chainId: SupportedNetworks; isOwner: boolean; diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts b/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts index d5d62d47..71f421ab 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts @@ -2,6 +2,7 @@ import { Address } from 'viem'; import { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { SupportedNetworks } from '@/utils/networks'; import { Market } from '@/utils/types'; +import { CapData } from '@/hooks/useVaultV2Data'; export type SettingsTab = 'general' | 'agents' | 'allocations'; @@ -38,7 +39,7 @@ export type AllocationsTabProps = { chainId: SupportedNetworks; vaultAsset?: Address; adapterAddress?: Address; - existingCaps: VaultV2Cap[]; + existingCaps?: CapData; onUpdateCaps: (caps: VaultV2Cap[]) => Promise; isUpdatingCaps: boolean; }; diff --git a/app/autovault/[chainId]/[vaultAddress]/content.tsx b/app/autovault/[chainId]/[vaultAddress]/content.tsx index a24b1424..58988067 100644 --- a/app/autovault/[chainId]/[vaultAddress]/content.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/content.tsx @@ -101,20 +101,19 @@ export default function VaultContent() { const symbolToDisplay = vaultData?.displaySymbol; const allocators = vaultData?.allocators ?? []; const sentinels = vaultData?.sentinels ?? []; - const caps = vaultData?.caps ?? []; const allocatorCount = allocators.length; const hasNoAllocators = !needsSetup && allocatorCount === 0; - const hasNoCaps = !needsSetup && allocatorCount > 0 && caps.length === 0; + const capsUninitialized = !vaultData?.capsData.needSetupCaps + const capData = vaultData?.capsData - console.log('caps', caps) const roleStatusText = useMemo(() => { if (needsSetup) return 'Adapter pending deployment'; if (hasNoAllocators) return 'Choose agents to enable automation'; - if (hasNoCaps) return 'Set market caps to complete strategy'; + if (capsUninitialized) return 'Set market caps to complete strategy'; if (!vaultData?.curator) return 'Curator not assigned yet'; return 'Vault is configured and ready'; - }, [hasNoAllocators, hasNoCaps, needsSetup, vaultData?.curator]); + }, [hasNoAllocators, capsUninitialized, needsSetup, vaultData?.curator]); const assetAddress = vaultData?.assetAddress; @@ -241,7 +240,7 @@ export default function VaultContent() {
)} - {hasNoCaps && isOwner && ( + {capsUninitialized && isOwner && (

Set market caps

@@ -292,12 +291,12 @@ export default function VaultContent() { 0 && caps.length > 0} + isActive={allocatorCount > 0 && !capsUninitialized} activeAgents={allocatorCount} description={ needsSetup ? 'Deploy the vault adapter before allocating capital.' - : allocatorCount > 0 && caps.length > 0 + : allocatorCount > 0 && !capsUninitialized ? 'Allocators are authorized and rebalancing within curator caps.' : 'Authorize an allocator to resume automated portfolio management.' } @@ -336,7 +335,7 @@ export default function VaultContent() { chainId={supportedChainId} vaultAsset={assetAddress as Address | undefined} adapterAddress={adapter} - existingCaps={caps} + capData={capData} onSetAllocator={setAllocator} onUpdateCaps={updateCaps} isUpdatingAllocator={isUpdatingAllocator} diff --git a/src/hooks/useVaultV2Data.ts b/src/hooks/useVaultV2Data.ts index 3e57323f..870f90f8 100644 --- a/src/hooks/useVaultV2Data.ts +++ b/src/hooks/useVaultV2Data.ts @@ -13,6 +13,13 @@ type UseVaultV2DataArgs = { fallbackSymbol?: string; }; +export type CapData = { + adapterCap: VaultV2Cap | null, + collateralCaps: VaultV2Cap[], + marketCaps: VaultV2Cap[], + needSetupCaps: boolean +} + export type VaultV2Data = { displayName: string; displaySymbol: string; @@ -24,13 +31,9 @@ export type VaultV2Data = { sentinels: string[]; owner: string; curator: string; - caps: VaultV2Cap[]; + capsData: CapData adopters: string[]; curatorDisplay: string; - // Parsed caps by level - adapterCap: VaultV2Cap | null; - collateralCaps: VaultV2Cap[]; - marketCaps: VaultV2Cap[]; }; type UseVaultV2DataReturn = { @@ -89,6 +92,9 @@ export function useVaultV2Data({ } }); + // if any one of the caps is not set, it means it still need setup! + const needSetupCaps = !adapterCap || collateralCaps.length === 0 || marketCaps.length === 0 + setData({ displayName: result.name || fallbackName, displaySymbol: result.symbol || fallbackSymbol, @@ -100,12 +106,14 @@ export function useVaultV2Data({ sentinels: result.sentinels, owner: result.owner, curator: result.curator, - caps: result.caps, + capsData: { + adapterCap, + collateralCaps, + marketCaps, + needSetupCaps + }, adopters: result.adopters, curatorDisplay, - adapterCap, - collateralCaps, - marketCaps, }); } catch (err) { setError(err instanceof Error ? err : new Error('Failed to fetch vault data')); diff --git a/src/utils/morpho.ts b/src/utils/morpho.ts index 902e9d95..335d3fcb 100644 --- a/src/utils/morpho.ts +++ b/src/utils/morpho.ts @@ -166,7 +166,7 @@ export function getMarketCapId(adopterAddress: Address, marketParams: MarketPara * @returns Object containing the cap type and extracted addresses/marketId */ export function parseCapIdParams(idParams: string): { - type: 'adapter' | 'collateral' | 'market'; + type: 'adapter' | 'collateral' | 'market' | 'unknown'; adapterAddress?: Address; collateralToken?: Address; marketParams?: MarketParams; @@ -237,10 +237,9 @@ export function parseCapIdParams(idParams: string): { } // Fallback: could not decode - console.warn('Could not decode idParams:', idParams); - return { type: 'market' }; + return { type: 'unknown' }; } catch (error) { console.error('Error parsing idParams:', error); - return { type: 'market' }; + return { type: 'unknown' }; } } From 9fb29029ae5eb3874cd7f0c1522b5f5fd3f99379 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 17 Oct 2025 10:29:52 +0800 Subject: [PATCH 16/29] feat: new flow --- .../components/VaultSettingsModal.tsx | 6 +- .../components/settings/AllocationsTab.tsx | 4 +- .../settings/CurrentAllocations.tsx | 378 +++++++++----- .../components/settings/EditAllocations.tsx | 492 +++++++++++++----- .../components/settings/Tooltips.tsx | 39 ++ .../components/settings/types.ts | 2 +- .../[chainId]/[vaultAddress]/content.tsx | 2 +- src/hooks/useVaultV2.ts | 3 +- 8 files changed, 655 insertions(+), 271 deletions(-) create mode 100644 app/autovault/[chainId]/[vaultAddress]/components/settings/Tooltips.tsx diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx index 83119cb8..09896c4b 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx @@ -33,7 +33,7 @@ type VaultSettingsModalProps = { adapterAddress?: Address; capData?: CapData; onSetAllocator: (allocator: Address, isAllocator: boolean) => Promise; - onUpdateCaps: (caps: VaultV2Cap[]) => Promise; + updateCaps: (caps: VaultV2Cap[]) => Promise; isUpdatingAllocator: boolean; isUpdatingCaps: boolean; }; @@ -58,7 +58,7 @@ export function VaultSettingsModal({ adapterAddress, capData = undefined, onSetAllocator, - onUpdateCaps, + updateCaps, isUpdatingAllocator, isUpdatingCaps, }: VaultSettingsModalProps) { @@ -149,7 +149,7 @@ export function VaultSettingsModal({ vaultAsset={vaultAsset} adapterAddress={adapterAddress} existingCaps={capData} - onUpdateCaps={onUpdateCaps} + updateCaps={updateCaps} isUpdatingCaps={isUpdatingCaps} /> ); diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/AllocationsTab.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/AllocationsTab.tsx index ebddf7ca..2c94d24a 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/AllocationsTab.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/AllocationsTab.tsx @@ -9,7 +9,7 @@ export function AllocationsTab({ vaultAsset, adapterAddress, existingCaps, - onUpdateCaps, + updateCaps, isUpdatingCaps, }: AllocationsTabProps) { const [isEditing, setIsEditing] = useState(false); @@ -36,6 +36,8 @@ export function AllocationsTab({ existingCaps={existingCaps} isOwner={isOwner} onStartEdit={() => setIsEditing(true)} + vaultAsset={vaultAsset} + networkId= /> ); } diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx index 8a57c35f..a17903a7 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx @@ -2,81 +2,104 @@ import { useMemo, useState } from 'react'; import { Button } from '@/components/common/Button'; import { MarketDetailsBlock } from '@/components/common/MarketDetailsBlock'; import { Spinner } from '@/components/common/Spinner'; -import { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { useMarkets } from '@/hooks/useMarkets'; import { parseCapIdParams } from '@/utils/morpho'; import { CapData } from '@/hooks/useVaultV2Data'; +import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons'; +import { Address } from 'viem'; +import { findToken } from '@/utils/tokens'; type CurrentAllocationsProps = { existingCaps?: CapData; isOwner: boolean; onStartEdit: () => void; + vaultAsset?: Address; + chainId: number }; export function CurrentAllocations({ existingCaps, isOwner, onStartEdit, + chainId, + vaultAsset }: CurrentAllocationsProps) { const { markets, loading: marketsLoading } = useMarkets(); - const [showDetailed, setShowDetailed] = useState(false); + const [expandedCollaterals, setExpandedCollaterals] = useState>(new Set()); - // Separate caps by level - const { adapterCap, collateralCaps, marketCaps } = useMemo(() => { - let adapterCap: VaultV2Cap | null = null; - const collateralCaps: VaultV2Cap[] = []; - const marketCaps: VaultV2Cap[] = []; + const token = vaultAsset ? findToken(vaultAsset, chainId) : undefined - existingCaps.forEach((cap) => { + const hasAnyCaps = existingCaps && ( + existingCaps.adapterCap !== null || + existingCaps.collateralCaps.length > 0 || + existingCaps.marketCaps.length > 0 + ); + + // Group market caps by collateral + const marketCapsByCollateral = useMemo(() => { + const grouped = new Map['marketCaps'][0]; + market: typeof markets[0] | null; + capPercent: string; + }>>(); + + if (!existingCaps) return grouped; + + existingCaps.marketCaps.forEach((cap) => { const parsed = parseCapIdParams(cap.idParams); - if (parsed.type === 'adapter') { - adapterCap = cap; - } else if (parsed.type === 'collateral') { - collateralCaps.push(cap); - } else if (parsed.type === 'market') { - marketCaps.push(cap); + if (parsed.type === 'market' && parsed.marketParams) { + const collateralAddr = parsed.marketParams.collateralToken.toLowerCase(); + + const market = markets.find( + (m) => m.uniqueKey.toLowerCase() === (parsed.marketId ?? '').toLowerCase() + ) ?? null; + + if (!grouped.has(collateralAddr)) { + grouped.set(collateralAddr, []); + } + + grouped.get(collateralAddr)!.push({ + cap, + market, + capPercent: (parseFloat(cap.relativeCap) / 1e16).toFixed(2), + }); } }); - return { adapterCap, collateralCaps, marketCaps }; - }, [existingCaps]); + return grouped; + }, [existingCaps, markets]); - const hasAnyCaps = existingCaps.length > 0; + // Map collateral caps with their markets + const collateralCapsWithMarkets = useMemo(() => { + return existingCaps?.collateralCaps.map((cap) => { + const parsed = parseCapIdParams(cap.idParams); + const collateralAddr = parsed.collateralToken?.toLowerCase() ?? ''; + const marketsForCollateral = marketCapsByCollateral.get(collateralAddr) || []; - console.log('existingCaps', existingCaps); + // Get collateral symbol from first market + const collateralSymbol = marketsForCollateral[0]?.market?.collateralAsset.symbol || 'Unknown'; - // Map collateral caps to display data - const collateralCapsWithData = useMemo(() => { - return collateralCaps.map((cap) => { - const parsed = parseCapIdParams(cap.idParams); return { cap, collateralToken: parsed.collateralToken ?? 'Unknown', + collateralSymbol, capPercent: (parseFloat(cap.relativeCap) / 1e16).toFixed(2), - absoluteCapFormatted: cap.absoluteCap, + markets: marketsForCollateral, }; + }) || []; + }, [existingCaps, marketCapsByCollateral]); + + const toggleCollateral = (collateralAddr: string) => { + setExpandedCollaterals((prev) => { + const next = new Set(prev); + if (next.has(collateralAddr)) { + next.delete(collateralAddr); + } else { + next.add(collateralAddr); + } + return next; }); - }, [collateralCaps]); - - // Map market caps to their market data (for detailed view) - const marketsWithCaps = useMemo(() => { - return marketCaps - .map((cap) => { - const parsed = parseCapIdParams(cap.idParams); - // Use case-insensitive matching for marketId - const market = markets.find( - (m) => m.uniqueKey.toLowerCase() === (parsed.marketId ?? '').toLowerCase() - ); - if (!market) return null; - return { - market, - cap, - capPercent: (parseFloat(cap.relativeCap) / 1e16).toFixed(2), - absoluteCapFormatted: cap.absoluteCap, - }; - }) - .filter((item) => item !== null); - }, [marketCaps, markets]); + }; if (marketsLoading) { return ( @@ -92,19 +115,10 @@ export function CurrentAllocations({

Allocation Caps

- Maximum allocation limits for adapter, collateral, and markets + Set limits on how much of each asset can be allocated across markets and collaterals.

- {collateralCaps.length > 0 && ( - - )} {isOwner && (
) : ( -
- {/* Adapter Cap (if exists) */} - {adapterCap && ( -
-

Adapter Cap

-
-
-
-

Total adapter allocation limit

+
+ {/* Adapter Cap (Level 1) */} + {existingCaps?.adapterCap && ( +
+
+
+
+

Adapter Cap

+ + Level 1 +
-
-
- {(parseFloat((adapterCap as VaultV2Cap).relativeCap) / 1e16).toFixed(2)}% -
-
Relative cap
+

Total allocation limit for this adapter

+
+
+
+ {(parseFloat(existingCaps.adapterCap.relativeCap) / 1e16).toFixed(2)}%
+
Relative cap
)} - {/* Collateral Caps or Market Caps based on toggle */} - {!showDetailed ? ( - // Collateral-level caps (default view) - collateralCaps.length > 0 && ( + {/* Collateral Caps (Level 2) */} + {collateralCapsWithMarkets.length > 0 && ( +
+
+

Collateral Caps

+ + Level 2 + + + ({collateralCapsWithMarkets.length}) + +
+
-

- Collateral Caps ({collateralCaps.length}) -

-
- {collateralCapsWithData.map((item, index) => ( + {collateralCapsWithMarkets.map((item) => { + const collateralAddr = item.collateralToken.toLowerCase(); + const isExpanded = expandedCollaterals.has(collateralAddr); + const hasMarkets = item.markets.length > 0; + + return (
-
-
-

- Collateral {index + 1} -

-

- {item.collateralToken} -

-
-
-
- {item.capPercent}% + {/* Collateral Cap Header */} +
hasMarkets && toggleCollateral(collateralAddr)} + > +
+
+
+
+

{item.collateralSymbol}

+ {hasMarkets && ( + + ({item.markets.length} market{item.markets.length !== 1 ? 's' : ''}) + + )} +
+

+ {item.collateralToken} +

+
-
- Abs: {item.absoluteCapFormatted} +
+
+
+ {item.capPercent}% +
+
Collateral cap
+
+ {hasMarkets && ( +
+ {isExpanded ? ( + + ) : ( + + )} +
+ )}
+ + {/* Market Caps (Level 3) - Expandable */} + {isExpanded && hasMarkets && ( +
+
+
Market Caps
+ + Level 3 + +
+
+ {item.markets.map((marketItem) => { + if (!marketItem.market) { + return ( +
+ Market data not available +
+ ); + } + + return ( +
+ +
+ Market allocation cap +
+ + {marketItem.capPercent}% + +
+
+
+ ); + })} +
+
+ )}
- ))} -
+ ); + })}
- ) - ) : ( - // Market-level caps (detailed view) - marketCaps.length > 0 && ( -
-

- Market Caps ({marketCaps.length}) -

+
+ )} + + {/* Orphaned Market Caps (markets without collateral caps) */} + {existingCaps?.marketCaps.length > 0 && ( + (() => { + const collateralsWithCaps = new Set( + collateralCapsWithMarkets.map((c) => c.collateralToken.toLowerCase()) + ); + + const orphanedMarkets = existingCaps.marketCaps.filter((cap) => { + const parsed = parseCapIdParams(cap.idParams); + if (parsed.type === 'market' && parsed.marketParams) { + const collateralAddr = parsed.marketParams.collateralToken.toLowerCase(); + return !collateralsWithCaps.has(collateralAddr); + } + return false; + }); + + if (orphanedMarkets.length === 0) return null; + + return (
- {marketsWithCaps.map((item) => { - if (!item) return null; - const { market, capPercent, absoluteCapFormatted } = item; - - return ( -
- -
- Maximum allocation cap -
- {capPercent}% -
Abs: {absoluteCapFormatted}
+
+

Direct Market Caps

+ + (without collateral caps) + +
+
+ {orphanedMarkets.map((cap) => { + const parsed = parseCapIdParams(cap.idParams); + const market = markets.find( + (m) => m.uniqueKey.toLowerCase() === (parsed.marketId ?? '').toLowerCase() + ); + + if (!market) return null; + + return ( +
+ +
+ Market cap +
+ + {(parseFloat(cap.relativeCap) / 1e16).toFixed(2)}% + +
-
- ); - })} + ); + })} +
-
- ) + ); + })() )}
)} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx index bfc4331f..c9fffe61 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx @@ -1,15 +1,22 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Address, parseUnits } from 'viem'; +import { Tooltip } from '@heroui/react'; +import { InfoCircledIcon } from '@radix-ui/react-icons'; import { Button } from '@/components/common/Button'; import { MarketsTableWithSameLoanAsset } from '@/components/common/MarketsTableWithSameLoanAsset'; import { Spinner } from '@/components/common/Spinner'; +import { TokenIcon } from '@/components/TokenIcon'; +import { TooltipContent } from '@/components/TooltipContent'; import { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { useMarketNetwork } from '@/hooks/useMarketNetwork'; import { useMarkets } from '@/hooks/useMarkets'; -import { getMarketCapId, parseCapIdParams } from '@/utils/morpho'; +import { useTokens } from '@/components/providers/TokenProvider'; +import { getMarketCapId, getCollateralCapId, parseCapIdParams } from '@/utils/morpho'; import { SupportedNetworks } from '@/utils/networks'; import { MarketCapState } from './types'; import { CapData } from '@/hooks/useVaultV2Data'; +import { CollateralCapTooltip } from './Tooltips'; +import { Badge } from '@/components/common/Badge'; type EditAllocationsProps = { existingCaps?: CapData; @@ -22,6 +29,22 @@ type EditAllocationsProps = { onSave: (caps: VaultV2Cap[]) => Promise; }; +type CollateralCapInfo = { + collateralAddress: Address; + collateralSymbol: string; + relativeCap: string; + absoluteCap: string; + needsCreation: boolean; +}; + +type MarketCapInfo = { + market: MarketCapState['market']; + relativeCap: string; + absoluteCap: string; +}; + +const MAX_UINT256 = 2n ** 256n - 1n; + export function EditAllocations({ existingCaps, vaultAsset, @@ -30,123 +53,245 @@ export function EditAllocations({ isUpdating, adapterAddress, onCancel, - onSave, + onSave }: EditAllocationsProps) { - const [marketCaps, setMarketCaps] = useState([]); + const [selectedMarkets, setSelectedMarkets] = useState>(new Map()); + const [collateralCaps, setCollateralCaps] = useState>(new Map()); const { markets, loading: marketsLoading } = useMarkets(); const { needSwitchChain, switchToNetwork } = useMarketNetwork({ targetChainId: chainId }); + const { findToken } = useTokens(); - // Initialize market caps from markets and existing data - useEffect(() => { - if (!markets || !vaultAsset) return; + // Get vault asset decimals for absolute cap + const vaultAssetDecimals = useMemo(() => { + if (!vaultAsset) return 18; + const token = findToken(vaultAsset, chainId); + return token?.decimals ?? 18; + }, [vaultAsset, chainId, findToken]); - const filteredMarkets = markets.filter( + // Filter available markets + const availableMarkets = useMemo(() => { + if (!markets || !vaultAsset) return []; + return markets.filter( (m) => m.loanAsset.address.toLowerCase() === vaultAsset.toLowerCase() && m.morphoBlue.chain.id === chainId, ); + }, [markets, vaultAsset, chainId]); - setMarketCaps( - filteredMarkets.map((market) => { - const existingCap = existingCaps.find((c) => { - const parsed = parseCapIdParams(c.idParams); - return parsed.marketId?.toLowerCase() === market.uniqueKey.toLowerCase(); + // Initialize from existing caps + useEffect(() => { + if (availableMarkets.length === 0) return; + + // Initialize collateral caps + const collateralCapsMap = new Map(); + existingCaps?.collateralCaps.forEach((cap) => { + const parsed = parseCapIdParams(cap.idParams); + if (parsed.collateralToken) { + const token = findToken(parsed.collateralToken, chainId); + collateralCapsMap.set(parsed.collateralToken.toLowerCase(), { + collateralAddress: parsed.collateralToken, + collateralSymbol: token?.symbol ?? 'Unknown', + relativeCap: (parseFloat(cap.relativeCap) / 1e16).toString(), + absoluteCap: cap.absoluteCap === '0' ? '' : (Number(cap.absoluteCap) / 10 ** vaultAssetDecimals).toString(), + needsCreation: false, }); - return { + } + }); + setCollateralCaps(collateralCapsMap); + + // Initialize selected markets + const marketsMap = new Map(); + existingCaps?.marketCaps.forEach((cap) => { + const parsed = parseCapIdParams(cap.idParams); + const market = availableMarkets.find((m) => m.uniqueKey.toLowerCase() === parsed.marketId?.toLowerCase()); + if (market) { + marketsMap.set(market.uniqueKey, { market, - relativeCap: existingCap ? (parseFloat(existingCap.relativeCap) / 1e16).toString() : '', - isSelected: !!existingCap, - }; - }), - ); - }, [markets, vaultAsset, chainId, existingCaps]); + relativeCap: (parseFloat(cap.relativeCap) / 1e16).toString(), + absoluteCap: cap.absoluteCap === '0' ? '' : (Number(cap.absoluteCap) / 10 ** vaultAssetDecimals).toString(), + }); + } + }); + setSelectedMarkets(marketsMap); + }, [availableMarkets, chainId, existingCaps, findToken, vaultAssetDecimals]); const handleToggleMarket = useCallback((marketId: string) => { - setMarketCaps((prev) => - prev.map((c) => { - if (c.market.uniqueKey === marketId) { - const newIsSelected = !c.isSelected; - return { - ...c, - isSelected: newIsSelected, - relativeCap: newIsSelected && !c.relativeCap ? '100' : c.relativeCap, - }; + const market = availableMarkets.find((m) => m.uniqueKey === marketId); + if (!market) return; + + setSelectedMarkets((prev) => { + const next = new Map(prev); + if (next.has(marketId)) { + // Removing market + next.delete(marketId); + } else { + // Adding market + next.set(marketId, { + market, + relativeCap: '100', + absoluteCap: '', + }); + + // Auto-create collateral cap if needed + const collateralAddr = market.collateralAsset.address.toLowerCase(); + setCollateralCaps((prevCaps) => { + if (!prevCaps.has(collateralAddr)) { + const newCaps = new Map(prevCaps); + newCaps.set(collateralAddr, { + collateralAddress: market.collateralAsset.address as Address, + collateralSymbol: market.collateralAsset.symbol, + relativeCap: '100', + absoluteCap: '', + needsCreation: true, + }); + return newCaps; + } + return prevCaps; + }); + } + return next; + }); + + // Clean up unused collateral caps + setCollateralCaps((prevCaps) => { + const usedCollaterals = new Set(); + selectedMarkets.forEach((info) => { + usedCollaterals.add(info.market.collateralAsset.address.toLowerCase()); + }); + + // Add/remove the toggled market's collateral + const toggledMarket = availableMarkets.find((m) => m.uniqueKey === marketId); + if (toggledMarket) { + const collateralAddr = toggledMarket.collateralAsset.address.toLowerCase(); + if (!selectedMarkets.has(marketId)) { + usedCollaterals.add(collateralAddr); + } else { + usedCollaterals.delete(collateralAddr); } - return c; - }), - ); - }, []); + } - const handleUpdateCapField = useCallback((marketId: string, value: string) => { - setMarketCaps((prev) => - prev.map((c) => (c.market.uniqueKey === marketId ? { ...c, relativeCap: value } : c)), - ); + const newCaps = new Map(prevCaps); + for (const [addr, info] of newCaps.entries()) { + if (info.needsCreation && !usedCollaterals.has(addr)) { + newCaps.delete(addr); + } + } + return newCaps; + }); + }, [availableMarkets, selectedMarkets]); + + const handleUpdateMarketCap = useCallback((marketId: string, field: 'relativeCap' | 'absoluteCap', value: string) => { + setSelectedMarkets((prev) => { + const next = new Map(prev); + const existing = next.get(marketId); + if (existing) { + next.set(marketId, { ...existing, [field]: value }); + } + return next; + }); }, []); - const hasChanges = useMemo(() => { - return marketCaps.some((c) => { - const existingCap = existingCaps.find((ec) => { - const parsed = parseCapIdParams(ec.idParams); - return parsed.marketId?.toLowerCase() === c.market.uniqueKey.toLowerCase(); - }); - if (c.isSelected !== !!existingCap) return true; - if (c.isSelected) { - const existingRelative = existingCap - ? (parseFloat(existingCap.relativeCap) / 1e16).toString() - : ''; - return c.relativeCap !== existingRelative; + const handleUpdateCollateralCap = useCallback((collateralAddr: string, field: 'relativeCap' | 'absoluteCap', value: string) => { + setCollateralCaps((prev) => { + const next = new Map(prev); + const existing = next.get(collateralAddr.toLowerCase()); + if (existing) { + next.set(collateralAddr.toLowerCase(), { ...existing, [field]: value }); } - return false; + return next; }); - }, [marketCaps, existingCaps]); + }, []); - const selectedCount = useMemo(() => { - return marketCaps.filter((c) => c.isSelected).length; - }, [marketCaps]); + const hasChanges = useMemo(() => { + const existingMarketIds = new Set( + existingCaps?.marketCaps.map((cap) => { + const parsed = parseCapIdParams(cap.idParams); + return parsed.marketId?.toLowerCase(); + }) ?? [] + ); + const currentMarketIds = new Set(Array.from(selectedMarkets.keys()).map((id) => id.toLowerCase())); + if (existingMarketIds.size !== currentMarketIds.size) return true; + for (const id of currentMarketIds) { + if (!existingMarketIds.has(id)) return true; + } + + return Array.from(collateralCaps.values()).some((c) => c.needsCreation) || selectedMarkets.size > 0; + }, [selectedMarkets, collateralCaps, existingCaps]); + + // Switch chain an submit tx const handleSave = useCallback(async () => { if (needSwitchChain) { switchToNetwork(); return; } - if (!adapterAddress) { - console.error('Adapter address is required to save caps'); + if (!adapterAddress || !vaultAsset) { + console.error('Adapter address and vault asset are required'); return; } - const capsToUpdate = marketCaps - .filter((c) => c.isSelected) - .map((c) => { - const relativeCapBigInt = - c.relativeCap && parseFloat(c.relativeCap) > 0 ? parseUnits(c.relativeCap, 16) : 0n; - - // Create MarketParams from the market object - const marketParams = { - loanToken: c.market.loanAsset.address as Address, - collateralToken: c.market.collateralAsset.address as Address, - oracle: c.market.oracleAddress as Address, - irm: c.market.irmAddress as Address, - lltv: BigInt(c.market.lltv), - }; - - const { params, id } = getMarketCapId(adapterAddress, marketParams); - - return { - capId: id, - idParams: params, - relativeCap: relativeCapBigInt.toString(), - absoluteCap: '0', - } as VaultV2Cap; + const capsToUpdate: VaultV2Cap[] = []; + + // Add collateral caps + for (const [, info] of collateralCaps.entries()) { + const relativeCapBigInt = info.relativeCap && parseFloat(info.relativeCap) > 0 + ? parseUnits(info.relativeCap, 16) + : 0n; + + const absoluteCapBigInt = info.absoluteCap && parseFloat(info.absoluteCap) > 0 + ? parseUnits(info.absoluteCap, vaultAssetDecimals) + : MAX_UINT256; + + const { params, id } = getCollateralCapId(info.collateralAddress); + + console.log('collateral params, id', params, id) + + capsToUpdate.push({ + capId: id, + idParams: params, + relativeCap: relativeCapBigInt.toString(), + absoluteCap: absoluteCapBigInt.toString(), + }); + } + + // Add market caps + for (const [, info] of selectedMarkets.entries()) { + const relativeCapBigInt = info.relativeCap && parseFloat(info.relativeCap) > 0 + ? parseUnits(info.relativeCap, 16) + : 0n; + + const absoluteCapBigInt = info.absoluteCap && parseFloat(info.absoluteCap) > 0 + ? parseUnits(info.absoluteCap, vaultAssetDecimals) + : MAX_UINT256; + + const marketParams = { + loanToken: info.market.loanAsset.address as Address, + collateralToken: info.market.collateralAsset.address as Address, + oracle: info.market.oracleAddress as Address, + irm: info.market.irmAddress as Address, + lltv: BigInt(info.market.lltv), + }; + + const { params, id } = getMarketCapId(adapterAddress, marketParams); + + console.log('collateral param, id', params, id) + + capsToUpdate.push({ + capId: id, + idParams: params, + relativeCap: relativeCapBigInt.toString(), + absoluteCap: absoluteCapBigInt.toString(), }); + } if (capsToUpdate.length === 0) return; const success = await onSave(capsToUpdate); if (success) { - // Parent will handle switching back to read mode + // Parent handles switching back to read mode } - }, [marketCaps, needSwitchChain, switchToNetwork, onSave]); + }, [selectedMarkets, collateralCaps, needSwitchChain, switchToNetwork, onSave, adapterAddress, vaultAsset, vaultAssetDecimals]); if (marketsLoading) { return ( @@ -156,12 +301,11 @@ export function EditAllocations({ ); } - if (marketCaps.length === 0) { + if (availableMarkets.length === 0) { return (

- No markets found for this vault's asset. Caps can be configured once markets are - available. + No markets found for this vault's asset.

- -
+ {/* Actions */} +
+
+
+ +
diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/Tooltips.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/Tooltips.tsx new file mode 100644 index 00000000..0e80ef0e --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/Tooltips.tsx @@ -0,0 +1,39 @@ +import { TooltipContent } from "@/components/TooltipContent"; +import { Tooltip } from "@heroui/react"; +import { InfoCircledIcon } from "@radix-ui/react-icons"; + +export function CollateralCapTooltip() { + return ( + } + > + + + ) +} + +export function MarketCapTooltip() { + return ( + } + > + + + ) +} \ No newline at end of file diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts b/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts index 71f421ab..2cd5498b 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts @@ -40,6 +40,6 @@ export type AllocationsTabProps = { vaultAsset?: Address; adapterAddress?: Address; existingCaps?: CapData; - onUpdateCaps: (caps: VaultV2Cap[]) => Promise; + updateCaps: (caps: VaultV2Cap[]) => Promise; isUpdatingCaps: boolean; }; diff --git a/app/autovault/[chainId]/[vaultAddress]/content.tsx b/app/autovault/[chainId]/[vaultAddress]/content.tsx index 58988067..abc6ebe3 100644 --- a/app/autovault/[chainId]/[vaultAddress]/content.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/content.tsx @@ -337,7 +337,7 @@ export default function VaultContent() { adapterAddress={adapter} capData={capData} onSetAllocator={setAllocator} - onUpdateCaps={updateCaps} + updateCaps={updateCaps} isUpdatingAllocator={isUpdatingAllocator} isUpdatingCaps={isUpdatingCaps} /> diff --git a/src/hooks/useVaultV2.ts b/src/hooks/useVaultV2.ts index b931373c..dabe3b44 100644 --- a/src/hooks/useVaultV2.ts +++ b/src/hooks/useVaultV2.ts @@ -356,9 +356,8 @@ export function useVaultV2({ caps.forEach((cap) => { const relativeCapBigInt = BigInt(cap.relativeCap); const absoluteCapBigInt = BigInt(cap.absoluteCap); - const idData = cap.capId as `0x${string}`; + const idData = cap.idParams as `0x${string}`; - // For updates, we always increase caps (curator can decrease if needed) if (relativeCapBigInt > 0n) { const increaseRelativeCapTx = encodeFunctionData({ abi: vaultv2Abi, From e8fca38dc9cac3995ac6c3ab6964283e0efb37ee Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 17 Oct 2025 15:55:21 +0800 Subject: [PATCH 17/29] feat: set caps done --- .../components/VaultAgentSummary.tsx | 72 --- .../components/VaultAllocatorCard.tsx | 95 +++ .../components/VaultCollateralsCard.tsx | 77 +++ .../components/VaultSettingsModal.tsx | 34 +- .../components/VaultSummaryMetrics.tsx | 9 +- .../components/settings/AddMarketCapModal.tsx | 38 ++ .../components/settings/AllocationsTab.tsx | 4 +- .../settings/CurrentAllocations.tsx | 247 ++++--- .../components/settings/EditAllocations.tsx | 608 +++++++++++------- .../components/settings/MarketCapsTable.tsx | 145 +++++ .../[chainId]/[vaultAddress]/content.tsx | 349 +++++----- src/components/common/MarketDetailsBlock.tsx | 26 +- .../common/MarketSelectionModal.tsx | 174 +++++ src/data-sources/morpho-api/v2-vaults.ts | 2 + src/hooks/useMarketWarnings.ts | 2 +- src/hooks/useVaultV2.ts | 97 ++- src/utils/morpho.ts | 16 +- 17 files changed, 1300 insertions(+), 695 deletions(-) delete mode 100644 app/autovault/[chainId]/[vaultAddress]/components/VaultAgentSummary.tsx create mode 100644 app/autovault/[chainId]/[vaultAddress]/components/VaultAllocatorCard.tsx create mode 100644 app/autovault/[chainId]/[vaultAddress]/components/VaultCollateralsCard.tsx create mode 100644 app/autovault/[chainId]/[vaultAddress]/components/settings/AddMarketCapModal.tsx create mode 100644 app/autovault/[chainId]/[vaultAddress]/components/settings/MarketCapsTable.tsx create mode 100644 src/components/common/MarketSelectionModal.tsx diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultAgentSummary.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultAgentSummary.tsx deleted file mode 100644 index a017cb63..00000000 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultAgentSummary.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Tooltip } from '@heroui/react'; -import clsx from 'clsx'; -import { GrStatusGood } from 'react-icons/gr'; -import { Button } from '@/components/common'; -import { TooltipContent } from '@/components/TooltipContent'; - -type VaultAgentSummaryProps = { - isActive: boolean; - activeAgents: number; - description: string; - onManageAgents: () => void; - onManageAllocations?: () => void; - roleStatusText: string; -}; - -export function VaultAgentSummary({ - isActive, - activeAgents, - description, - onManageAgents, - onManageAllocations, - roleStatusText, -}: VaultAgentSummaryProps) { - return ( -
-
-
- - - {isActive ? 'Automation agents executing strategy' : 'Automation paused'} - - } - title="Automation status" - detail={description} - /> - } - > - Details - -
-

- {activeAgents > 0 - ? `${activeAgents} allocator${activeAgents > 1 ? 's' : ''} authorized to rebalance.` - : 'No allocators authorized yet—add one to enable automation.'} -

-

{roleStatusText}

-
-
- - {onManageAllocations && ( - - )} -
-
- ); -} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultAllocatorCard.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultAllocatorCard.tsx new file mode 100644 index 00000000..aa7680a3 --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultAllocatorCard.tsx @@ -0,0 +1,95 @@ +import { Card, CardBody, CardHeader, Tooltip } from '@heroui/react'; +import { GearIcon } from '@radix-ui/react-icons'; +import { GrStatusGood } from 'react-icons/gr'; +import { Address } from 'viem'; +import { Spinner } from '@/components/common/Spinner'; +import { AddressDisplay } from '@/components/common/AddressDisplay'; +import { TooltipContent } from '@/components/TooltipContent'; +import { SupportedNetworks } from '@/utils/networks'; + +type VaultAllocatorCardProps = { + allocators: string[]; + chainId: SupportedNetworks; + onManageAgents: () => void; + needsSetup?: boolean; + isOwner?: boolean; + isLoading?: boolean; +}; + +export function VaultAllocatorCard({ + allocators, + chainId, + onManageAgents, + needsSetup = false, + isOwner = false, + isLoading = false, +}: VaultAllocatorCardProps) { + const hasAllocators = allocators.length > 0; + const cardStyle = 'bg-surface rounded shadow-sm'; + + if (needsSetup) { + return null; + } + + return ( + + + Allocators + {isOwner && ( + + )} + + + {isLoading ? ( +
+ +
+ ) : hasAllocators ? ( +
+ {allocators.map((allocatorAddress) => ( +
+
+
+ + +
+

Authorized for rebalancing

+
+
+ ))} +
+ ) : ( +
+
+ + No allocators configured + } + title="Allocators" + detail="Add an allocator agent to enable automated vault rebalancing and allocation management." + /> + } + > + What's this? + +
+
+ )} +
+
+ ); +} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultCollateralsCard.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultCollateralsCard.tsx new file mode 100644 index 00000000..de3e08a8 --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultCollateralsCard.tsx @@ -0,0 +1,77 @@ +import { Card, CardBody, CardHeader } from '@heroui/react'; +import { GearIcon } from '@radix-ui/react-icons'; +import { Address } from 'viem'; +import { Spinner } from '@/components/common/Spinner'; +import { TokenIcon } from '@/components/TokenIcon'; +import { parseCapIdParams } from '@/utils/morpho'; +import { SupportedNetworks } from '@/utils/networks'; +import type { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; + +type VaultCollateralsCardProps = { + collateralCaps: VaultV2Cap[]; + chainId: SupportedNetworks; + onManageCaps: () => void; + needsSetup?: boolean; + isOwner?: boolean; + isLoading?: boolean; +}; + +export function VaultCollateralsCard({ + collateralCaps, + chainId, + onManageCaps, + needsSetup = false, + isOwner = false, + isLoading = false, +}: VaultCollateralsCardProps) { + const cardStyle = 'bg-surface rounded shadow-sm'; + + if (needsSetup) { + return null; + } + + const collateralTokens = collateralCaps + .map(cap => { + const parsed = parseCapIdParams(cap.idParams); + return parsed.collateralToken; + }) + .filter((token): token is Address => !!token); + + const hasCollaterals = collateralTokens.length > 0; + + return ( + + + Collaterals + {isOwner && ( + + )} + + + {isLoading ? ( +
+ +
+ ) : hasCollaterals ? ( +
+ {collateralTokens.map((tokenAddress) => ( +
+ +
+ ))} +
+ ) : ( +
+ + None configured +
+ )} +
+
+ ); +} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx index 09896c4b..0bdd7182 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { LuX } from 'react-icons/lu'; +import { ReloadIcon } from '@radix-ui/react-icons'; import { Address } from 'viem'; import { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { SupportedNetworks } from '@/utils/networks'; @@ -36,6 +37,8 @@ type VaultSettingsModalProps = { updateCaps: (caps: VaultV2Cap[]) => Promise; isUpdatingAllocator: boolean; isUpdatingCaps: boolean; + onRefresh?: () => void; + isRefreshing?: boolean; }; export function VaultSettingsModal({ @@ -61,6 +64,8 @@ export function VaultSettingsModal({ updateCaps, isUpdatingAllocator, isUpdatingCaps, + onRefresh, + isRefreshing = false, }: VaultSettingsModalProps) { const [activeTab, setActiveTab] = useState(initialTab); const [mounted, setMounted] = useState(false); @@ -175,14 +180,27 @@ export function VaultSettingsModal({ {/* Header */}

Vault Settings

- +
+ {onRefresh && ( + + )} + +
{/* Content */} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultSummaryMetrics.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultSummaryMetrics.tsx index 89f4da7d..41c5dfbc 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultSummaryMetrics.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultSummaryMetrics.tsx @@ -1,5 +1,10 @@ import { PropsWithChildren } from 'react'; -export function VaultSummaryMetrics({ children }: PropsWithChildren) { - return
{children}
; +type VaultSummaryMetricsProps = PropsWithChildren & { + columns?: 3 | 4; +}; + +export function VaultSummaryMetrics({ children, columns = 4 }: VaultSummaryMetricsProps) { + const gridClass = columns === 3 ? 'lg:grid-cols-3' : 'lg:grid-cols-4'; + return
{children}
; } diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/AddMarketCapModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/AddMarketCapModal.tsx new file mode 100644 index 00000000..654a0be0 --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/AddMarketCapModal.tsx @@ -0,0 +1,38 @@ +import { Address } from 'viem'; +import { MarketSelectionModal } from '@/components/common/MarketSelectionModal'; +import { Market } from '@/utils/types'; +import { SupportedNetworks } from '@/utils/networks'; + +type AddMarketCapModalProps = { + vaultAsset: Address; + chainId: SupportedNetworks; + existingMarketIds: Set; + onClose: () => void; + onAdd: (markets: Market[]) => void; +}; + +/** + * Wrapper around MarketSelectionModal for adding market caps + * Provides cap-specific labels and context + */ +export function AddMarketCapModal({ + vaultAsset, + chainId, + existingMarketIds, + onClose, + onAdd, +}: AddMarketCapModalProps) { + return ( + + ); +} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/AllocationsTab.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/AllocationsTab.tsx index 2c94d24a..5d929a4a 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/AllocationsTab.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/AllocationsTab.tsx @@ -24,7 +24,7 @@ export function AllocationsTab({ adapterAddress={adapterAddress} onCancel={() => setIsEditing(false)} onSave={async (caps) => { - const success = await onUpdateCaps(caps); + const success = await updateCaps(caps); if (success) { setIsEditing(false); } @@ -37,7 +37,7 @@ export function AllocationsTab({ isOwner={isOwner} onStartEdit={() => setIsEditing(true)} vaultAsset={vaultAsset} - networkId= + chainId={chainId} /> ); } diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx index a17903a7..59df8f62 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx @@ -1,13 +1,16 @@ import { useMemo, useState } from 'react'; import { Button } from '@/components/common/Button'; -import { MarketDetailsBlock } from '@/components/common/MarketDetailsBlock'; import { Spinner } from '@/components/common/Spinner'; +import { TokenIcon } from '@/components/TokenIcon'; import { useMarkets } from '@/hooks/useMarkets'; import { parseCapIdParams } from '@/utils/morpho'; import { CapData } from '@/hooks/useVaultV2Data'; import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons'; -import { Address } from 'viem'; +import { Address, maxUint128 } from 'viem'; import { findToken } from '@/utils/tokens'; +import { MarketCapsTable } from './MarketCapsTable'; +import { MarketDetailsBlock } from '@/components/common/MarketDetailsBlock'; +import { CollateralCapTooltip } from './Tooltips'; type CurrentAllocationsProps = { existingCaps?: CapData; @@ -27,7 +30,27 @@ export function CurrentAllocations({ const { markets, loading: marketsLoading } = useMarkets(); const [expandedCollaterals, setExpandedCollaterals] = useState>(new Set()); - const token = vaultAsset ? findToken(vaultAsset, chainId) : undefined + const vaultAssetToken = vaultAsset ? findToken(vaultAsset, chainId) : undefined; + const vaultAssetDecimals = vaultAssetToken?.decimals ?? 18; + + // Format absolute cap value + const formatAbsoluteCap = (cap: string): string => { + if (!cap || cap === '') { + return 'No limit'; + } + + try { + const capBigInt = BigInt(cap); + if (capBigInt >= maxUint128) { + return 'No limit'; + } + const value = Number(capBigInt) / 10 ** vaultAssetDecimals; + return value.toLocaleString(undefined, { maximumFractionDigits: 2 }); + } catch (e) { + // If we can't parse it as BigInt, return as is + return cap; + } + }; const hasAnyCaps = existingCaps && ( existingCaps.adapterCap !== null || @@ -47,7 +70,8 @@ export function CurrentAllocations({ existingCaps.marketCaps.forEach((cap) => { const parsed = parseCapIdParams(cap.idParams); - if (parsed.type === 'market' && parsed.marketParams) { + + if (parsed.type === 'market' && parsed.marketParams?.collateralToken) { const collateralAddr = parsed.marketParams.collateralToken.toLowerCase(); const market = markets.find( @@ -76,8 +100,11 @@ export function CurrentAllocations({ const collateralAddr = parsed.collateralToken?.toLowerCase() ?? ''; const marketsForCollateral = marketCapsByCollateral.get(collateralAddr) || []; - // Get collateral symbol from first market - const collateralSymbol = marketsForCollateral[0]?.market?.collateralAsset.symbol || 'Unknown'; + // Get collateral symbol - try from token lookup first, then from market + const collateralToken = findToken(parsed.collateralToken as Address, chainId); + const collateralSymbol = collateralToken?.symbol || + marketsForCollateral[0]?.market?.collateralAsset.symbol || + 'Unknown'; return { cap, @@ -87,7 +114,7 @@ export function CurrentAllocations({ markets: marketsForCollateral, }; }) || []; - }, [existingCaps, marketCapsByCollateral]); + }, [existingCaps, marketCapsByCollateral, chainId]); const toggleCollateral = (collateralAddr: string) => { setExpandedCollaterals((prev) => { @@ -128,7 +155,7 @@ export function CurrentAllocations({
{!hasAnyCaps ? ( -
+

No caps configured yet

Set caps to control how agents allocate funds across markets @@ -136,40 +163,19 @@ export function CurrentAllocations({

) : (
- {/* Adapter Cap (Level 1) */} - {existingCaps?.adapterCap && ( -
-
-
-
-

Adapter Cap

- - Level 1 - -
-

Total allocation limit for this adapter

-
-
-
- {(parseFloat(existingCaps.adapterCap.relativeCap) / 1e16).toFixed(2)}% -
-
Relative cap
-
+ {/* Collateral Caps */} + {collateralCapsWithMarkets.length > 0 && ( +
+
+

Collateral Caps ({collateralCapsWithMarkets.length})

+
-
- )} - {/* Collateral Caps (Level 2) */} - {collateralCapsWithMarkets.length > 0 && ( -
-
-

Collateral Caps

- - Level 2 - - - ({collateralCapsWithMarkets.length}) - + {/* Column Headers */} +
+
Collateral
+
Relative %
+
Absolute {vaultAssetToken?.symbol ? `(${vaultAssetToken.symbol})` : ''}
@@ -181,95 +187,62 @@ export function CurrentAllocations({ return (
- {/* Collateral Cap Header */} + {/* Collateral Cap Row */}
hasMarkets && toggleCollateral(collateralAddr)} > -
-
-
-
-

{item.collateralSymbol}

- {hasMarkets && ( - - ({item.markets.length} market{item.markets.length !== 1 ? 's' : ''}) - - )} -
-

- {item.collateralToken} -

-
-
-
-
-
- {item.capPercent}% -
-
Collateral cap
-
- {hasMarkets && ( -
- {isExpanded ? ( - - ) : ( - - )} -
+ +
+ {item.collateralSymbol} + {hasMarkets && ( + + ({item.markets.length} market{item.markets.length !== 1 ? 's' : ''}) + + )} +
+
+ {item.capPercent}% +
+
+ {formatAbsoluteCap(item.cap.absoluteCap)} +
+ {hasMarkets && ( +
+ {isExpanded ? ( + + ) : ( + )}
-
+ )}
- {/* Market Caps (Level 3) - Expandable */} + {/* Market Caps - Expandable */} {isExpanded && hasMarkets && (
-
-
Market Caps
- - Level 3 - -
-
- {item.markets.map((marketItem) => { - if (!marketItem.market) { - return ( -
- Market data not available -
- ); - } - - return ( -
- -
- Market allocation cap -
- - {marketItem.capPercent}% - -
-
-
- ); - })} -
+
Market Caps
+ m.market) + .map(m => ({ + market: m.market!, + relativeCap: m.capPercent, + absoluteCap: m.cap.absoluteCap, + isEditable: false, + }))} + showHeaders={false} + vaultAssetSymbol={vaultAssetToken?.symbol} + vaultAssetAddress={vaultAsset} + chainId={chainId} + />
)}
@@ -288,7 +261,7 @@ export function CurrentAllocations({ const orphanedMarkets = existingCaps.marketCaps.filter((cap) => { const parsed = parseCapIdParams(cap.idParams); - if (parsed.type === 'market' && parsed.marketParams) { + if (parsed.type === 'market' && parsed.marketParams?.collateralToken) { const collateralAddr = parsed.marketParams.collateralToken.toLowerCase(); return !collateralsWithCaps.has(collateralAddr); } @@ -317,21 +290,27 @@ export function CurrentAllocations({ return (
- -
- Market cap -
- - {(parseFloat(cap.relativeCap) / 1e16).toFixed(2)}% - +
+ +
+
+ Caps: +
+
+ + {(parseFloat(cap.relativeCap) / 1e16).toFixed(2)}% + + relative +
+
+ {formatAbsoluteCap(cap.absoluteCap)} + absolute +
diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx index c9fffe61..4b1d8d88 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx @@ -1,21 +1,20 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Address, parseUnits } from 'viem'; -import { Tooltip } from '@heroui/react'; -import { InfoCircledIcon } from '@radix-ui/react-icons'; +import { Address, parseUnits, maxUint128 } from 'viem'; import { Button } from '@/components/common/Button'; -import { MarketsTableWithSameLoanAsset } from '@/components/common/MarketsTableWithSameLoanAsset'; import { Spinner } from '@/components/common/Spinner'; import { TokenIcon } from '@/components/TokenIcon'; -import { TooltipContent } from '@/components/TooltipContent'; import { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { useMarketNetwork } from '@/hooks/useMarketNetwork'; import { useMarkets } from '@/hooks/useMarkets'; import { useTokens } from '@/components/providers/TokenProvider'; -import { getMarketCapId, getCollateralCapId, parseCapIdParams } from '@/utils/morpho'; +import { getMarketCapId, getCollateralCapId, getAdapterCapId, parseCapIdParams } from '@/utils/morpho'; import { SupportedNetworks } from '@/utils/networks'; -import { MarketCapState } from './types'; import { CapData } from '@/hooks/useVaultV2Data'; -import { CollateralCapTooltip } from './Tooltips'; +import { CollateralCapTooltip, MarketCapTooltip } from './Tooltips'; +import { MarketCapsTable } from './MarketCapsTable'; +import { AddMarketCapModal } from './AddMarketCapModal'; +import { Market } from '@/utils/types'; +import { PlusIcon } from '@radix-ui/react-icons'; import { Badge } from '@/components/common/Badge'; type EditAllocationsProps = { @@ -34,17 +33,16 @@ type CollateralCapInfo = { collateralSymbol: string; relativeCap: string; absoluteCap: string; - needsCreation: boolean; + existingCapId?: string; }; type MarketCapInfo = { - market: MarketCapState['market']; + market: Market; relativeCap: string; absoluteCap: string; + existingCapId?: string; }; -const MAX_UINT256 = 2n ** 256n - 1n; - export function EditAllocations({ existingCaps, vaultAsset, @@ -55,20 +53,23 @@ export function EditAllocations({ onCancel, onSave }: EditAllocationsProps) { - const [selectedMarkets, setSelectedMarkets] = useState>(new Map()); + const [marketCaps, setMarketCaps] = useState>(new Map()); const [collateralCaps, setCollateralCaps] = useState>(new Map()); + const [showAddMarketModal, setShowAddMarketModal] = useState(false); + const { markets, loading: marketsLoading } = useMarkets(); const { needSwitchChain, switchToNetwork } = useMarketNetwork({ targetChainId: chainId }); const { findToken } = useTokens(); - // Get vault asset decimals for absolute cap - const vaultAssetDecimals = useMemo(() => { - if (!vaultAsset) return 18; - const token = findToken(vaultAsset, chainId); - return token?.decimals ?? 18; + // Get vault asset decimals and token + const vaultAssetToken = useMemo(() => { + if (!vaultAsset) return undefined; + return findToken(vaultAsset, chainId); }, [vaultAsset, chainId, findToken]); - // Filter available markets + const vaultAssetDecimals = vaultAssetToken?.decimals ?? 18; + + // Filter available markets for adding const availableMarkets = useMemo(() => { if (!markets || !vaultAsset) return []; return markets.filter( @@ -88,45 +89,56 @@ export function EditAllocations({ const parsed = parseCapIdParams(cap.idParams); if (parsed.collateralToken) { const token = findToken(parsed.collateralToken, chainId); + + const relativeCapBigInt = BigInt(cap.relativeCap); + const relativeCap = (Number(relativeCapBigInt) / 1e16).toString(); + + const absoluteCapBigInt = BigInt(cap.absoluteCap); + const absoluteCap = absoluteCapBigInt === 0n || absoluteCapBigInt >= maxUint128 + ? '' + : (Number(absoluteCapBigInt) / 10 ** vaultAssetDecimals).toString(); + collateralCapsMap.set(parsed.collateralToken.toLowerCase(), { collateralAddress: parsed.collateralToken, collateralSymbol: token?.symbol ?? 'Unknown', - relativeCap: (parseFloat(cap.relativeCap) / 1e16).toString(), - absoluteCap: cap.absoluteCap === '0' ? '' : (Number(cap.absoluteCap) / 10 ** vaultAssetDecimals).toString(), - needsCreation: false, + relativeCap, + absoluteCap, + existingCapId: cap.capId, }); } }); setCollateralCaps(collateralCapsMap); - // Initialize selected markets - const marketsMap = new Map(); + // Initialize market caps + const marketCapsMap = new Map(); existingCaps?.marketCaps.forEach((cap) => { const parsed = parseCapIdParams(cap.idParams); const market = availableMarkets.find((m) => m.uniqueKey.toLowerCase() === parsed.marketId?.toLowerCase()); if (market) { - marketsMap.set(market.uniqueKey, { + const relativeCapBigInt = BigInt(cap.relativeCap); + const relativeCap = (Number(relativeCapBigInt) / 1e16).toString(); + + const absoluteCapBigInt = BigInt(cap.absoluteCap); + const absoluteCap = absoluteCapBigInt === 0n || absoluteCapBigInt >= maxUint128 + ? '' + : (Number(absoluteCapBigInt) / 10 ** vaultAssetDecimals).toString(); + + marketCapsMap.set(market.uniqueKey.toLowerCase(), { market, - relativeCap: (parseFloat(cap.relativeCap) / 1e16).toString(), - absoluteCap: cap.absoluteCap === '0' ? '' : (Number(cap.absoluteCap) / 10 ** vaultAssetDecimals).toString(), + relativeCap, + absoluteCap, + existingCapId: cap.capId, }); } }); - setSelectedMarkets(marketsMap); + setMarketCaps(marketCapsMap); }, [availableMarkets, chainId, existingCaps, findToken, vaultAssetDecimals]); - const handleToggleMarket = useCallback((marketId: string) => { - const market = availableMarkets.find((m) => m.uniqueKey === marketId); - if (!market) return; - - setSelectedMarkets((prev) => { + const handleAddMarkets = useCallback((newMarkets: Market[]) => { + setMarketCaps((prev) => { const next = new Map(prev); - if (next.has(marketId)) { - // Removing market - next.delete(marketId); - } else { - // Adding market - next.set(marketId, { + newMarkets.forEach((market) => { + next.set(market.uniqueKey.toLowerCase(), { market, relativeCap: '100', absoluteCap: '', @@ -142,50 +154,53 @@ export function EditAllocations({ collateralSymbol: market.collateralAsset.symbol, relativeCap: '100', absoluteCap: '', - needsCreation: true, }); return newCaps; } return prevCaps; }); - } + }); return next; }); + }, []); - // Clean up unused collateral caps - setCollateralCaps((prevCaps) => { - const usedCollaterals = new Set(); - selectedMarkets.forEach((info) => { - usedCollaterals.add(info.market.collateralAsset.address.toLowerCase()); - }); - - // Add/remove the toggled market's collateral - const toggledMarket = availableMarkets.find((m) => m.uniqueKey === marketId); - if (toggledMarket) { - const collateralAddr = toggledMarket.collateralAsset.address.toLowerCase(); - if (!selectedMarkets.has(marketId)) { - usedCollaterals.add(collateralAddr); - } else { - usedCollaterals.delete(collateralAddr); + const handleRemoveMarket = useCallback((marketId: string) => { + setMarketCaps((prev) => { + const next = new Map(prev); + const marketInfo = next.get(marketId.toLowerCase()); + next.delete(marketId.toLowerCase()); + + // Check if collateral is still used by other markets + if (marketInfo) { + const collateralAddr = marketInfo.market.collateralAsset.address.toLowerCase(); + const stillUsed = Array.from(next.values()).some( + (m) => m.market.collateralAsset.address.toLowerCase() === collateralAddr + ); + + // Remove collateral cap if no longer used and it's a new cap + if (!stillUsed) { + setCollateralCaps((prevCaps) => { + const capInfo = prevCaps.get(collateralAddr); + if (capInfo && !capInfo.existingCapId) { + const newCaps = new Map(prevCaps); + newCaps.delete(collateralAddr); + return newCaps; + } + return prevCaps; + }); } } - const newCaps = new Map(prevCaps); - for (const [addr, info] of newCaps.entries()) { - if (info.needsCreation && !usedCollaterals.has(addr)) { - newCaps.delete(addr); - } - } - return newCaps; + return next; }); - }, [availableMarkets, selectedMarkets]); + }, []); const handleUpdateMarketCap = useCallback((marketId: string, field: 'relativeCap' | 'absoluteCap', value: string) => { - setSelectedMarkets((prev) => { + setMarketCaps((prev) => { const next = new Map(prev); - const existing = next.get(marketId); + const existing = next.get(marketId.toLowerCase()); if (existing) { - next.set(marketId, { ...existing, [field]: value }); + next.set(marketId.toLowerCase(), { ...existing, [field]: value }); } return next; }); @@ -203,23 +218,13 @@ export function EditAllocations({ }, []); const hasChanges = useMemo(() => { - const existingMarketIds = new Set( - existingCaps?.marketCaps.map((cap) => { - const parsed = parseCapIdParams(cap.idParams); - return parsed.marketId?.toLowerCase(); - }) ?? [] - ); - const currentMarketIds = new Set(Array.from(selectedMarkets.keys()).map((id) => id.toLowerCase())); + // Check if there are any new caps or modifications + const hasNewMarkets = Array.from(marketCaps.values()).some(m => !m.existingCapId); + const hasNewCollaterals = Array.from(collateralCaps.values()).some(c => !c.existingCapId); - if (existingMarketIds.size !== currentMarketIds.size) return true; - for (const id of currentMarketIds) { - if (!existingMarketIds.has(id)) return true; - } + return hasNewMarkets || hasNewCollaterals || marketCaps.size > 0 || collateralCaps.size > 0; + }, [marketCaps, collateralCaps]); - return Array.from(collateralCaps.values()).some((c) => c.needsCreation) || selectedMarkets.size > 0; - }, [selectedMarkets, collateralCaps, existingCaps]); - - // Switch chain an submit tx const handleSave = useCallback(async () => { if (needSwitchChain) { switchToNetwork(); @@ -233,37 +238,68 @@ export function EditAllocations({ const capsToUpdate: VaultV2Cap[] = []; - // Add collateral caps + // Add adapter cap if it doesn't exist + if (!existingCaps?.adapterCap && adapterAddress) { + const { params, id } = getAdapterCapId(adapterAddress); + capsToUpdate.push({ + capId: id, + idParams: params, + relativeCap: parseUnits('100', 16).toString(), // Default 100% + absoluteCap: maxUint128.toString(), // No limit + oldRelativeCap: '0', + oldAbsoluteCap: '0', + }); + } + + // Add collateral caps with delta calculation for (const [, info] of collateralCaps.entries()) { - const relativeCapBigInt = info.relativeCap && parseFloat(info.relativeCap) > 0 + const newRelativeCapBigInt = info.relativeCap && info.relativeCap !== '' && parseFloat(info.relativeCap) > 0 ? parseUnits(info.relativeCap, 16) : 0n; - const absoluteCapBigInt = info.absoluteCap && parseFloat(info.absoluteCap) > 0 + const newAbsoluteCapBigInt = info.absoluteCap && info.absoluteCap !== '' && parseFloat(info.absoluteCap) > 0 ? parseUnits(info.absoluteCap, vaultAssetDecimals) - : MAX_UINT256; + : maxUint128; - const { params, id } = getCollateralCapId(info.collateralAddress); + // Find existing cap to calculate delta + const existingCap = existingCaps?.collateralCaps.find(cap => { + const parsed = parseCapIdParams(cap.idParams); + return parsed.collateralToken?.toLowerCase() === info.collateralAddress.toLowerCase(); + }); - console.log('collateral params, id', params, id) + const oldRelativeCap = existingCap ? BigInt(existingCap.relativeCap) : 0n; + const oldAbsoluteCap = existingCap ? BigInt(existingCap.absoluteCap) : 0n; + + const { params, id } = getCollateralCapId(info.collateralAddress); capsToUpdate.push({ capId: id, idParams: params, - relativeCap: relativeCapBigInt.toString(), - absoluteCap: absoluteCapBigInt.toString(), + relativeCap: newRelativeCapBigInt.toString(), + absoluteCap: newAbsoluteCapBigInt.toString(), + oldRelativeCap: oldRelativeCap.toString(), + oldAbsoluteCap: oldAbsoluteCap.toString(), }); } - // Add market caps - for (const [, info] of selectedMarkets.entries()) { - const relativeCapBigInt = info.relativeCap && parseFloat(info.relativeCap) > 0 + // Add market caps with delta calculation + for (const [, info] of marketCaps.entries()) { + const newRelativeCapBigInt = info.relativeCap && info.relativeCap !== '' && parseFloat(info.relativeCap) > 0 ? parseUnits(info.relativeCap, 16) : 0n; - const absoluteCapBigInt = info.absoluteCap && parseFloat(info.absoluteCap) > 0 + const newAbsoluteCapBigInt = info.absoluteCap && info.absoluteCap !== '' && parseFloat(info.absoluteCap) > 0 ? parseUnits(info.absoluteCap, vaultAssetDecimals) - : MAX_UINT256; + : maxUint128; + + // Find existing cap to calculate delta + const existingCap = existingCaps?.marketCaps.find(cap => { + const parsed = parseCapIdParams(cap.idParams); + return parsed.marketId?.toLowerCase() === info.market.uniqueKey.toLowerCase(); + }); + + const oldRelativeCap = existingCap ? BigInt(existingCap.relativeCap) : 0n; + const oldAbsoluteCap = existingCap ? BigInt(existingCap.absoluteCap) : 0n; const marketParams = { loanToken: info.market.loanAsset.address as Address, @@ -275,13 +311,13 @@ export function EditAllocations({ const { params, id } = getMarketCapId(adapterAddress, marketParams); - console.log('collateral param, id', params, id) - capsToUpdate.push({ capId: id, idParams: params, - relativeCap: relativeCapBigInt.toString(), - absoluteCap: absoluteCapBigInt.toString(), + relativeCap: newRelativeCapBigInt.toString(), + absoluteCap: newAbsoluteCapBigInt.toString(), + oldRelativeCap: oldRelativeCap.toString(), + oldAbsoluteCap: oldAbsoluteCap.toString(), }); } @@ -291,7 +327,7 @@ export function EditAllocations({ if (success) { // Parent handles switching back to read mode } - }, [selectedMarkets, collateralCaps, needSwitchChain, switchToNetwork, onSave, adapterAddress, vaultAsset, vaultAssetDecimals]); + }, [marketCaps, collateralCaps, needSwitchChain, switchToNetwork, onSave, adapterAddress, vaultAsset, vaultAssetDecimals, existingCaps]); if (marketsLoading) { return ( @@ -301,170 +337,250 @@ export function EditAllocations({ ); } - if (availableMarkets.length === 0) { - return ( -
-

- No markets found for this vault's asset. -

- -
- ); - } + const existingMarketIds = new Set(Array.from(marketCaps.keys())); - const selectedCount = selectedMarkets.size; - const collateralCount = collateralCaps.size; + // Group markets by collateral + const marketsByCollateral = useMemo(() => { + const grouped = new Map(); + marketCaps.forEach((info) => { + const collateralAddr = info.market.collateralAsset.address.toLowerCase(); + if (!grouped.has(collateralAddr)) { + grouped.set(collateralAddr, []); + } + grouped.get(collateralAddr)!.push(info); + }); + return grouped; + }, [marketCaps]); return ( -
-
-
-

Edit Allocation Caps

-

Select markets and configure caps

+ <> +
+
+
+

Edit Allocation Caps

+

Modify existing caps or add new market caps

+
-
- {/* Collateral Caps Section */} - {collateralCount > 0 && ( -
-
-

Collateral Caps ({collateralCount})

- -
-
- {Array.from(collateralCaps.values()).map((info) => ( -
- - {info.collateralSymbol} - {info.needsCreation && ( - New - )} - { - const val = e.target.value; - if (val === '' || /^\d*\.?\d*$/.test(val)) { - const num = parseFloat(val); - if (val === '' || (num >= 0 && num <= 100)) { - handleUpdateCollateralCap(info.collateralAddress, 'relativeCap', val); - } - } - }} - placeholder="100" - disabled={!isOwner} - className="w-14 rounded bg-hovered px-2 py-1 text-right text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-primary" - /> - % - { - const val = e.target.value; - if (val === '' || /^\d*\.?\d*$/.test(val)) { - handleUpdateCollateralCap(info.collateralAddress, 'absoluteCap', val); - } - }} - placeholder="No limit" - disabled={!isOwner} - className="w-20 rounded bg-hovered px-2 py-1 text-right text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-primary" - /> + {/* Adapter Cap Warning */} + {(() => { + // Check if adapter cap needs attention + const hasAdapterCap = !!existingCaps?.adapterCap; + if (!hasAdapterCap) { + return ( +
+
+
+
+

Adapter Not Authorized

+

+ The Morpho Market V1 adapter is not authorized to allocate funds in this vault. + This will result in all funds remaining idle until the adapter cap is configured. +

+
+
- ))} -
-
- )} + ); + } - {/* Markets Table with Inline Cap Inputs */} -
-

- Markets {selectedCount > 0 ? `(${selectedCount} selected)` : ''} -

- ({ - market: m, - isSelected: selectedMarkets.has(m.uniqueKey), - }))} - onToggleMarket={handleToggleMarket} - disabled={!isOwner} - renderCartItemExtra={(market) => { - const capInfo = selectedMarkets.get(market.uniqueKey); - if (!capInfo) return null; + const relativeCapBigInt = BigInt(existingCaps.adapterCap!.relativeCap); + const absoluteCapBigInt = BigInt(existingCaps.adapterCap!.absoluteCap); + const isFullyAuthorized = relativeCapBigInt >= parseUnits('100', 16) && absoluteCapBigInt >= maxUint128; + if (!isFullyAuthorized) { + const relativeCapPercent = (Number(relativeCapBigInt) / 1e16).toFixed(2); return ( -
-
- { - const val = e.target.value; - if (val === '' || /^\d*\.?\d*$/.test(val)) { - const num = parseFloat(val); - if (val === '' || (num >= 0 && num <= 100)) { - handleUpdateMarketCap(market.uniqueKey, 'relativeCap', val); - } - } - }} - placeholder="100" - disabled={!isOwner} - className="w-14 rounded bg-hovered px-2 py-1 text-right text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-primary" - /> - % -
-
- { - const val = e.target.value; - if (val === '' || /^\d*\.?\d*$/.test(val)) { - handleUpdateMarketCap(market.uniqueKey, 'absoluteCap', val); - } - }} - placeholder="No limit" - disabled={!isOwner} - className="w-20 rounded bg-hovered px-2 py-1 text-right text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-primary" - /> +
+
+
+
+

Adapter Partially Authorized

+

+ The Morpho Market V1 adapter is limited to {relativeCapPercent}% of vault funds. + This may result in idle funds that cannot be allocated to markets. Consider setting the adapter cap to 100% with no absolute limit for optimal capital efficiency. +

+
); - }} - /> -
+ } - {/* Actions */} -
-
-
- + return null; + })()} + + {/* Collateral Caps Section */} + {collateralCaps.size > 0 && ( +
+
+

Collateral Caps ({collateralCaps.size})

+ +
+ + {/* Column Headers */} +
+
Collateral
+
Relative %
+
Absolute ({vaultAssetToken?.symbol ?? 'units'})
+
+ +
+ {Array.from(collateralCaps.values()).map((info) => { + const isNew = !info.existingCapId; + + return ( +
+ +
+ {info.collateralSymbol} + {isNew && ( + New + )} +
+
+ { + const val = e.target.value; + if (val === '' || /^\d*\.?\d*$/.test(val)) { + const num = parseFloat(val); + if (val === '' || (num >= 0 && num <= 100)) { + handleUpdateCollateralCap(info.collateralAddress, 'relativeCap', val); + } + } + }} + placeholder="100" + disabled={!isOwner} + className="w-16 rounded bg-hovered px-2 py-1 text-right text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-primary" + /> + % + +
+
+ { + const val = e.target.value; + if (val === '' || /^\d*\.?\d*$/.test(val)) { + handleUpdateCollateralCap(info.collateralAddress, 'absoluteCap', val); + } + }} + placeholder="No limit" + disabled={!isOwner} + className="w-24 rounded bg-hovered px-2 py-1 text-right text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-primary" + /> + +
+
+ ); + })} +
+
+ )} + + {/* Market Caps Section */} + {marketCaps.size > 0 && ( +
+
+

Market Caps ({marketCaps.size})

+ +
+ + {/* Column Headers */} +
+
Market
+
Relative %
+
Absolute ({vaultAssetToken?.symbol ?? 'units'})
+
+ + ({ + market: info.market, + relativeCap: info.relativeCap, + absoluteCap: info.absoluteCap, + isEditable: true, + isNew: !info.existingCapId, + onUpdateCap: (field, value) => handleUpdateMarketCap(info.market.uniqueKey, field, value), + }))} + showHeaders={false} + vaultAssetSymbol={vaultAssetToken?.symbol} + vaultAssetAddress={vaultAsset} + chainId={chainId} + isOwner={isOwner} + /> +
+ )} + + {/* Add Market Button */} +
+ + {/* Actions */} +
+
+
+ + +
+
-
+ + {/* Add Market Modal */} + {showAddMarketModal && vaultAsset && ( + setShowAddMarketModal(false)} + onAdd={handleAddMarkets} + /> + )} + ); } diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/MarketCapsTable.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/MarketCapsTable.tsx new file mode 100644 index 00000000..4cd7e02f --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/MarketCapsTable.tsx @@ -0,0 +1,145 @@ +import { MarketDetailsBlock } from '@/components/common/MarketDetailsBlock'; +import { Market } from '@/utils/types'; +import { maxUint128 } from 'viem'; +import { findToken } from '@/utils/tokens'; +import { Address } from 'viem'; +import { Badge } from '@/components/common/Badge'; + +type MarketCapRow = { + market: Market; + relativeCap: string; + absoluteCap: string; + isEditable?: boolean; + isNew?: boolean; + onUpdateCap?: (field: 'relativeCap' | 'absoluteCap', value: string) => void; +}; + +type MarketCapsTableProps = { + markets: MarketCapRow[]; + showHeaders?: boolean; + vaultAssetSymbol?: string; + vaultAssetAddress?: Address; + chainId?: number; + isOwner?: boolean; +}; + +export function MarketCapsTable({ + markets, + showHeaders = true, + vaultAssetSymbol, + vaultAssetAddress, + chainId, + isOwner = true +}: MarketCapsTableProps) { + // Get decimals for proper formatting + const vaultAssetDecimals = vaultAssetAddress && chainId + ? findToken(vaultAssetAddress, chainId)?.decimals ?? 18 + : 18; + + const formatAbsoluteCap = (cap: string): string => { + if (!cap || cap === '') { + return 'No limit'; + } + + try { + const capBigInt = BigInt(cap); + if (capBigInt >= maxUint128) { + return 'No limit'; + } + const value = Number(capBigInt) / 10 ** vaultAssetDecimals; + return value.toLocaleString(undefined, { maximumFractionDigits: 2 }); + } catch (e) { + // If we can't parse it as BigInt, return as is + return cap; + } + }; + + if (markets.length === 0) { + return null; + } + + return ( +
+ {showHeaders && ( +
+
Market
+
Relative %
+
Absolute{vaultAssetSymbol ? ` (${vaultAssetSymbol})` : ''}
+
+ )} +
+ {markets.map((row) => ( +
+
+ + {row.isNew && ( + New + )} +
+ {row.isEditable && row.onUpdateCap ? ( + <> +
+ { + const val = e.target.value; + if (val === '' || /^\d*\.?\d*$/.test(val)) { + const num = parseFloat(val); + if (val === '' || (num >= 0 && num <= 100)) { + row.onUpdateCap!('relativeCap', val); + } + } + }} + placeholder="100" + disabled={!isOwner} + className="w-16 rounded bg-hovered px-2 py-1 text-right text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50" + /> + % + +
+
+ { + const val = e.target.value; + if (val === '' || /^\d*\.?\d*$/.test(val)) { + row.onUpdateCap!('absoluteCap', val); + } + }} + placeholder="No limit" + disabled={!isOwner} + className="w-24 rounded bg-hovered px-2 py-1 text-right text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50" + /> + +
+ + ) : ( + <> +
{row.relativeCap}%
+
+ {formatAbsoluteCap(row.absoluteCap)} +
+ + )} +
+ ))} +
+
+ ); +} diff --git a/app/autovault/[chainId]/[vaultAddress]/content.tsx b/app/autovault/[chainId]/[vaultAddress]/content.tsx index abc6ebe3..502b6cf9 100644 --- a/app/autovault/[chainId]/[vaultAddress]/content.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/content.tsx @@ -17,7 +17,8 @@ import { useVaultV2Data } from '@/hooks/useVaultV2Data'; import { getSlicedAddress } from '@/utils/address'; import { formatBalance } from '@/utils/balance'; import { ALL_SUPPORTED_NETWORKS, SupportedNetworks, getNetworkConfig } from '@/utils/networks'; -import { VaultAgentSummary } from './components/VaultAgentSummary'; +import { VaultAllocatorCard } from './components/VaultAllocatorCard'; +import { VaultCollateralsCard } from './components/VaultCollateralsCard'; import { VaultInitializationModal } from './components/VaultInitializationModal'; // Removed VaultMarketAllocations - will be re-added when real data is available import { VaultSettingsModal } from './components/VaultSettingsModal'; @@ -94,7 +95,6 @@ export default function VaultContent() { vaultData?.owner && connectedAddress && vaultData.owner.toLowerCase() === connectedAddress.toLowerCase(), ); - const isFetchingSummary = vaultDataLoading; const isError = !!vaultDataError; const title = vaultData?.displayName ?? fallbackTitle; @@ -103,17 +103,12 @@ export default function VaultContent() { const sentinels = vaultData?.sentinels ?? []; const allocatorCount = allocators.length; const hasNoAllocators = !needsSetup && allocatorCount === 0; - const capsUninitialized = !vaultData?.capsData.needSetupCaps - const capData = vaultData?.capsData + const capsUninitialized = vaultData?.capsData?.needSetupCaps ?? true; + const capData = vaultData?.capsData; + const collateralCaps = capData?.collateralCaps ?? []; + console.log('vaultData', vaultData) - const roleStatusText = useMemo(() => { - if (needsSetup) return 'Adapter pending deployment'; - if (hasNoAllocators) return 'Choose agents to enable automation'; - if (capsUninitialized) return 'Set market caps to complete strategy'; - if (!vaultData?.curator) return 'Curator not assigned yet'; - return 'Vault is configured and ready'; - }, [hasNoAllocators, capsUninitialized, needsSetup, vaultData?.curator]); const assetAddress = vaultData?.assetAddress; @@ -160,189 +155,177 @@ export default function VaultContent() {
- {isFetchingSummary ? ( -
- -
- ) : ( - <> -
-
-

{title}

- {symbolToDisplay && ( - {symbolToDisplay} - )} -
-
- - {isOwner && ( - - )} -
-
- - {needsSetup && networkConfig?.vaultConfig?.marketV1AdapterFactory && ( -
-
-

Complete vault initialization

-

- Deploy adapter, configure registry, and optionally choose an agent to automate - this vault. -

-
- -
- )} - - {hasNoAllocators && isOwner && ( -
-
-

Choose an agent

-

- Add an agent to enable automated allocation and rebalancing. -

-
- -
+
+
+

{title}

+ {symbolToDisplay && ( + {symbolToDisplay} )} - - {capsUninitialized && isOwner && ( -
-
-

Set market caps

-

- Define caps for markets to complete your vault strategy and activate - automation. -

-
- -
+
+
+ + {isOwner && ( + )} +
+
- -
- Total supply -
- {totalSupplyLabel} - {assetAddress && ( - - )} -
-
- {vaultData?.tokenSymbol ? `${vaultData.tokenSymbol} vault supply` : 'Vault token supply'} -
-
-
- Current APY -
{apyLabel}
-
Live APY coming soon
-
-
- Allocators -
{allocatorCount}
-
- {allocatorCount > 0 ? 'Active automation agents' : 'Add an allocator to enable automation'} -
-
-
+ {needsSetup && networkConfig?.vaultConfig?.marketV1AdapterFactory && ( +
+
+

Complete vault initialization

+

+ Deploy adapter, configure registry, and optionally choose an agent to automate + this vault. +

+
+ +
+ )} - 0 && !capsUninitialized} - activeAgents={allocatorCount} - description={ - needsSetup - ? 'Deploy the vault adapter before allocating capital.' - : allocatorCount > 0 && !capsUninitialized - ? 'Allocators are authorized and rebalancing within curator caps.' - : 'Authorize an allocator to resume automated portfolio management.' - } - roleStatusText={roleStatusText} - onManageAgents={() => { - if (needsSetup && networkConfig?.vaultConfig?.marketV1AdapterFactory) { - setShowInitializationModal(true); - return; - } + {hasNoAllocators && isOwner && ( +
+
+

Choose an agent

+

+ Add an agent to enable automated allocation and rebalancing. +

+
+ +
+ )} + + {capsUninitialized && isOwner && ( +
+
+

Set market caps

+

+ Define caps for markets to complete your vault strategy and activate + automation. +

+
+ +
)} + + +
+ Total supply +
+ {totalSupplyLabel} + {assetAddress && ( + + )} +
+
+ {vaultData?.tokenSymbol ? `${vaultData.tokenSymbol} vault supply` : 'Vault token supply'} +
+
+
+ Current APY +
{apyLabel}
+
Live APY coming soon
+
+ { + if (needsSetup && networkConfig?.vaultConfig?.marketV1AdapterFactory) { + setShowInitializationModal(true); + return; + } + setSettingsTab('agents'); + setShowSettings(true); + }} + needsSetup={needsSetup} + isOwner={isOwner} + isLoading={vaultDataLoading} + /> + { + setSettingsTab('allocations'); + setShowSettings(true); + }} + needsSetup={needsSetup} + isOwner={isOwner} + isLoading={vaultDataLoading} + /> +
+ + {/* TODO: Get real market allocations from subgraph */} + {/* */} + setShowSettings(false)} + initialTab={settingsTab} + isOwner={isOwner} + onUpdateMetadata={updateNameAndSymbol} + updatingMetadata={isUpdatingMetadata} + defaultName={vaultData?.displayName ?? ''} + defaultSymbol={vaultData?.displaySymbol ?? ''} + currentName={onChainName ?? ''} + currentSymbol={onChainSymbol ?? ''} + owner={vaultData?.owner} + curator={vaultData?.curator} + allocators={allocators} + sentinels={sentinels} + chainId={supportedChainId} + vaultAsset={assetAddress as Address | undefined} + adapterAddress={adapter} + capData={capData} + onSetAllocator={setAllocator} + updateCaps={updateCaps} + isUpdatingAllocator={isUpdatingAllocator} + isUpdatingCaps={isUpdatingCaps} + onRefresh={() => void refetchVaultData()} + isRefreshing={vaultDataLoading} + />
diff --git a/src/components/common/MarketDetailsBlock.tsx b/src/components/common/MarketDetailsBlock.tsx index 9771010b..458bbfc0 100644 --- a/src/components/common/MarketDetailsBlock.tsx +++ b/src/components/common/MarketDetailsBlock.tsx @@ -16,6 +16,7 @@ type MarketDetailsBlockProps = { defaultCollapsed?: boolean; mode?: 'supply' | 'borrow'; showRewards?: boolean; + disableExpansion?: boolean; }; export function MarketDetailsBlock({ @@ -24,8 +25,9 @@ export function MarketDetailsBlock({ defaultCollapsed = false, mode = 'supply', showRewards = false, + disableExpansion = false, }: MarketDetailsBlockProps): JSX.Element { - const [isExpanded, setIsExpanded] = useState(!defaultCollapsed); + const [isExpanded, setIsExpanded] = useState(!defaultCollapsed && !disableExpansion); const { activeCampaigns, hasActiveRewards } = useMarketCampaigns({ marketId: market.uniqueKey, @@ -44,18 +46,18 @@ export function MarketDetailsBlock({
{/* Collapsible Market Details */}
setIsExpanded(!isExpanded)} + className={`bg-hovered rounded transition-colors ${disableExpansion ? '' : 'cursor-pointer'}`} + onClick={() => !disableExpansion && setIsExpanded(!isExpanded)} onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { + if (!disableExpansion && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setIsExpanded(!isExpanded); } }} - role="button" - tabIndex={0} - aria-expanded={isExpanded} - aria-label={`${isExpanded ? 'Collapse' : 'Expand'} market details`} + role={disableExpansion ? undefined : "button"} + tabIndex={disableExpansion ? undefined : 0} + aria-expanded={disableExpansion ? undefined : isExpanded} + aria-label={disableExpansion ? undefined : `${isExpanded ? 'Collapse' : 'Expand'} market details`} >
@@ -113,9 +115,11 @@ export function MarketDetailsBlock({ )}
-
- {isExpanded ? : } -
+ {!disableExpansion && ( +
+ {isExpanded ? : } +
+ )}
{/* Expanded Market Details */} diff --git a/src/components/common/MarketSelectionModal.tsx b/src/components/common/MarketSelectionModal.tsx new file mode 100644 index 00000000..3e49a4f8 --- /dev/null +++ b/src/components/common/MarketSelectionModal.tsx @@ -0,0 +1,174 @@ +import { useState, useMemo } from 'react'; +import { Address } from 'viem'; +import { Button } from '@/components/common/Button'; +import { MarketsTableWithSameLoanAsset } from '@/components/common/MarketsTableWithSameLoanAsset'; +import { Spinner } from '@/components/common/Spinner'; +import { useMarkets } from '@/hooks/useMarkets'; +import { Market } from '@/utils/types'; +import { SupportedNetworks } from '@/utils/networks'; + +type MarketSelectionModalProps = { + title?: string; + description?: string; + vaultAsset?: Address; + chainId: SupportedNetworks; + excludeMarketIds?: Set; + multiSelect?: boolean; + onClose: () => void; + onSelect: (markets: Market[]) => void; + confirmButtonText?: string; +}; + +/** + * Generic reusable modal for selecting markets + * Can be used anywhere in the app where market selection is needed + */ +export function MarketSelectionModal({ + title = 'Select Markets', + description = 'Choose markets from the list below', + vaultAsset, + chainId, + excludeMarketIds, + multiSelect = true, + onClose, + onSelect, + confirmButtonText, +}: MarketSelectionModalProps) { + const [selectedMarkets, setSelectedMarkets] = useState>(new Set()); + const { markets, loading: marketsLoading } = useMarkets(); + + // Filter available markets + const availableMarkets = useMemo(() => { + if (!markets) return []; + + let filtered = markets.filter((m) => m.morphoBlue.chain.id === chainId); + + // Filter by vault asset if provided + if (vaultAsset) { + filtered = filtered.filter( + (m) => m.loanAsset.address.toLowerCase() === vaultAsset.toLowerCase() + ); + } + + // Exclude already selected markets if provided + if (excludeMarketIds) { + filtered = filtered.filter( + (m) => !excludeMarketIds.has(m.uniqueKey.toLowerCase()) + ); + } + + return filtered; + }, [markets, vaultAsset, chainId, excludeMarketIds]); + + const handleToggleMarket = (marketId: string) => { + setSelectedMarkets((prev) => { + const next = new Set(prev); + if (next.has(marketId)) { + next.delete(marketId); + } else { + if (multiSelect) { + next.add(marketId); + } else { + // Single select mode - clear previous selection + next.clear(); + next.add(marketId); + } + } + return next; + }); + }; + + const handleConfirm = () => { + const marketsToReturn = availableMarkets.filter((m) => + selectedMarkets.has(m.uniqueKey) + ); + onSelect(marketsToReturn); + onClose(); + }; + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + if (marketsLoading) { + return ( +
+
+
+ +
+
+
+ ); + } + + const selectedCount = selectedMarkets.size; + const buttonText = confirmButtonText ?? ( + multiSelect + ? `Select ${selectedCount > 0 ? selectedCount : ''} Market${selectedCount !== 1 ? 's' : ''}` + : 'Select Market' + ); + + return ( +
+
+
+
+

{title}

+

{description}

+
+
+ + {availableMarkets.length === 0 ? ( +
+

+ {excludeMarketIds && excludeMarketIds.size > 0 + ? 'No more markets available to select.' + : 'No markets found matching the criteria.'} +

+
+ ) : ( +
+ ({ + market: m, + isSelected: selectedMarkets.has(m.uniqueKey), + }))} + onToggleMarket={handleToggleMarket} + /> +
+ )} + +
+ {multiSelect && ( +

+ {selectedCount} market{selectedCount !== 1 ? 's' : ''} selected +

+ )} + {!multiSelect &&
} +
+ + +
+
+
+
+ ); +} diff --git a/src/data-sources/morpho-api/v2-vaults.ts b/src/data-sources/morpho-api/v2-vaults.ts index ee895b46..a38eb123 100644 --- a/src/data-sources/morpho-api/v2-vaults.ts +++ b/src/data-sources/morpho-api/v2-vaults.ts @@ -9,6 +9,8 @@ export type VaultV2Cap = { absoluteCap: string; capId: string; idParams: string; + oldRelativeCap?: string; // For delta calculation + oldAbsoluteCap?: string; // For delta calculation }; export type VaultV2Details = { diff --git a/src/hooks/useMarketWarnings.ts b/src/hooks/useMarketWarnings.ts index 28848836..9a5c7a6f 100644 --- a/src/hooks/useMarketWarnings.ts +++ b/src/hooks/useMarketWarnings.ts @@ -18,7 +18,7 @@ export const useMarketWarnings = ( market.oracle, market.oracleAddress, market.morphoBlue?.chain?.id, - market.realizedBadDebt.underlying, + market.realizedBadDebt?.underlying, considerWhitelist, ]); }; diff --git a/src/hooks/useVaultV2.ts b/src/hooks/useVaultV2.ts index dabe3b44..e14c2bdc 100644 --- a/src/hooks/useVaultV2.ts +++ b/src/hooks/useVaultV2.ts @@ -354,43 +354,88 @@ export function useVaultV2({ const txs: `0x${string}`[] = []; caps.forEach((cap) => { - const relativeCapBigInt = BigInt(cap.relativeCap); - const absoluteCapBigInt = BigInt(cap.absoluteCap); + const newRelativeCap = BigInt(cap.relativeCap); + const newAbsoluteCap = BigInt(cap.absoluteCap); + const oldRelativeCap = cap.oldRelativeCap ? BigInt(cap.oldRelativeCap) : 0n; + const oldAbsoluteCap = cap.oldAbsoluteCap ? BigInt(cap.oldAbsoluteCap) : 0n; const idData = cap.idParams as `0x${string}`; - if (relativeCapBigInt > 0n) { - const increaseRelativeCapTx = encodeFunctionData({ - abi: vaultv2Abi, - functionName: 'increaseRelativeCap', - args: [idData, relativeCapBigInt], - }); + // Handle relative cap delta + if (newRelativeCap !== oldRelativeCap) { + if (newRelativeCap > oldRelativeCap) { + // Increase + const increaseRelativeCapTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'increaseRelativeCap', + args: [idData, newRelativeCap], + }); - const submitIncreaseRelativeCapTx = encodeFunctionData({ - abi: vaultv2Abi, - functionName: 'submit', - args: [increaseRelativeCapTx], - }); + const submitIncreaseRelativeCapTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'submit', + args: [increaseRelativeCapTx], + }); + + txs.push(submitIncreaseRelativeCapTx, increaseRelativeCapTx); + } else if (newRelativeCap < oldRelativeCap) { + // Decrease + const decreaseRelativeCapTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'decreaseRelativeCap', + args: [idData, newRelativeCap], + }); - txs.push(submitIncreaseRelativeCapTx, increaseRelativeCapTx); + const submitDecreaseRelativeCapTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'submit', + args: [decreaseRelativeCapTx], + }); + + txs.push(submitDecreaseRelativeCapTx, decreaseRelativeCapTx); + } } - if (absoluteCapBigInt > 0n) { - const increaseAbsoluteCapTx = encodeFunctionData({ - abi: vaultv2Abi, - functionName: 'increaseAbsoluteCap', - args: [idData, absoluteCapBigInt], - }); + // Handle absolute cap delta + if (newAbsoluteCap !== oldAbsoluteCap) { + if (newAbsoluteCap > oldAbsoluteCap) { + // Increase + const increaseAbsoluteCapTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'increaseAbsoluteCap', + args: [idData, newAbsoluteCap], + }); - const submitIncreaseAbsoluteCapTx = encodeFunctionData({ - abi: vaultv2Abi, - functionName: 'submit', - args: [increaseAbsoluteCapTx], - }); + const submitIncreaseAbsoluteCapTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'submit', + args: [increaseAbsoluteCapTx], + }); + + txs.push(submitIncreaseAbsoluteCapTx, increaseAbsoluteCapTx); + } else if (newAbsoluteCap < oldAbsoluteCap) { + // Decrease + const decreaseAbsoluteCapTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'decreaseAbsoluteCap', + args: [idData, newAbsoluteCap], + }); - txs.push(submitIncreaseAbsoluteCapTx, increaseAbsoluteCapTx); + const submitDecreaseAbsoluteCapTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'submit', + args: [decreaseAbsoluteCapTx], + }); + + txs.push(submitDecreaseAbsoluteCapTx, decreaseAbsoluteCapTx); + } } }); + if (txs.length === 0) { + console.log('No cap changes detected'); + return false; + } + const multicallTx = encodeFunctionData({ abi: vaultv2Abi, functionName: 'multicall', diff --git a/src/utils/morpho.ts b/src/utils/morpho.ts index 335d3fcb..ec535d95 100644 --- a/src/utils/morpho.ts +++ b/src/utils/morpho.ts @@ -213,22 +213,18 @@ export function parseCapIdParams(idParams: string): { ); if (decoded[0] === 'this/marketParams') { - const marketParams = decoded[2] as any; - const [loanToken, collateralToken, oracle, irm, lltv] = marketParams; + const marketParamsBlock = decoded[2] as any; + const marketParams = marketParamsBlock[0] as any as MarketParams; // Create a market ID hash from the market params - const marketId = keccak256(encodeAbiParameters(marketParamsType, marketParams)); + const marketId = keccak256(encodeAbiParameters(marketParamsType, [marketParams])); + + console.log('market param', marketParams) return { type: 'market', adapterAddress: decoded[1] as Address, - marketParams: { - loanToken: loanToken as Address, - collateralToken: collateralToken as Address, - oracle: oracle as Address, - irm: irm as Address, - lltv: lltv as bigint, - }, + marketParams, marketId, }; } From 949b6c674c825a60498b9f7e7b8b2a2517b9b16b Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 17 Oct 2025 16:26:34 +0800 Subject: [PATCH 18/29] feat: deposit --- .../components/DepositToVaultModal.tsx | 171 +++++++++ .../components/TotalSupplyCard.tsx | 101 ++++++ .../components/VaultDepositProcessModal.tsx | 143 ++++++++ .../[chainId]/[vaultAddress]/content.tsx | 39 +-- src/hooks/useVaultV2.ts | 84 +++++ src/hooks/useVaultV2Deposit.ts | 324 ++++++++++++++++++ 6 files changed, 833 insertions(+), 29 deletions(-) create mode 100644 app/autovault/[chainId]/[vaultAddress]/components/DepositToVaultModal.tsx create mode 100644 app/autovault/[chainId]/[vaultAddress]/components/TotalSupplyCard.tsx create mode 100644 app/autovault/[chainId]/[vaultAddress]/components/VaultDepositProcessModal.tsx create mode 100644 src/hooks/useVaultV2Deposit.ts diff --git a/app/autovault/[chainId]/[vaultAddress]/components/DepositToVaultModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/DepositToVaultModal.tsx new file mode 100644 index 00000000..3ead8bc3 --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/DepositToVaultModal.tsx @@ -0,0 +1,171 @@ +import React from 'react'; +import { Cross1Icon } from '@radix-ui/react-icons'; +import { Address } from 'viem'; +import { useAccount } from 'wagmi'; +import { Button } from '@/components/common'; +import Input from '@/components/Input/Input'; +import { TokenIcon } from '@/components/TokenIcon'; +import AccountConnect from '@/components/layout/header/AccountConnect'; +import { useLocalStorage } from '@/hooks/useLocalStorage'; +import { useVaultV2Deposit } from '@/hooks/useVaultV2Deposit'; +import { formatBalance } from '@/utils/balance'; +import { VaultDepositProcessModal } from './VaultDepositProcessModal'; + +type DepositToVaultModalProps = { + vaultAddress: Address; + vaultName: string; + assetAddress: Address; + assetSymbol: string; + assetDecimals: number; + chainId: number; + onClose: () => void; + onSuccess?: () => void; +}; + +export function DepositToVaultModal({ + vaultAddress, + vaultName, + assetAddress, + assetSymbol, + assetDecimals, + chainId, + onClose, + onSuccess, +}: DepositToVaultModalProps): JSX.Element { + const { address: account, isConnected } = useAccount(); + const [usePermit2Setting] = useLocalStorage('usePermit2', true); + + const { + depositAmount, + setDepositAmount, + inputError, + setInputError, + tokenBalance, + isApproved, + permit2Authorized, + isLoadingPermit2, + depositPending, + approveAndDeposit, + signAndDeposit, + showProcessModal, + setShowProcessModal, + currentStep, + } = useVaultV2Deposit({ + vaultAddress, + assetAddress, + assetSymbol, + assetDecimals, + chainId, + vaultName, + onSuccess, + }); + + return ( + <> +
+
+
+ + +
+
+
+ + Deposit {assetSymbol} +
+ Deposit to {vaultName} +
+
+ + {!isConnected ? ( +
+ +
+ ) : ( + <> + {/* Deposit Input Section */} +
+
+
+ Deposit amount +

+ Balance: {formatBalance(tokenBalance ?? BigInt(0), assetDecimals)}{' '} + {assetSymbol} +

+
+ +
+
+ + {inputError && ( +

+ {inputError} +

+ )} +
+ + {!permit2Authorized || (!usePermit2Setting && !isApproved) ? ( + + ) : ( + + )} +
+
+
+ + )} +
+
+
+ + {showProcessModal && ( + setShowProcessModal(false)} + vaultName={vaultName} + assetSymbol={assetSymbol} + amount={depositAmount} + assetDecimals={assetDecimals} + usePermit2={usePermit2Setting} + /> + )} + + ); +} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/TotalSupplyCard.tsx b/app/autovault/[chainId]/[vaultAddress]/components/TotalSupplyCard.tsx new file mode 100644 index 00000000..dab3f160 --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/TotalSupplyCard.tsx @@ -0,0 +1,101 @@ +import React, { useMemo, useState } from 'react'; +import { PlusIcon } from '@radix-ui/react-icons'; +import { Address } from 'viem'; +import { useReadContract } from 'wagmi'; +import { TokenIcon } from '@/components/TokenIcon'; +import { vaultv2Abi } from '@/abis/vaultv2'; +import { formatBalance } from '@/utils/balance'; +import { DepositToVaultModal } from './DepositToVaultModal'; + +type VaultTotalAssetsCardProps = { + tokenDecimals?: number; + tokenSymbol?: string; + assetAddress?: Address; + chainId: number; + vaultAddress: Address; + vaultName: string; + onRefresh?: () => void; +}; + +export function TotalSupplyCard({ + tokenDecimals, + tokenSymbol, + assetAddress, + chainId, + vaultAddress, + vaultName, + onRefresh, +}: VaultTotalAssetsCardProps): JSX.Element { + const [showDepositModal, setShowDepositModal] = useState(false); + + // Read totalAssets directly from the vault contract + const { data: totalAssets, refetch: refetchTotalAssets } = useReadContract({ + address: vaultAddress, + abi: vaultv2Abi, + functionName: 'totalAssets', + chainId, + query: { + refetchInterval: 10000, // Refetch every 10 seconds + }, + }); + + const totalAssetsLabel = useMemo(() => { + if (totalAssets === undefined || tokenDecimals === undefined) return '--'; + + try { + const numericAssets = formatBalance(totalAssets, tokenDecimals); + const formattedAssets = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 2, + }).format(numericAssets); + + return `${formattedAssets}${tokenSymbol ? ` ${tokenSymbol}` : ''}`.trim(); + } catch (_error) { + return '--'; + } + }, [tokenDecimals, tokenSymbol, totalAssets]); + + const handleDepositSuccess = () => { + setShowDepositModal(false); + void refetchTotalAssets(); + onRefresh?.(); + }; + + return ( + <> +
+
+ Total Assets + {assetAddress && tokenSymbol && tokenDecimals !== undefined && ( + + )} +
+
+ {totalAssetsLabel} + {assetAddress && ( + + )} +
+
+ + {showDepositModal && assetAddress && tokenSymbol && tokenDecimals !== undefined && ( + setShowDepositModal(false)} + onSuccess={handleDepositSuccess} + /> + )} + + ); +} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultDepositProcessModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultDepositProcessModal.tsx new file mode 100644 index 00000000..08d955e3 --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultDepositProcessModal.tsx @@ -0,0 +1,143 @@ +import React, { useMemo } from 'react'; +import { Cross1Icon } from '@radix-ui/react-icons'; +import { motion, AnimatePresence } from 'framer-motion'; +import { FaCheckCircle, FaCircle } from 'react-icons/fa'; +import { VaultDepositStepType } from '@/hooks/useVaultV2Deposit'; +import { formatBalance } from '@/utils/balance'; + +type VaultDepositProcessModalProps = { + currentStep: VaultDepositStepType; + onClose: () => void; + vaultName: string; + assetSymbol: string; + amount: bigint; + assetDecimals: number; + usePermit2?: boolean; +}; + +export function VaultDepositProcessModal({ + currentStep, + onClose, + vaultName, + assetSymbol, + amount, + assetDecimals, + usePermit2 = true, +}: VaultDepositProcessModalProps): JSX.Element { + const steps = useMemo(() => { + if (usePermit2) { + return [ + { + key: 'approve', + label: 'Authorize Permit2', + detail: `This one-time approval makes sure you don't need to send approval tx again in the future.`, + }, + { + key: 'signing', + label: 'Sign message in wallet', + detail: 'Sign a Permit2 signature to authorize the deposit', + }, + { + key: 'depositing', + label: 'Confirm Deposit', + detail: 'Confirm transaction in wallet to complete the deposit', + }, + ]; + } + + // Standard ERC20 approval flow + return [ + { + key: 'approve', + label: 'Approve Token', + detail: `Approve ${assetSymbol} for spending`, + }, + { + key: 'depositing', + label: 'Confirm Deposit', + detail: 'Confirm transaction in wallet to complete the deposit', + }, + ]; + }, [usePermit2, assetSymbol]); + + const getStepStatus = (stepKey: string) => { + const currentIndex = steps.findIndex((step) => step.key === currentStep); + const stepIndex = steps.findIndex((step) => step.key === stepKey); + + if (stepIndex < currentIndex) { + return 'done'; + } + if (stepKey === currentStep) { + return 'current'; + } + return 'undone'; + }; + + const formattedAmount = useMemo(() => { + return formatBalance(amount, assetDecimals).toString(); + }, [amount, assetDecimals]); + + return ( + + + + + +
+

Deposit {assetSymbol}

+

+ Depositing {formattedAmount} {assetSymbol} to {vaultName} +

+ + {/* Steps */} +
+ {steps.map((step) => { + const status = getStepStatus(step.key); + return ( +
+
+ {status === 'done' ? ( + + ) : status === 'current' ? ( + + ) : ( + + )} +
+
+
{step.label}
+
{step.detail}
+
+
+ ); + })} +
+
+
+
+
+ ); +} diff --git a/app/autovault/[chainId]/[vaultAddress]/content.tsx b/app/autovault/[chainId]/[vaultAddress]/content.tsx index 502b6cf9..147a8637 100644 --- a/app/autovault/[chainId]/[vaultAddress]/content.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/content.tsx @@ -17,6 +17,7 @@ import { useVaultV2Data } from '@/hooks/useVaultV2Data'; import { getSlicedAddress } from '@/utils/address'; import { formatBalance } from '@/utils/balance'; import { ALL_SUPPORTED_NETWORKS, SupportedNetworks, getNetworkConfig } from '@/utils/networks'; +import { TotalSupplyCard } from './components/TotalSupplyCard'; import { VaultAllocatorCard } from './components/VaultAllocatorCard'; import { VaultCollateralsCard } from './components/VaultCollateralsCard'; import { VaultInitializationModal } from './components/VaultInitializationModal'; @@ -112,22 +113,6 @@ export default function VaultContent() { const assetAddress = vaultData?.assetAddress; - const totalSupplyLabel = useMemo(() => { - if (!vaultData?.totalSupply || vaultData?.tokenDecimals === undefined) return '--'; - - try { - const rawSupply = BigInt(vaultData.totalSupply); - const numericSupply = formatBalance(rawSupply, vaultData.tokenDecimals); - const formattedSupply = new Intl.NumberFormat('en-US', { - maximumFractionDigits: 2, - }).format(numericSupply); - - return `${formattedSupply}${vaultData.tokenSymbol ? ` ${vaultData.tokenSymbol}` : ''}`.trim(); - } catch (_error) { - return '--'; - } - }, [vaultData?.tokenDecimals, vaultData?.tokenSymbol, vaultData?.totalSupply]); - // TODO: Get real APY from subgraph or calculate from market allocations const apyLabel = '0%'; @@ -253,22 +238,18 @@ export default function VaultContent() { )} -
- Total supply -
- {totalSupplyLabel} - {assetAddress && ( - - )} -
-
- {vaultData?.tokenSymbol ? `${vaultData.tokenSymbol} vault supply` : 'Vault token supply'} -
-
+ void refetchVaultData()} + />
Current APY
{apyLabel}
-
Live APY coming soon
=> { + if (!account || !vaultAddress) return false; + + const depositTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'deposit', + args: [amount, receiver], + }); + + try { + await sendDepositTx({ + account, + to: vaultAddress, + data: depositTx, + chainId: chainIdToUse, + }); + return true; + } catch (depositError) { + if (depositError instanceof Error && depositError.message.toLowerCase().includes('reject')) { + return false; + } + console.error('Failed to deposit to vault', depositError); + throw depositError; + } + }, + [account, chainIdToUse, sendDepositTx, vaultAddress], + ); + + const withdraw = useCallback( + async (amount: bigint, receiver: Address, owner: Address): Promise => { + if (!account || !vaultAddress) return false; + + const withdrawTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'withdraw', + args: [amount, receiver, owner], + }); + + try { + await sendWithdrawTx({ + account, + to: vaultAddress, + data: withdrawTx, + chainId: chainIdToUse, + }); + return true; + } catch (withdrawError) { + if (withdrawError instanceof Error && withdrawError.message.toLowerCase().includes('reject')) { + return false; + } + console.error('Failed to withdraw from vault', withdrawError); + throw withdrawError; + } + }, + [account, chainIdToUse, sendWithdrawTx, vaultAddress], + ); + const adapter = useMemo(() => { if (!data) return zeroAddress; return data as Address; @@ -494,5 +574,9 @@ export function useVaultV2({ isUpdatingAllocator, updateCaps, isUpdatingCaps, + deposit, + isDepositing, + withdraw, + isWithdrawing, }; } diff --git a/src/hooks/useVaultV2Deposit.ts b/src/hooks/useVaultV2Deposit.ts new file mode 100644 index 00000000..a58f6a25 --- /dev/null +++ b/src/hooks/useVaultV2Deposit.ts @@ -0,0 +1,324 @@ +import { useCallback, useState, Dispatch, SetStateAction } from 'react'; +import { Address, encodeFunctionData } from 'viem'; +import { useAccount, useBalance } from 'wagmi'; +import morphoBundlerAbi from '@/abis/bundlerV2'; +import { useERC20Approval } from '@/hooks/useERC20Approval'; +import { useLocalStorage } from '@/hooks/useLocalStorage'; +import { usePermit2 } from '@/hooks/usePermit2'; +import { useStyledToast } from '@/hooks/useStyledToast'; +import { useTransactionWithToast } from '@/hooks/useTransactionWithToast'; +import { formatBalance } from '@/utils/balance'; +import { getBundlerV2, MONARCH_TX_IDENTIFIER } from '@/utils/morpho'; +import { GAS_COSTS, GAS_MULTIPLIER } from 'app/markets/components/constants'; + +export type VaultDepositStepType = 'approve' | 'signing' | 'depositing'; + +export type UseVaultV2DepositReturn = { + // State + depositAmount: bigint; + setDepositAmount: Dispatch>; + inputError: string | null; + setInputError: Dispatch>; + showProcessModal: boolean; + setShowProcessModal: Dispatch>; + currentStep: VaultDepositStepType; + + // Balance data + tokenBalance: bigint | undefined; + + // Transaction state + isApproved: boolean; + permit2Authorized: boolean; + isLoadingPermit2: boolean; + depositPending: boolean; + + // Actions + approveAndDeposit: () => Promise; + signAndDeposit: () => Promise; +}; + +type UseVaultV2DepositParams = { + vaultAddress: Address; + assetAddress: Address; + assetSymbol: string; + assetDecimals: number; + chainId: number; + vaultName: string; + onSuccess?: () => void; +}; + +export function useVaultV2Deposit({ + vaultAddress, + assetAddress, + assetSymbol, + assetDecimals, + chainId, + vaultName, + onSuccess, +}: UseVaultV2DepositParams): UseVaultV2DepositReturn { + // State + const [depositAmount, setDepositAmount] = useState(BigInt(0)); + const [inputError, setInputError] = useState(null); + const [showProcessModal, setShowProcessModal] = useState(false); + const [currentStep, setCurrentStep] = useState('approve'); + const [usePermit2Setting] = useLocalStorage('usePermit2', true); + + const { address: account } = useAccount(); + const toast = useStyledToast(); + + // Get token balance + const { data: tokenBalance } = useBalance({ + token: assetAddress, + address: account, + chainId, + }); + + // Handle Permit2 authorization - authorize bundler to use Permit2 on behalf of user + const { + authorizePermit2, + permit2Authorized, + isLoading: isLoadingPermit2, + signForBundlers, + } = usePermit2({ + user: account as Address, + spender: getBundlerV2(chainId), + token: assetAddress, + refetchInterval: 10000, + chainId, + tokenSymbol: assetSymbol, + amount: depositAmount, + }); + + // Handle ERC20 approval - approve bundler for standard flow + const { isApproved, approve } = useERC20Approval({ + token: assetAddress, + spender: getBundlerV2(chainId), + amount: depositAmount, + tokenSymbol: assetSymbol, + }); + + // Transaction handler + const { isConfirming: depositPending, sendTransactionAsync } = useTransactionWithToast({ + toastId: 'vault-deposit', + pendingText: `Depositing ${formatBalance(depositAmount, assetDecimals)} ${assetSymbol}`, + successText: `${assetSymbol} Deposited to Vault`, + errorText: 'Failed to deposit', + chainId, + pendingDescription: `Depositing to ${vaultName}...`, + successDescription: `Successfully deposited to ${vaultName}`, + onSuccess, + }); + + // Execute deposit transaction + const executeDepositTransaction = useCallback(async () => { + try { + const txs: `0x${string}`[] = []; + let gas = undefined; + + if (usePermit2Setting) { + // Permit2 flow: Sign permit and use bundler to deposit + const { sigs, permitSingle } = await signForBundlers(); + + const tx1 = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'approve2', + args: [permitSingle, sigs, false], + }); + + // transferFrom with permit2 + const tx2 = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'transferFrom2', + args: [assetAddress, depositAmount], + }); + + txs.push(tx1, tx2); + } else { + // Standard ERC20 flow: Transfer tokens to bundler first + txs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'erc20TransferFrom', + args: [assetAddress, depositAmount], + }), + ); + + // Standard Flow: add gas + gas = GAS_COSTS.SINGLE_SUPPLY; // Using same gas estimate as supply + } + + setCurrentStep('depositing'); + + const minShares = BigInt(1); + const erc4626DepositTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'erc4626Deposit', + args: [vaultAddress, depositAmount, minShares, account as Address], + }); + + txs.push(erc4626DepositTx); + + // add timeout here to prevent rabby reverting + await new Promise((resolve) => setTimeout(resolve, 800)); + + await sendTransactionAsync({ + account, + to: getBundlerV2(chainId), + data: (encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'multicall', + args: [txs], + }) + MONARCH_TX_IDENTIFIER) as `0x${string}`, + value: 0n, + + // Only add gas for standard approval flow -> skip gas estimation + gas: gas ? BigInt(gas * GAS_MULTIPLIER) : undefined, + }); + + setShowProcessModal(false); + + return true; + } catch (error: unknown) { + setShowProcessModal(false); + toast.error('Deposit Failed', 'Deposit to vault failed or cancelled'); + return false; + } + }, [ + account, + assetAddress, + vaultAddress, + depositAmount, + sendTransactionAsync, + signForBundlers, + usePermit2Setting, + toast, + chainId, + ]); + + // Approve and deposit handler + const approveAndDeposit = useCallback(async () => { + if (!account) { + toast.info('No account connected', 'Please connect your wallet to continue.'); + return; + } + + try { + setShowProcessModal(true); + setCurrentStep('approve'); + + if (usePermit2Setting) { + // Permit2 flow + try { + await authorizePermit2(); + setCurrentStep('signing'); + + // Small delay to prevent UI glitches + await new Promise((resolve) => setTimeout(resolve, 500)); + + await executeDepositTransaction(); + } catch (error: unknown) { + console.error('Error in Permit2 flow:', error); + if (error instanceof Error) { + if (error.message.includes('User rejected')) { + toast.error('Transaction rejected', 'Transaction rejected by user'); + } else { + toast.error('Error', 'Failed to process Permit2 transaction'); + } + } else { + toast.error('Error', 'An unexpected error occurred'); + } + throw error; + } + return; + } + + // Standard ERC20 flow + if (!isApproved) { + try { + await approve(); + setCurrentStep('depositing'); + + // Small delay to prevent UI glitches + await new Promise((resolve) => setTimeout(resolve, 1000)); + } catch (error: unknown) { + console.error('Error in approval:', error); + if (error instanceof Error) { + if (error.message.includes('User rejected')) { + toast.error('Transaction rejected', 'Approval rejected by user'); + } else { + toast.error('Transaction Error', 'Failed to approve token'); + } + } else { + toast.error('Transaction Error', 'An unexpected error occurred during approval'); + } + throw error; + } + } else { + setCurrentStep('depositing'); + } + + await executeDepositTransaction(); + } catch (error: unknown) { + console.error('Error in approveAndDeposit:', error); + setShowProcessModal(false); + } + }, [ + account, + authorizePermit2, + executeDepositTransaction, + usePermit2Setting, + isApproved, + approve, + toast, + ]); + + // Sign and deposit handler (for when already authorized) + const signAndDeposit = useCallback(async () => { + if (!account) { + toast.info('No account connected', 'Please connect your wallet to continue.'); + return; + } + + try { + setShowProcessModal(true); + setCurrentStep('signing'); + await executeDepositTransaction(); + } catch (error: unknown) { + console.error('Error in signAndDeposit:', error); + setShowProcessModal(false); + if (error instanceof Error) { + if (error.message.includes('User rejected')) { + toast.error('Transaction rejected', 'Transaction rejected by user'); + } else { + toast.error('Transaction Error', 'Failed to process transaction'); + } + } else { + toast.error('Transaction Error', 'An unexpected error occurred'); + } + } + }, [account, executeDepositTransaction, toast]); + + return { + // State + depositAmount, + setDepositAmount, + inputError, + setInputError, + showProcessModal, + setShowProcessModal, + currentStep, + + // Balance data + tokenBalance: tokenBalance?.value, + + // Transaction state + isApproved, + permit2Authorized, + isLoadingPermit2, + depositPending, + + // Actions + approveAndDeposit, + signAndDeposit, + }; +} From 97688228e8088c4b261de3d943f266a3e70abb6a Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 20 Oct 2025 16:34:08 +0800 Subject: [PATCH 19/29] feat: rename allocation -> cap --- .../components/VaultSettingsModal.tsx | 8 ++++---- .../settings/{AllocationsTab.tsx => CapsTab.tsx} | 14 +++++++------- .../{CurrentAllocations.tsx => CurrentCaps.tsx} | 10 +++++----- .../{EditAllocations.tsx => EditCaps.tsx} | 10 +++++----- .../[vaultAddress]/components/settings/index.ts | 2 +- .../[vaultAddress]/components/settings/types.ts | 4 ++-- .../[chainId]/[vaultAddress]/content.tsx | 15 +++++++-------- src/utils/morpho.ts | 2 -- 8 files changed, 31 insertions(+), 34 deletions(-) rename app/autovault/[chainId]/[vaultAddress]/components/settings/{AllocationsTab.tsx => CapsTab.tsx} (75%) rename app/autovault/[chainId]/[vaultAddress]/components/settings/{CurrentAllocations.tsx => CurrentCaps.tsx} (97%) rename app/autovault/[chainId]/[vaultAddress]/components/settings/{EditAllocations.tsx => EditCaps.tsx} (98%) diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx index 0bdd7182..1872a28d 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx @@ -5,13 +5,13 @@ import { ReloadIcon } from '@radix-ui/react-icons'; import { Address } from 'viem'; import { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { SupportedNetworks } from '@/utils/networks'; -import { GeneralTab, AgentsTab, AllocationsTab, SettingsTab } from './settings'; +import { GeneralTab, AgentsTab, CapsTab, SettingsTab } from './settings'; import { CapData } from '@/hooks/useVaultV2Data'; const TABS: { id: SettingsTab; label: string }[] = [ { id: 'general', label: 'General' }, { id: 'agents', label: 'Agent' }, - { id: 'allocations', label: 'Allocation' }, + { id: 'caps', label: 'Caps' }, ]; type VaultSettingsModalProps = { @@ -146,9 +146,9 @@ export function VaultSettingsModal({ chainId={chainId} /> ); - case 'allocations': + case 'caps': return ( - ) : ( - setIsEditing(true)} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentCaps.tsx similarity index 97% rename from app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx rename to app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentCaps.tsx index 59df8f62..5182bae4 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentAllocations.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentCaps.tsx @@ -12,7 +12,7 @@ import { MarketCapsTable } from './MarketCapsTable'; import { MarketDetailsBlock } from '@/components/common/MarketDetailsBlock'; import { CollateralCapTooltip } from './Tooltips'; -type CurrentAllocationsProps = { +type CurrentCapsProps = { existingCaps?: CapData; isOwner: boolean; onStartEdit: () => void; @@ -20,13 +20,13 @@ type CurrentAllocationsProps = { chainId: number }; -export function CurrentAllocations({ +export function CurrentCaps({ existingCaps, isOwner, onStartEdit, chainId, vaultAsset -}: CurrentAllocationsProps) { +}: CurrentCapsProps) { const { markets, loading: marketsLoading } = useMarkets(); const [expandedCollaterals, setExpandedCollaterals] = useState>(new Set()); @@ -140,9 +140,9 @@ export function CurrentAllocations({
-

Allocation Caps

+

Cap Settings

- Set limits on how much of each asset can be allocated across markets and collaterals. + Define allocation limits across markets and collaterals to control agent behavior.

diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/EditCaps.tsx similarity index 98% rename from app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx rename to app/autovault/[chainId]/[vaultAddress]/components/settings/EditCaps.tsx index 4b1d8d88..c419ea54 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/EditAllocations.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/EditCaps.tsx @@ -17,7 +17,7 @@ import { Market } from '@/utils/types'; import { PlusIcon } from '@radix-ui/react-icons'; import { Badge } from '@/components/common/Badge'; -type EditAllocationsProps = { +type EditCapsProps = { existingCaps?: CapData; vaultAsset?: Address; chainId: SupportedNetworks; @@ -43,7 +43,7 @@ type MarketCapInfo = { existingCapId?: string; }; -export function EditAllocations({ +export function EditCaps({ existingCaps, vaultAsset, chainId, @@ -52,7 +52,7 @@ export function EditAllocations({ adapterAddress, onCancel, onSave -}: EditAllocationsProps) { +}: EditCapsProps) { const [marketCaps, setMarketCaps] = useState>(new Map()); const [collateralCaps, setCollateralCaps] = useState>(new Map()); const [showAddMarketModal, setShowAddMarketModal] = useState(false); @@ -357,8 +357,8 @@ export function EditAllocations({
-

Edit Allocation Caps

-

Modify existing caps or add new market caps

+

Edit Cap Settings

+

Modify allocation limits or add new market caps

diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/index.ts b/app/autovault/[chainId]/[vaultAddress]/components/settings/index.ts index aeca8ffc..005eaebd 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/index.ts +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/index.ts @@ -1,4 +1,4 @@ export { GeneralTab } from './GeneralTab'; export { AgentsTab } from './AgentsTab'; -export { AllocationsTab } from './AllocationsTab'; +export { CapsTab } from './CapsTab'; export * from './types'; diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts b/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts index 2cd5498b..e6b2ef9a 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts @@ -4,7 +4,7 @@ import { SupportedNetworks } from '@/utils/networks'; import { Market } from '@/utils/types'; import { CapData } from '@/hooks/useVaultV2Data'; -export type SettingsTab = 'general' | 'agents' | 'allocations'; +export type SettingsTab = 'general' | 'agents' | 'caps'; export type MarketCapState = { market: Market; @@ -34,7 +34,7 @@ export type AgentsTabProps = { chainId: SupportedNetworks; }; -export type AllocationsTabProps = { +export type CapsTabProps = { isOwner: boolean; chainId: SupportedNetworks; vaultAsset?: Address; diff --git a/app/autovault/[chainId]/[vaultAddress]/content.tsx b/app/autovault/[chainId]/[vaultAddress]/content.tsx index 147a8637..a0fa1079 100644 --- a/app/autovault/[chainId]/[vaultAddress]/content.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/content.tsx @@ -52,7 +52,7 @@ export default function VaultContent() { } }, [supportedChainId]); - const [settingsTab, setSettingsTab] = useState<'general' | 'agents' | 'allocations'>('general'); + const [settingsTab, setSettingsTab] = useState<'general' | 'agents' | 'caps'>('general'); const [showSettings, setShowSettings] = useState(false); const [showInitializationModal, setShowInitializationModal] = useState(false); @@ -217,10 +217,9 @@ export default function VaultContent() { {capsUninitialized && isOwner && (
-

Set market caps

+

Configure allocation caps

- Define caps for markets to complete your vault strategy and activate - automation. + Set allocation limits for markets to complete your vault strategy and activate automation.

)} @@ -241,7 +240,7 @@ export default function VaultContent() { { - setSettingsTab('allocations'); + setSettingsTab('caps'); setShowSettings(true); }} needsSetup={needsSetup} diff --git a/src/utils/morpho.ts b/src/utils/morpho.ts index ec535d95..f96f435b 100644 --- a/src/utils/morpho.ts +++ b/src/utils/morpho.ts @@ -219,8 +219,6 @@ export function parseCapIdParams(idParams: string): { // Create a market ID hash from the market params const marketId = keccak256(encodeAbiParameters(marketParamsType, [marketParams])); - console.log('market param', marketParams) - return { type: 'market', adapterAddress: decoded[1] as Address, From 9c432197fc8e854829e27bbabef6c45d7a6e2486 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 21 Oct 2025 17:02:38 +0800 Subject: [PATCH 20/29] feat: fix allocations --- AGENTS.md | 7 + .../components/TotalSupplyCard.tsx | 13 +- .../components/VaultMarketAllocations.tsx | 227 ++++++++++----- .../allocations/AllocationPieChart.tsx | 48 +++ .../components/allocations/CollateralView.tsx | 73 +++++ .../components/allocations/MarketView.tsx | 114 ++++++++ .../[chainId]/[vaultAddress]/content.tsx | 34 ++- .../common/MarketCapInputCompact.tsx | 191 ------------ src/components/common/MarketCapTable.tsx | 274 ------------------ src/hooks/useAllocations.ts | 92 ++++++ src/hooks/useVaultV2.ts | 24 +- src/utils/monarch-agent.ts | 2 +- src/utils/vaultAllocation.ts | 49 ++++ 13 files changed, 590 insertions(+), 558 deletions(-) create mode 100644 app/autovault/[chainId]/[vaultAddress]/components/allocations/AllocationPieChart.tsx create mode 100644 app/autovault/[chainId]/[vaultAddress]/components/allocations/CollateralView.tsx create mode 100644 app/autovault/[chainId]/[vaultAddress]/components/allocations/MarketView.tsx delete mode 100644 src/components/common/MarketCapInputCompact.tsx delete mode 100644 src/components/common/MarketCapTable.tsx create mode 100644 src/hooks/useAllocations.ts create mode 100644 src/utils/vaultAllocation.ts diff --git a/AGENTS.md b/AGENTS.md index ea05fff9..25f8fcd2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,13 @@ Consult `docs/Styling.md` before touching UI. Always follow the documented desig ## Implementation Mindset Default to the simplest viable implementation first. Reach for straightforward data flows, avoid premature abstractions, and only layer on complexity when the trivial approach no longer meets requirements. +## Function Organization & Separation of Concerns +Never define utility functions or business logic inside hooks, components, or classes. Extract them into dedicated utility files in `src/utils/`. This principle—often called **Single Responsibility Principle** or **Separation of Concerns**—keeps code testable, reusable, and maintainable. For example: +- ❌ Bad: Defining `readAllocation()` inside `useAllocations.ts` +- ✅ Good: Creating `src/utils/vaultAllocation.ts` with `readAllocation()`, `formatAllocationAmount()`, etc., then importing into the hook + +Hooks should orchestrate effects and state; components should render UI; utilities should handle pure logic. Keep each layer focused on its single responsibility. + ## Git Ownership Never run git commits, pushes, or other history-altering commands—leave all git operations to the maintainers. diff --git a/app/autovault/[chainId]/[vaultAddress]/components/TotalSupplyCard.tsx b/app/autovault/[chainId]/[vaultAddress]/components/TotalSupplyCard.tsx index dab3f160..41e83d25 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/TotalSupplyCard.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/TotalSupplyCard.tsx @@ -8,6 +8,7 @@ import { formatBalance } from '@/utils/balance'; import { DepositToVaultModal } from './DepositToVaultModal'; type VaultTotalAssetsCardProps = { + totalAssets?: bigint tokenDecimals?: number; tokenSymbol?: string; assetAddress?: Address; @@ -24,20 +25,11 @@ export function TotalSupplyCard({ chainId, vaultAddress, vaultName, + totalAssets, onRefresh, }: VaultTotalAssetsCardProps): JSX.Element { const [showDepositModal, setShowDepositModal] = useState(false); - // Read totalAssets directly from the vault contract - const { data: totalAssets, refetch: refetchTotalAssets } = useReadContract({ - address: vaultAddress, - abi: vaultv2Abi, - functionName: 'totalAssets', - chainId, - query: { - refetchInterval: 10000, // Refetch every 10 seconds - }, - }); const totalAssetsLabel = useMemo(() => { if (totalAssets === undefined || tokenDecimals === undefined) return '--'; @@ -56,7 +48,6 @@ export function TotalSupplyCard({ const handleDepositSuccess = () => { setShowDepositModal(false); - void refetchTotalAssets(); onRefresh?.(); }; diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx index 48e6f4a3..be59c8e7 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx @@ -1,87 +1,182 @@ -import OracleVendorBadge from '@/components/OracleVendorBadge'; -import { TokenIcon } from '@/components/TokenIcon'; -import { VaultAllocation } from '@/hooks/useAutovaultData'; - -const formatPercent = (value: number | null | undefined) => - typeof value === 'number' && Number.isFinite(value) ? `${value.toFixed(2)}%` : '--'; - -const formatApy = formatPercent; +import { useMemo, useState } from 'react'; +import { Address } from 'viem'; +import { Spinner } from '@/components/common/Spinner'; +import { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; +import { AllocationData } from '@/hooks/useAllocations'; +import { useMarkets } from '@/hooks/useMarkets'; +import { parseCapIdParams } from '@/utils/morpho'; +import { findToken } from '@/utils/tokens'; +import { SupportedNetworks } from '@/utils/networks'; +import { CollateralView } from './allocations/CollateralView'; +import { MarketView } from './allocations/MarketView'; +import { formatBalance } from '@/utils/balance'; type VaultMarketAllocationsProps = { - allocations: VaultAllocation[]; + totalAssets?: bigint + marketCaps: VaultV2Cap[]; + collateralCaps: VaultV2Cap[]; + allocations: AllocationData[]; vaultAssetSymbol: string; + vaultAssetDecimals: number; + chainId: SupportedNetworks; + isLoading: boolean; }; -export function VaultMarketAllocations({ allocations, vaultAssetSymbol }: VaultMarketAllocationsProps) { - if (allocations.length === 0) { +type ViewMode = 'collateral' | 'market'; + +export function VaultMarketAllocations({ + totalAssets, + marketCaps, + collateralCaps, + allocations, + vaultAssetSymbol, + vaultAssetDecimals, + chainId, + isLoading, +}: VaultMarketAllocationsProps) { + const [viewMode, setViewMode] = useState('collateral'); + const { markets } = useMarkets(); + + // Create a map of capId -> allocation amount + const allocationMap = useMemo(() => { + const map = new Map(); + allocations.forEach((alloc) => { + map.set(alloc.capId, alloc.allocation); + }); + return map; + }, [allocations]); + + // Prepare collateral data + const collateralData = useMemo(() => { + return collateralCaps + .map((cap) => { + const parsed = parseCapIdParams(cap.idParams); + if (!parsed.collateralToken) return null; + + const collateralToken = findToken(parsed.collateralToken, chainId); + const allocation = allocationMap.get(cap.capId) ?? 0n; + + return { + collateralAddress: parsed.collateralToken, + collateralSymbol: collateralToken?.symbol ?? 'Unknown', + allocation, + }; + }) + .filter((item): item is NonNullable => item !== null); + }, [collateralCaps, allocationMap, chainId]); + + // Prepare market data + const marketData = useMemo(() => { + return marketCaps + .map((cap) => { + const parsed = parseCapIdParams(cap.idParams); + if (parsed.type !== 'market' || !parsed.marketId) return null; + + const market = markets.find((m) => m.uniqueKey.toLowerCase() === parsed.marketId?.toLowerCase()); + if (!market) return null; + + const allocation = allocationMap.get(cap.capId) ?? 0n; + + return { + market, + allocation, + }; + }) + .filter((item): item is NonNullable => item !== null); + }, [marketCaps, markets, allocationMap]); + + const totalAllocation = useMemo(() => { + return totalAssets ?? allocations.reduce((sum, allocation) => sum + allocation.allocation, 0n) + }, [totalAssets]) + + const hasAnyAllocations = useMemo(() => totalAllocation > 0n, [totalAllocation]) + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (collateralData.length === 0 && marketData.length === 0) { return (
- No markets supplied yet. Configure your strategy to start allocating assets. + No markets configured yet. Configure caps in settings to start allocating assets.
); } return ( -
-
+
+ {/* Header */} +
-

Active Markets

-

Supply allocations managed by this vault.

+

+ {hasAnyAllocations ? 'Active Allocations' : 'Market Configuration'} +

+

+ {hasAnyAllocations + ? 'Current asset distribution across markets' + : 'Markets are configured but no assets have been allocated yet'} +

-
- Vault asset: {vaultAssetSymbol} +
+
+ Asset: {vaultAssetSymbol} +
+ {hasAnyAllocations && ( +
+ Total: {formatBalance(totalAssets ?? 0n, vaultAssetDecimals)} {vaultAssetSymbol} +
+ )}
-
- - - - - - - - - - - - {allocations.map((allocation) => ( - - - - - - - - ))} - -
IDAllocationAPYRiskVault Share
-
- - - {allocation.marketId} - -
-
- {allocation.allocationFormatted ?? `-- ${vaultAssetSymbol}`} - {formatApy(allocation.apy)} -
- - LLTV {formatPercent(allocation.lltv)} - - -
-
{formatPercent(allocation.allocationPercent)}
+ {/* View Mode Toggle */} +
+ +
+ + {/* Content */} + {viewMode === 'collateral' ? ( + + ) : ( + + )}
); } diff --git a/app/autovault/[chainId]/[vaultAddress]/components/allocations/AllocationPieChart.tsx b/app/autovault/[chainId]/[vaultAddress]/components/allocations/AllocationPieChart.tsx new file mode 100644 index 00000000..a7d10db9 --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/allocations/AllocationPieChart.tsx @@ -0,0 +1,48 @@ +type AllocationPieChartProps = { + percentage: number; + size?: number; +}; + +export function AllocationPieChart({ percentage, size = 16 }: AllocationPieChartProps) { + const isEmpty = percentage === 0; + const radius = size / 2; + const strokeWidth = 2; + const normalizedRadius = radius - strokeWidth / 2; + const circumference = normalizedRadius * 2 * Math.PI; + const strokeDashoffset = circumference - (percentage / 100) * circumference; + + return ( + + {/* Background circle - always same opacity */} + + {/* Progress circle - only shown when not empty */} + {!isEmpty && ( + + )} + + ); +} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/allocations/CollateralView.tsx b/app/autovault/[chainId]/[vaultAddress]/components/allocations/CollateralView.tsx new file mode 100644 index 00000000..2fb6d1ae --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/allocations/CollateralView.tsx @@ -0,0 +1,73 @@ +import { Address } from 'viem'; +import { TokenIcon } from '@/components/TokenIcon'; +import { SupportedNetworks } from '@/utils/networks'; +import { formatAllocationAmount, calculateAllocationPercent } from '@/utils/vaultAllocation'; +import { AllocationPieChart } from './AllocationPieChart'; + +type CollateralItem = { + collateralAddress: Address; + collateralSymbol: string; + allocation: bigint; +}; + +type CollateralViewProps = { + items: CollateralItem[]; + totalAllocation: bigint; + vaultAssetSymbol: string; + vaultAssetDecimals: number; + chainId: SupportedNetworks; +}; + +export function CollateralView({ + items, + totalAllocation, + vaultAssetSymbol, + vaultAssetDecimals, + chainId, +}: CollateralViewProps) { + // Sort by allocation amount (most to least) + const sortedItems = [...items].sort((a, b) => { + if (a.allocation > b.allocation) return -1; + if (a.allocation < b.allocation) return 1; + return 0; + }); + + return ( +
+ {sortedItems.map((item) => { + const percentage = + totalAllocation > 0n ? parseFloat(calculateAllocationPercent(item.allocation, totalAllocation)) : 0; + + return ( +
+
+ +
+

{item.collateralSymbol}

+

Collateral

+
+
+
+
+ {item.allocation > 0n ? ( + <> +

+ {formatAllocationAmount(item.allocation, vaultAssetDecimals)} {vaultAssetSymbol} +

+

{percentage.toFixed(2)}% of total

+ + ) : ( +

No allocation

+ )} +
+ +
+
+ ); + })} +
+ ); +} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/allocations/MarketView.tsx b/app/autovault/[chainId]/[vaultAddress]/components/allocations/MarketView.tsx new file mode 100644 index 00000000..52690459 --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/allocations/MarketView.tsx @@ -0,0 +1,114 @@ +import { TokenIcon } from '@/components/TokenIcon'; +import { Market } from '@/utils/types'; +import { SupportedNetworks } from '@/utils/networks'; +import { formatAllocationAmount, calculateAllocationPercent } from '@/utils/vaultAllocation'; +import { getTruncatedAssetName } from '@/utils/oracle'; +import { AllocationPieChart } from './AllocationPieChart'; + +type MarketItem = { + market: Market; + allocation: bigint; +}; + +type MarketViewProps = { + items: MarketItem[]; + totalAllocation: bigint; + vaultAssetSymbol: string; + vaultAssetDecimals: number; + chainId: SupportedNetworks; +}; + +export function MarketView({ + items, + totalAllocation, + vaultAssetSymbol, + vaultAssetDecimals, + chainId, +}: MarketViewProps) { + // Sort by allocation amount (most to least) + const sortedItems = [...items].sort((a, b) => { + if (a.allocation > b.allocation) return -1; + if (a.allocation < b.allocation) return 1; + return 0; + }); + + return ( +
+ {sortedItems.map((item) => { + const { market, allocation } = item; + const percentage = + totalAllocation > 0n ? parseFloat(calculateAllocationPercent(allocation, totalAllocation)) : 0; + const supplyApy = (market.state.supplyApy * 100).toFixed(2); + const lltv = (Number(market.lltv) / 1e16).toFixed(0); + + return ( +
+ {/* Market Identity */} +
+
+
+ +
+
+ +
+
+
+ + {getTruncatedAssetName(market.loanAsset.symbol)} + + + / {getTruncatedAssetName(market.collateralAsset.symbol)} + +
+
+ + {/* Market Stats */} +
+
+ {supplyApy}% + APY +
+
+ {lltv}% + LLTV +
+
+ + {/* Allocation */} +
+
+ {allocation > 0n ? ( + <> +

+ {formatAllocationAmount(allocation, vaultAssetDecimals)} {vaultAssetSymbol} +

+

{percentage.toFixed(2)}% of total

+ + ) : ( +

No allocation

+ )} +
+ +
+
+ ); + })} +
+ ); +} diff --git a/app/autovault/[chainId]/[vaultAddress]/content.tsx b/app/autovault/[chainId]/[vaultAddress]/content.tsx index a0fa1079..c8b6d809 100644 --- a/app/autovault/[chainId]/[vaultAddress]/content.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/content.tsx @@ -9,13 +9,11 @@ import { useAccount } from 'wagmi'; import { Button } from '@/components/common'; import { AddressDisplay } from '@/components/common/AddressDisplay'; import Header from '@/components/layout/header/Header'; -import LoadingScreen from '@/components/Status/LoadingScreen'; -import { TokenIcon } from '@/components/TokenIcon'; // Removed useVaultDetails (was mock data) import { useVaultV2 } from '@/hooks/useVaultV2'; import { useVaultV2Data } from '@/hooks/useVaultV2Data'; +import { useAllocations } from '@/hooks/useAllocations'; import { getSlicedAddress } from '@/utils/address'; -import { formatBalance } from '@/utils/balance'; import { ALL_SUPPORTED_NETWORKS, SupportedNetworks, getNetworkConfig } from '@/utils/networks'; import { TotalSupplyCard } from './components/TotalSupplyCard'; import { VaultAllocatorCard } from './components/VaultAllocatorCard'; @@ -24,6 +22,7 @@ import { VaultInitializationModal } from './components/VaultInitializationModal' // Removed VaultMarketAllocations - will be re-added when real data is available import { VaultSettingsModal } from './components/VaultSettingsModal'; import { VaultSummaryMetrics } from './components/VaultSummaryMetrics'; +import { VaultMarketAllocations } from './components/VaultMarketAllocations'; export default function VaultContent() { const { chainId: chainIdParam, vaultAddress } = useParams<{ chainId: string; vaultAddress: string }>(); @@ -85,6 +84,7 @@ export default function VaultContent() { isUpdatingAllocator, updateCaps, isUpdatingCaps, + totalAssets } = useVaultV2({ vaultAddress: vaultAddressValue, chainId: supportedChainId, @@ -107,9 +107,21 @@ export default function VaultContent() { const capsUninitialized = vaultData?.capsData?.needSetupCaps ?? true; const capData = vaultData?.capsData; const collateralCaps = capData?.collateralCaps ?? []; + const marketCaps = capData?.marketCaps ?? []; - console.log('vaultData', vaultData) + // Memoize the caps array to prevent unnecessary refetches + const allCaps = useMemo(() => { + return [...collateralCaps, ...marketCaps]; + }, [collateralCaps, marketCaps]); + + // Fetch current allocations for all caps + const { allocations: allAllocations, loading: allocationsLoading } = useAllocations({ + vaultAddress: vaultAddressValue, + chainId: supportedChainId, + caps: allCaps, + enabled: !needsSetup && !!capData, + }); const assetAddress = vaultData?.assetAddress; @@ -240,6 +252,7 @@ export default function VaultContent() { - {/* TODO: Get real market allocations from subgraph */} - {/* */} + {/* Market Allocations - Show current allocation state */} + setShowSettings(false)} diff --git a/src/components/common/MarketCapInputCompact.tsx b/src/components/common/MarketCapInputCompact.tsx deleted file mode 100644 index 46656b7c..00000000 --- a/src/components/common/MarketCapInputCompact.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import React, { useState } from 'react'; -import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons'; -import { motion, AnimatePresence } from 'framer-motion'; -import { formatUnits } from 'viem'; -import { getTruncatedAssetName } from '@/utils/oracle'; -import { Market } from '@/utils/types'; -import OracleVendorBadge from '../OracleVendorBadge'; -import { TokenIcon } from '../TokenIcon'; - -type MarketCapInputCompactProps = { - market: Market; - relativeCap: string; - absoluteCap: string; - onRelativeCapChange: (value: string) => void; - onAbsoluteCapChange: (value: string) => void; - isSelected?: boolean; - onToggle?: () => void; - disabled?: boolean; -}; - -export function MarketCapInputCompact({ - market, - relativeCap, - absoluteCap, - onRelativeCapChange, - onAbsoluteCapChange, - isSelected = false, - onToggle, - disabled = false, -}: MarketCapInputCompactProps): JSX.Element { - const [relativeCapError, setRelativeCapError] = useState(''); - const [isExpanded, setIsExpanded] = useState(false); - - const handleRelativeCapChange = (e: React.ChangeEvent) => { - const value = e.target.value; - - // Allow empty or valid decimal numbers - if (value === '' || /^\d*\.?\d*$/.test(value)) { - onRelativeCapChange(value); - - // Validate percentage (0-100) - if (value !== '') { - const numValue = parseFloat(value); - if (numValue > 100) { - setRelativeCapError('Max 100%'); - } else if (numValue < 0) { - setRelativeCapError('Must be positive'); - } else { - setRelativeCapError(''); - } - } else { - setRelativeCapError(''); - } - } - }; - - const handleAbsoluteCapChange = (e: React.ChangeEvent) => { - const value = e.target.value; - - // Allow empty or valid numbers - if (value === '' || /^\d*\.?\d*$/.test(value)) { - onAbsoluteCapChange(value); - } - }; - - return ( -
- {/* Compact Header */} -
-
- -
-
- -
-
- -
-
-
- - {getTruncatedAssetName(market.loanAsset.symbol)} - - - / {getTruncatedAssetName(market.collateralAsset.symbol)} - -
- {!isExpanded && ( -
- · - - · - {market.state?.supplyApy ? (market.state.supplyApy * 100).toFixed(2) : '0.00'}% APY - · - {formatUnits(BigInt(market.lltv), 16)}% LTV -
- )} -
- - -
- - {/* Expanded Details with Cap Inputs */} - - {isExpanded && isSelected && ( - -
-
- - Relative Cap (% of vault) - -
- - % -
- {relativeCapError &&

{relativeCapError}

} -
- -
- - Absolute Cap ({market.loanAsset.symbol}) - - -
-
-
- )} -
-
- ); -} diff --git a/src/components/common/MarketCapTable.tsx b/src/components/common/MarketCapTable.tsx deleted file mode 100644 index 45f6bd5a..00000000 --- a/src/components/common/MarketCapTable.tsx +++ /dev/null @@ -1,274 +0,0 @@ -import React, { useMemo, useState } from 'react'; -import { formatUnits } from 'viem'; -import { formatBalance, formatReadable } from '@/utils/balance'; -import { getTruncatedAssetName } from '@/utils/oracle'; -import { Market } from '@/utils/types'; -import OracleVendorBadge from '../OracleVendorBadge'; -import { TokenIcon } from '../TokenIcon'; - -type MarketCapState = { - market: Market; - relativeCap: string; - absoluteCap: string; - isSelected: boolean; -}; - -type MarketCapTableProps = { - markets: MarketCapState[]; - onToggleMarket: (marketId: string) => void; - onRelativeCapChange: (marketId: string, value: string) => void; - disabled?: boolean; - collateralFilter: string[]; - onCollateralFilterChange: (collaterals: string[]) => void; -}; - -const ITEMS_PER_PAGE = 10; - -export function MarketCapTable({ - markets, - onToggleMarket, - onRelativeCapChange, - disabled = false, - collateralFilter, - onCollateralFilterChange, -}: MarketCapTableProps): JSX.Element { - const [currentPage, setCurrentPage] = useState(1); - const [searchQuery, setSearchQuery] = useState(''); - - // Get unique collaterals for filter - const availableCollaterals = useMemo(() => { - const collaterals = new Set(); - markets.forEach((m) => collaterals.add(m.market.collateralAsset.symbol)); - return Array.from(collaterals).sort(); - }, [markets]); - - // Filter markets - const filteredMarkets = useMemo(() => { - let filtered = markets; - - // Apply collateral filter - if (collateralFilter.length > 0) { - filtered = filtered.filter((m) => - collateralFilter.includes(m.market.collateralAsset.symbol), - ); - } - - // Apply search query - if (searchQuery.trim()) { - const query = searchQuery.toLowerCase(); - filtered = filtered.filter( - (m) => - m.market.collateralAsset.symbol.toLowerCase().includes(query) || - m.market.uniqueKey.toLowerCase().includes(query), - ); - } - - return filtered; - }, [markets, collateralFilter, searchQuery]); - - // Pagination - const totalPages = Math.ceil(filteredMarkets.length / ITEMS_PER_PAGE); - const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; - const paginatedMarkets = filteredMarkets.slice(startIndex, startIndex + ITEMS_PER_PAGE); - - // Reset to page 1 when filters change - React.useEffect(() => { - setCurrentPage(1); - }, [collateralFilter, searchQuery]); - - const handleCapChange = (marketId: string, value: string) => { - // Allow empty or valid decimal numbers - if (value === '' || /^\d*\.?\d*$/.test(value)) { - const numValue = parseFloat(value); - if (value === '' || (numValue >= 0 && numValue <= 100)) { - onRelativeCapChange(marketId, value); - } - } - }; - - return ( -
- {/* Filters */} -
- setSearchQuery(e.target.value)} - className="flex-1 rounded border border-gray-200 bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none dark:border-gray-700" - /> - -
- - {/* Table */} -
- - - - - - - - - - - - - - {paginatedMarkets.length === 0 ? ( - - - - ) : ( - paginatedMarkets.map((capState) => ( - - - - - - - - - - )) - )} - -
- - - Collateral - - Oracle - - LTV - - APY - - Liquidity - - Max % -
- No markets found -
- onToggleMarket(capState.market.uniqueKey)} - disabled={disabled} - className="h-4 w-4 cursor-pointer rounded border-gray-300 text-primary focus:ring-2 focus:ring-primary dark:border-gray-600" - /> - -
- - - {getTruncatedAssetName(capState.market.collateralAsset.symbol)} - -
-
- - - - {formatUnits(BigInt(capState.market.lltv), 16)}% - - - {capState.market.state?.supplyApy - ? `${(capState.market.state.supplyApy * 100).toFixed(2)}%` - : '—'} - - {formatReadable( - formatBalance( - capState.market.state.liquidityAssets, - capState.market.loanAsset.decimals, - ), - )} - - {capState.isSelected ? ( -
- - handleCapChange(capState.market.uniqueKey, e.target.value) - } - placeholder="100" - disabled={disabled} - className="w-16 rounded border border-gray-200 bg-background px-2 py-1 text-right text-sm focus:border-primary focus:outline-none dark:border-gray-700" - /> - % -
- ) : ( -
- )} -
-
- - {/* Pagination */} - {totalPages > 1 && ( -
-
- Showing {startIndex + 1}-{Math.min(startIndex + ITEMS_PER_PAGE, filteredMarkets.length)}{' '} - of {filteredMarkets.length} -
-
- - - Page {currentPage} of {totalPages} - - -
-
- )} -
- ); -} diff --git a/src/hooks/useAllocations.ts b/src/hooks/useAllocations.ts new file mode 100644 index 00000000..8b8b30dd --- /dev/null +++ b/src/hooks/useAllocations.ts @@ -0,0 +1,92 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Address } from 'viem'; +import { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; +import { SupportedNetworks } from '@/utils/networks'; +import { readAllocation } from '@/utils/vaultAllocation'; + +export type AllocationData = { + capId: string; + allocation: bigint; + cap: VaultV2Cap; +}; + +type UseAllocationsArgs = { + vaultAddress?: Address; + chainId: SupportedNetworks; + caps?: VaultV2Cap[]; + enabled?: boolean; +}; + +type UseAllocationsReturn = { + allocations: AllocationData[]; + loading: boolean; + error: Error | null; + refetch: () => Promise; +}; + +export function useAllocations({ + vaultAddress, + chainId, + caps = [], + enabled = true, +}: UseAllocationsArgs): UseAllocationsReturn { + const [allocations, setAllocations] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Create a stable key from capIds to detect actual changes + const capsKey = useMemo(() => { + return caps.map((c) => c.capId).sort().join(','); + }, [caps]); + + const load = useCallback(async () => { + if (!vaultAddress || !enabled || caps.length === 0) { + setAllocations([]); + return; + } + + setLoading(true); + setError(null); + + try { + // Read all allocations in parallel + const allocationPromises = caps.map(async (cap) => { + const allocation = await readAllocation( + vaultAddress, + cap.capId as `0x${string}`, + chainId, + ); + + return { + capId: cap.capId, + allocation, + cap, + }; + }); + + const results = await Promise.all(allocationPromises); + setAllocations(results); + } catch (err) { + const errorObj = err instanceof Error ? err : new Error('Failed to fetch allocations'); + setError(errorObj); + console.error('Error fetching allocations:', err); + } finally { + setLoading(false); + } + }, [vaultAddress, chainId, capsKey, enabled]); // Use capsKey instead of caps + + useEffect(() => { + void load(); + }, [load]); + + const refetch = useCallback(async () => { + await load(); + }, [load]); + + return { + allocations, + loading, + error, + refetch, + }; +} diff --git a/src/hooks/useVaultV2.ts b/src/hooks/useVaultV2.ts index 300a7afd..c70375cf 100644 --- a/src/hooks/useVaultV2.ts +++ b/src/hooks/useVaultV2.ts @@ -71,6 +71,18 @@ export function useVaultV2({ }, }); + + // Read totalAssets directly from the vault contract + const { data: totalAssets } = useReadContract({ + address: vaultAddress, + abi: vaultv2Abi, + functionName: 'totalAssets', + chainId, + query: { + enabled: Boolean(vaultAddress) + }, + }); + const currentCurator = useMemo(() => (curator as Address | undefined) ?? zeroAddress, [curator]); const handleInitializationSuccess = useCallback(() => { @@ -378,20 +390,13 @@ export function useVaultV2({ txs.push(submitIncreaseRelativeCapTx, increaseRelativeCapTx); } else if (newRelativeCap < oldRelativeCap) { - // Decrease + // Decrease, no need to use submit for timelock const decreaseRelativeCapTx = encodeFunctionData({ abi: vaultv2Abi, functionName: 'decreaseRelativeCap', args: [idData, newRelativeCap], }); - - const submitDecreaseRelativeCapTx = encodeFunctionData({ - abi: vaultv2Abi, - functionName: 'submit', - args: [decreaseRelativeCapTx], - }); - - txs.push(submitDecreaseRelativeCapTx, decreaseRelativeCapTx); + txs.push(decreaseRelativeCapTx); } } @@ -578,5 +583,6 @@ export function useVaultV2({ isDepositing, withdraw, isWithdrawing, + totalAssets, }; } diff --git a/src/utils/monarch-agent.ts b/src/utils/monarch-agent.ts index b9705f7a..46f2bd8a 100644 --- a/src/utils/monarch-agent.ts +++ b/src/utils/monarch-agent.ts @@ -15,7 +15,7 @@ export const getAgentContract = (chain: SupportedNetworks) => { }; export enum KnownAgents { - MAX_APY = '0xe0e04468A54937244BEc3bc6C1CA8Bc36ECE6704', + MAX_APY = '0x038cC0fFf3aBc20dcd644B1136F42A33df135c52', } diff --git a/src/utils/vaultAllocation.ts b/src/utils/vaultAllocation.ts new file mode 100644 index 00000000..354e53cf --- /dev/null +++ b/src/utils/vaultAllocation.ts @@ -0,0 +1,49 @@ +import { Address } from 'viem'; +import { vaultv2Abi } from '@/abis/vaultv2'; +import { SupportedNetworks } from '@/utils/networks'; +import { getClient } from '@/utils/rpc'; + +/** + * Read the current allocation amount for a specific cap ID from the vault contract + */ +export async function readAllocation( + vaultAddress: Address, + capId: `0x${string}`, + chainId: SupportedNetworks, +): Promise { + try { + const client = getClient(chainId); + const amount = await client.readContract({ + address: vaultAddress, + abi: vaultv2Abi, + functionName: 'allocation', + args: [capId], + }); + + return amount as bigint; + } catch (error) { + console.error(`Failed to read allocation for capId ${capId}:`, error); + return 0n; + } +} + +/** + * Format allocation amount with proper decimals and locale formatting + */ +export function formatAllocationAmount(amount: bigint, decimals: number): string { + if (amount === 0n) return '0'; + const value = Number(amount) / 10 ** decimals; + return value.toLocaleString(undefined, { + maximumFractionDigits: 2, + minimumFractionDigits: 0, + }); +} + +/** + * Calculate allocation percentage relative to total + */ +export function calculateAllocationPercent(amount: bigint, total: bigint): string { + if (total === 0n) return '0.00'; + const percent = (Number(amount) / Number(total)) * 100; + return percent.toFixed(2); +} From d69fe24e5a2469aafee948700742d3f95fff5057 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 21 Oct 2025 18:48:41 +0800 Subject: [PATCH 21/29] fix: refetch logics and cleanup adapter --- .../components/VaultInitializationModal.tsx | 52 ++++-------- .../components/VaultSettingsModal.tsx | 7 +- .../[chainId]/[vaultAddress]/content.tsx | 85 +++++++++++-------- src/data-sources/morpho-api/v2-vaults.ts | 4 +- src/data-sources/subgraph/v2-vaults.ts | 6 +- src/graphql/morpho-v2-subgraph-queries.ts | 2 +- src/hooks/useMorphoMarketV1Adapters.ts | 7 +- src/hooks/useVaultV2.ts | 57 +++---------- src/hooks/useVaultV2Data.ts | 4 +- 9 files changed, 97 insertions(+), 127 deletions(-) diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultInitializationModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultInitializationModal.tsx index 08a21018..89ca0be9 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultInitializationModal.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultInitializationModal.tsx @@ -197,10 +197,16 @@ export function VaultInitializationModal({ isOpen, onClose, vaultAddress, + marketAdapter, // address of MorphoMakretV1Aapater + marketAdapterLoading, // + refetchMarketAdapter, // refetch all "depolyed market adapter" chainId, onAdapterConfigured, }: { isOpen: boolean; + marketAdapter: Address; + marketAdapterLoading: boolean; + refetchMarketAdapter: () => void; onClose: () => void; vaultAddress: Address; chainId: SupportedNetworks; @@ -217,16 +223,8 @@ export function VaultInitializationModal({ const configured = getNetworkConfig(chainId).vaultConfig?.morphoRegistry; return (configured as Address | undefined) ?? ZERO_ADDRESS; }, [chainId]); + const { - adapters, - loading: adaptersLoading, - refetch: refetchAdapters, - } = useMorphoMarketV1Adapters({ vaultAddress, chainId }); - const subgraphAdapter = (adapters[0]?.adapter as Address | undefined) ?? ZERO_ADDRESS; - - const { - adapter: onChainAdapter, - refetch: refetchVault, completeInitialization, isInitializing, } = useVaultV2({ @@ -234,13 +232,8 @@ export function VaultInitializationModal({ chainId, }); - const unifiedAdapter = useMemo(() => { - if (subgraphAdapter !== ZERO_ADDRESS) return subgraphAdapter; - if (onChainAdapter && onChainAdapter !== ZERO_ADDRESS) return onChainAdapter; - return ZERO_ADDRESS; - }, [onChainAdapter, subgraphAdapter]); - const adapterDetected = unifiedAdapter !== ZERO_ADDRESS; + const adapterDetected = marketAdapter !== ZERO_ADDRESS; const { deploy, isDeploying, canDeploy } = useDeployMorphoMarketV1Adapter({ vaultAddress, @@ -248,32 +241,25 @@ export function VaultInitializationModal({ morphoAddress, }); - const handleDeploy = useCallback(async () => { setStatusVisible(true); await deploy(); - await refetchAdapters(); - }, [deploy, refetchAdapters]); + await refetchMarketAdapter(); + }, [deploy, refetchMarketAdapter]); - const handleAdapterDetected = useCallback(async () => { - await refetchVault(); - onAdapterConfigured(); - }, [onAdapterConfigured, refetchVault]); const handleCompleteInitialization = useCallback(async () => { - if (unifiedAdapter === ZERO_ADDRESS || registryAddress === ZERO_ADDRESS) return; + if (marketAdapter === ZERO_ADDRESS || registryAddress === ZERO_ADDRESS) return; try { const success = await completeInitialization( registryAddress, - unifiedAdapter, + marketAdapter, selectedAgent ?? undefined, ); if (!success) { return; } - - await refetchVault(); onAdapterConfigured(); onClose(); } catch (error) { @@ -283,10 +269,9 @@ export function VaultInitializationModal({ completeInitialization, onAdapterConfigured, onClose, - refetchVault, registryAddress, selectedAgent, - unifiedAdapter, + marketAdapter, ]); useEffect(() => { @@ -301,9 +286,8 @@ export function VaultInitializationModal({ useEffect(() => { if (adapterDetected && stepIndex === 0) { setStepIndex(1); - void handleAdapterDetected(); } - }, [adapterDetected, handleAdapterDetected, stepIndex]); + }, [adapterDetected, stepIndex]); const stepTitle = useMemo(() => { switch (currentStep) { @@ -321,7 +305,7 @@ export function VaultInitializationModal({ }, [currentStep]); const canProceedToAgents = adapterDetected && registryAddress !== ZERO_ADDRESS; - const showLoading = statusVisible && (isDeploying || adaptersLoading); + const showLoading = statusVisible && (isDeploying || marketAdapterLoading); const showBackButton = stepIndex > 0 && stepIndex < 3; const canProceedFromAdapterCap = adapterCapRelative !== '' && parseFloat(adapterCapRelative) > 0; @@ -436,19 +420,19 @@ export function VaultInitializationModal({ )} {currentStep === 'adapter-cap' && ( )} {currentStep === 'finalize' && ( diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx index 1872a28d..eda4ce4c 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx @@ -7,6 +7,7 @@ import { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { SupportedNetworks } from '@/utils/networks'; import { GeneralTab, AgentsTab, CapsTab, SettingsTab } from './settings'; import { CapData } from '@/hooks/useVaultV2Data'; +import { useMorphoMarketV1Adapters } from '@/hooks/useMorphoMarketV1Adapters'; const TABS: { id: SettingsTab; label: string }[] = [ { id: 'general', label: 'General' }, @@ -31,7 +32,7 @@ type VaultSettingsModalProps = { sentinels?: string[]; chainId: SupportedNetworks; vaultAsset?: Address; - adapterAddress?: Address; + marketAdapter: Address; // the deploy morpho market v1 adapter capData?: CapData; onSetAllocator: (allocator: Address, isAllocator: boolean) => Promise; updateCaps: (caps: VaultV2Cap[]) => Promise; @@ -58,7 +59,7 @@ export function VaultSettingsModal({ sentinels = [], chainId, vaultAsset, - adapterAddress, + marketAdapter, capData = undefined, onSetAllocator, updateCaps, @@ -152,7 +153,7 @@ export function VaultSettingsModal({ isOwner={isOwner} chainId={chainId} vaultAsset={vaultAsset} - adapterAddress={adapterAddress} + adapterAddress={marketAdapter} existingCaps={capData} updateCaps={updateCaps} isUpdatingCaps={isUpdatingCaps} diff --git a/app/autovault/[chainId]/[vaultAddress]/content.tsx b/app/autovault/[chainId]/[vaultAddress]/content.tsx index c8b6d809..cf7524b9 100644 --- a/app/autovault/[chainId]/[vaultAddress]/content.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/content.tsx @@ -4,8 +4,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { GearIcon } from '@radix-ui/react-icons'; import Link from 'next/link'; import { useParams } from 'next/navigation'; -import { Address } from 'viem'; -import { useAccount } from 'wagmi'; +import { Address, zeroAddress } from 'viem'; +import { useAccount, useCall } from 'wagmi'; import { Button } from '@/components/common'; import { AddressDisplay } from '@/components/common/AddressDisplay'; import Header from '@/components/layout/header/Header'; @@ -23,9 +23,10 @@ import { VaultInitializationModal } from './components/VaultInitializationModal' import { VaultSettingsModal } from './components/VaultSettingsModal'; import { VaultSummaryMetrics } from './components/VaultSummaryMetrics'; import { VaultMarketAllocations } from './components/VaultMarketAllocations'; +import { useMorphoMarketV1Adapters } from '@/hooks/useMorphoMarketV1Adapters'; export default function VaultContent() { - const { chainId: chainIdParam, vaultAddress } = useParams<{ chainId: string; vaultAddress: string }>(); + const { chainId: chainIdParam, vaultAddress } = useParams<{ chainId: string; vaultAddress: string }>(); const vaultAddressValue = vaultAddress as Address; const { address } = useAccount(); const [hasMounted, setHasMounted] = useState(false); @@ -35,7 +36,8 @@ export default function VaultContent() { }, []); const connectedAddress = hasMounted ? address : undefined; - const supportedChainId = useMemo(() => { + + const chainId = useMemo(() => { const parsed = Number(chainIdParam); if (Number.isFinite(parsed) && ALL_SUPPORTED_NETWORKS.includes(parsed as SupportedNetworks)) { return parsed as SupportedNetworks; @@ -45,37 +47,31 @@ export default function VaultContent() { const networkConfig = useMemo(() => { try { - return getNetworkConfig(supportedChainId); + return getNetworkConfig(chainId); } catch (error) { return null; } - }, [supportedChainId]); + }, [chainId]); const [settingsTab, setSettingsTab] = useState<'general' | 'agents' | 'caps'>('general'); const [showSettings, setShowSettings] = useState(false); const [showInitializationModal, setShowInitializationModal] = useState(false); const fallbackTitle = `Vault ${getSlicedAddress(vaultAddressValue)}`; + const { data: vaultData, loading: vaultDataLoading, error: vaultDataError, - refetch: refetchVaultData, + refetch: refetchVaultDataFromAPI, } = useVaultV2Data({ vaultAddress: vaultAddressValue, - chainId: supportedChainId, + chainId, }); - // Stabilize the callback to prevent infinite re-renders - const handleTransactionSuccess = useCallback(() => { - void refetchVaultData(); - }, [refetchVaultData]); - const { - adapter, - needsSetup, isLoading: adapterLoading, - refetch: refetchAdapter, + refetch: refetchVaultFromContract, updateNameAndSymbol, isUpdatingMetadata, name: onChainName, @@ -87,8 +83,8 @@ export default function VaultContent() { totalAssets } = useVaultV2({ vaultAddress: vaultAddressValue, - chainId: supportedChainId, - onTransactionSuccess: handleTransactionSuccess, + chainId, + onTransactionSuccess: refetchVaultDataFromAPI, }); // Use vaultData for owner check (from subgraph) @@ -96,6 +92,14 @@ export default function VaultContent() { vaultData?.owner && connectedAddress && vaultData.owner.toLowerCase() === connectedAddress.toLowerCase(), ); + const { + morphoMarketV1Adapter, + loading: adaptersLoading, + refetch: refetchMorphoAdapter, + } = useMorphoMarketV1Adapters({ vaultAddress: vaultAddressValue, chainId }); + + const needDeployMarketAdapater = useMemo(() => !adaptersLoading && morphoMarketV1Adapter === zeroAddress, [adapterLoading, morphoMarketV1Adapter]) ; + const isError = !!vaultDataError; const title = vaultData?.displayName ?? fallbackTitle; @@ -103,7 +107,7 @@ export default function VaultContent() { const allocators = vaultData?.allocators ?? []; const sentinels = vaultData?.sentinels ?? []; const allocatorCount = allocators.length; - const hasNoAllocators = !needsSetup && allocatorCount === 0; + const hasNoAllocators = !needDeployMarketAdapater && allocatorCount === 0; const capsUninitialized = vaultData?.capsData?.needSetupCaps ?? true; const capData = vaultData?.capsData; const collateralCaps = capData?.collateralCaps ?? []; @@ -118,13 +122,18 @@ export default function VaultContent() { // Fetch current allocations for all caps const { allocations: allAllocations, loading: allocationsLoading } = useAllocations({ vaultAddress: vaultAddressValue, - chainId: supportedChainId, + chainId, caps: allCaps, - enabled: !needsSetup && !!capData, + enabled: !needDeployMarketAdapater && !!capData, }); const assetAddress = vaultData?.assetAddress; + const refetchAll = useCallback(() => { + refetchVaultDataFromAPI() + refetchVaultFromContract() + }, []) + // TODO: Get real APY from subgraph or calculate from market allocations const apyLabel = '0%'; @@ -162,7 +171,7 @@ export default function VaultContent() {
@@ -183,7 +192,7 @@ export default function VaultContent() {
- {needsSetup && networkConfig?.vaultConfig?.marketV1AdapterFactory && ( + {needDeployMarketAdapater && networkConfig?.vaultConfig?.marketV1AdapterFactory && (

Complete vault initialization

@@ -254,10 +263,10 @@ export default function VaultContent() { tokenSymbol={vaultData?.tokenSymbol} totalAssets={totalAssets} assetAddress={assetAddress as Address | undefined} - chainId={supportedChainId} + chainId={chainId} vaultAddress={vaultAddressValue} vaultName={title} - onRefresh={() => void refetchVaultData()} + onRefresh={() => void refetchAll()} />
Current APY @@ -265,27 +274,27 @@ export default function VaultContent() {
{ - if (needsSetup && networkConfig?.vaultConfig?.marketV1AdapterFactory) { + if (needDeployMarketAdapater && networkConfig?.vaultConfig?.marketV1AdapterFactory) { setShowInitializationModal(true); return; } setSettingsTab('agents'); setShowSettings(true); }} - needsSetup={needsSetup} + needsSetup={needDeployMarketAdapater} isOwner={isOwner} isLoading={vaultDataLoading} /> { setSettingsTab('caps'); setShowSettings(true); }} - needsSetup={needsSetup} + needsSetup={needDeployMarketAdapater} isOwner={isOwner} isLoading={vaultDataLoading} /> @@ -299,7 +308,7 @@ export default function VaultContent() { totalAssets={totalAssets} vaultAssetSymbol={vaultData?.tokenSymbol ?? '--'} vaultAssetDecimals={vaultData?.tokenDecimals ?? 18} - chainId={supportedChainId} + chainId={chainId} isLoading={allocationsLoading || vaultDataLoading} /> void refetchVaultData()} + onRefresh={() => void refetchAll()} isRefreshing={vaultDataLoading} />
@@ -334,12 +343,14 @@ export default function VaultContent() { {networkConfig?.vaultConfig?.marketV1AdapterFactory && ( setShowInitializationModal(false)} vaultAddress={vaultAddressValue} - chainId={supportedChainId} + chainId={chainId} onAdapterConfigured={() => { - void refetchAdapter(); - void refetchVaultData(); + void refetchMorphoAdapter(); }} /> )} diff --git a/src/data-sources/morpho-api/v2-vaults.ts b/src/data-sources/morpho-api/v2-vaults.ts index a38eb123..87f67ec6 100644 --- a/src/data-sources/morpho-api/v2-vaults.ts +++ b/src/data-sources/morpho-api/v2-vaults.ts @@ -24,7 +24,7 @@ export type VaultV2Details = { sentinels: string[]; caps: VaultV2Cap[]; totalSupply: string; - adopters: string[]; + adapters: string[]; avgApy?: number; }; @@ -102,7 +102,7 @@ function transformVault(apiVault: ApiVaultV2): VaultV2Details { sentinels: [], // Not available in API response caps: apiVault.caps.items.map(transformCap), totalSupply: String(apiVault.totalSupply), - adopters: [], // Not available in API response + adapters: [], // Not available in API response avgApy: apiVault.avgApy, }; } diff --git a/src/data-sources/subgraph/v2-vaults.ts b/src/data-sources/subgraph/v2-vaults.ts index 1968a099..125c60d4 100644 --- a/src/data-sources/subgraph/v2-vaults.ts +++ b/src/data-sources/subgraph/v2-vaults.ts @@ -43,7 +43,7 @@ export type VaultV2Details = { sentinels: string[]; caps: VaultV2Cap[]; totalSupply: string; - adopters: string[]; + adapters: string[]; }; type SubgraphVaultV2Response = { @@ -59,7 +59,7 @@ type SubgraphVaultV2Response = { sentinels: { account: string }[]; caps: VaultV2Cap[]; totalSupply: string; - adopters: { address: string }[]; + adapters: { address: string }[]; } | null; }; errors?: any[]; @@ -181,7 +181,7 @@ export const fetchVaultV2Details = async ( sentinels: vault.sentinels.map((s) => s.account), caps: vault.caps, totalSupply: vault.totalSupply, - adopters: vault.adopters.map((a) => a.address), + adapters: vault.adapters.map((a) => a.address), }; } catch (error) { console.error( diff --git a/src/graphql/morpho-v2-subgraph-queries.ts b/src/graphql/morpho-v2-subgraph-queries.ts index 53c94bbd..5e388b23 100644 --- a/src/graphql/morpho-v2-subgraph-queries.ts +++ b/src/graphql/morpho-v2-subgraph-queries.ts @@ -34,7 +34,7 @@ export const vaultV2Query = ` marketId } totalSupply - adopters(where: {isAdopter: true}) { + adapters(where: {isAdapter: true}) { address } } diff --git a/src/hooks/useMorphoMarketV1Adapters.ts b/src/hooks/useMorphoMarketV1Adapters.ts index 2f99b2d9..44a6d953 100644 --- a/src/hooks/useMorphoMarketV1Adapters.ts +++ b/src/hooks/useMorphoMarketV1Adapters.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Address } from 'viem'; +import { Address, zeroAddress } from 'viem'; import { fetchMorphoMarketV1Adapters, MorphoMarketV1AdapterRecord } from '@/data-sources/subgraph/morpho-market-v1-adapters'; import { getMorphoAddress } from '@/utils/morpho'; import { getNetworkConfig, SupportedNetworks } from '@/utils/networks'; @@ -55,8 +55,11 @@ export function useMorphoMarketV1Adapters({ void fetchAdapters(); }, [fetchAdapters]); + const morphoMarketV1Adapter = useMemo(() => adapters.length == 0? zeroAddress : adapters[0].adapter, [adapters]) + return { - adapters, + morphoMarketV1Adapter, + adapters, // all market adapters (should only be just one) loading, error, refetch: fetchAdapters, diff --git a/src/hooks/useVaultV2.ts b/src/hooks/useVaultV2.ts index c70375cf..600963bc 100644 --- a/src/hooks/useVaultV2.ts +++ b/src/hooks/useVaultV2.ts @@ -6,8 +6,6 @@ import { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { SupportedNetworks } from '@/utils/networks'; import { useTransactionWithToast } from './useTransactionWithToast'; -const ADAPTER_INDEX = 0n; - export function useVaultV2({ vaultAddress, chainId, @@ -21,23 +19,6 @@ export function useVaultV2({ const chainIdToUse = (chainId ?? connectedChainId) as SupportedNetworks; const { address: account } = useAccount(); - const { - data, - isLoading, - isFetching, - refetch, - error, - } = useReadContract({ - address: vaultAddress, - abi: vaultv2Abi, - functionName: 'adapters', - args: [ADAPTER_INDEX], - chainId: chainIdToUse, - query: { - enabled: Boolean(vaultAddress), - }, - }); - const { data: curator } = useReadContract({ address: vaultAddress, abi: vaultv2Abi, @@ -73,7 +54,7 @@ export function useVaultV2({ // Read totalAssets directly from the vault contract - const { data: totalAssets } = useReadContract({ + const { data: totalAssets, refetch: refetchBalance, isLoading: loadingBalance } = useReadContract({ address: vaultAddress, abi: vaultv2Abi, functionName: 'totalAssets', @@ -85,10 +66,14 @@ export function useVaultV2({ const currentCurator = useMemo(() => (curator as Address | undefined) ?? zeroAddress, [curator]); + const refetchAll = useCallback(() => { + refetchBalance() + }, [refetchBalance]) + const handleInitializationSuccess = useCallback(() => { - void refetch(); + void refetchAll(); onTransactionSuccess?.(); - }, [refetch, onTransactionSuccess]); + }, [refetchAll, onTransactionSuccess]); const { isConfirming: isInitializing, sendTransactionAsync: sendInitializationTx } = useTransactionWithToast({ toastId: 'completeInitialization', @@ -111,11 +96,6 @@ export function useVaultV2({ chainId: chainIdToUse, }); - const handleAllocatorOrCapSuccess = useCallback(() => { - void refetch(); - onTransactionSuccess?.(); - }, [refetch, onTransactionSuccess]); - const { isConfirming: isUpdatingAllocator, sendTransactionAsync: sendAllocatorTx } = useTransactionWithToast({ toastId: 'update-allocator', pendingText: 'Updating allocator', @@ -124,7 +104,7 @@ export function useVaultV2({ pendingDescription: 'Updating allocator status', successDescription: 'Allocator status changed', chainId: chainIdToUse, - onSuccess: handleAllocatorOrCapSuccess, + onSuccess: onTransactionSuccess, }); const { isConfirming: isUpdatingCaps, sendTransactionAsync: sendCapsTx } = useTransactionWithToast({ @@ -135,7 +115,7 @@ export function useVaultV2({ pendingDescription: 'Applying new market caps', successDescription: 'Caps updated successfully', chainId: chainIdToUse, - onSuccess: handleAllocatorOrCapSuccess, + onSuccess: onTransactionSuccess, }); @@ -474,7 +454,7 @@ export function useVaultV2({ pendingDescription: 'Depositing assets to vault', successDescription: 'Assets deposited successfully', chainId: chainIdToUse, - onSuccess: handleAllocatorOrCapSuccess, + onSuccess: onTransactionSuccess, }); const { isConfirming: isWithdrawing, sendTransactionAsync: sendWithdrawTx } = useTransactionWithToast({ @@ -485,7 +465,7 @@ export function useVaultV2({ pendingDescription: 'Withdrawing assets from vault', successDescription: 'Assets withdrawn successfully', chainId: chainIdToUse, - onSuccess: handleAllocatorOrCapSuccess, + onSuccess: onTransactionSuccess, }); const deposit = useCallback( @@ -546,11 +526,6 @@ export function useVaultV2({ [account, chainIdToUse, sendWithdrawTx, vaultAddress], ); - const adapter = useMemo(() => { - if (!data) return zeroAddress; - return data as Address; - }, [data]); - const name = useMemo(() => { if (!rawName) return ''; return String(rawName); @@ -561,14 +536,10 @@ export function useVaultV2({ return String(rawSymbol); }, [rawSymbol]); - const needsSetup = adapter === zeroAddress; - + return { - adapter, - needsSetup, - isLoading: isLoading || isFetching, - refetch, - error: error as Error | null, + isLoading: loadingBalance, + refetch: refetchAll, completeInitialization, isInitializing, name, diff --git a/src/hooks/useVaultV2Data.ts b/src/hooks/useVaultV2Data.ts index 870f90f8..04813d1e 100644 --- a/src/hooks/useVaultV2Data.ts +++ b/src/hooks/useVaultV2Data.ts @@ -32,7 +32,7 @@ export type VaultV2Data = { owner: string; curator: string; capsData: CapData - adopters: string[]; + adapters: string[]; curatorDisplay: string; }; @@ -112,7 +112,7 @@ export function useVaultV2Data({ marketCaps, needSetupCaps }, - adopters: result.adopters, + adapters: result.adapters, curatorDisplay, }); } catch (err) { From 5b389bb529ca4fd4213c0749be096191c77cde39 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 21 Oct 2025 21:21:55 +0800 Subject: [PATCH 22/29] refactor: data hooks --- .../components/VaultAllocatorCard.tsx | 46 ++-- .../components/VaultCollateralsCard.tsx | 4 +- .../components/settings/AgentListItem.tsx | 33 +++ .../components/settings/AgentsTab.tsx | 94 +++----- .../[chainId]/[vaultAddress]/content.tsx | 213 ++++++------------ src/hooks/useVaultPage.ts | 142 ++++++++++++ src/utils/monarch-agent.ts | 1 + src/utils/types.ts | 1 + 8 files changed, 314 insertions(+), 220 deletions(-) create mode 100644 app/autovault/[chainId]/[vaultAddress]/components/settings/AgentListItem.tsx create mode 100644 src/hooks/useVaultPage.ts diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultAllocatorCard.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultAllocatorCard.tsx index aa7680a3..acbfe8e6 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultAllocatorCard.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultAllocatorCard.tsx @@ -1,11 +1,13 @@ import { Card, CardBody, CardHeader, Tooltip } from '@heroui/react'; import { GearIcon } from '@radix-ui/react-icons'; import { GrStatusGood } from 'react-icons/gr'; +import { HiQuestionMarkCircle } from 'react-icons/hi'; import { Address } from 'viem'; import { Spinner } from '@/components/common/Spinner'; -import { AddressDisplay } from '@/components/common/AddressDisplay'; import { TooltipContent } from '@/components/TooltipContent'; import { SupportedNetworks } from '@/utils/networks'; +import { findAgent } from '@/utils/monarch-agent'; +import Image from 'next/image'; type VaultAllocatorCardProps = { allocators: string[]; @@ -45,26 +47,30 @@ export function VaultAllocatorCard({
) : hasAllocators ? ( -
- {allocators.map((allocatorAddress) => ( -
-
-
- - +
+ {allocators.map((allocatorAddress) => { + const agent = findAgent(allocatorAddress); + return ( + +
+ {agent ? ( + {agent.name} + ) : ( + + )}
-

Authorized for rebalancing

-
-
- ))} + + ); + })}
) : (
diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultCollateralsCard.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultCollateralsCard.tsx index de3e08a8..08cda6bf 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultCollateralsCard.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultCollateralsCard.tsx @@ -53,9 +53,9 @@ export function VaultCollateralsCard({
) : hasCollaterals ? ( -
+
{collateralTokens.map((tokenAddress) => ( -
+
+
+ {agent ? ( + {agent.name} + ) : ( + + )} +
+ {agent && {agent.name}} + +
+ ); +} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentsTab.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentsTab.tsx index 7c65c0df..ceea2b04 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentsTab.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentsTab.tsx @@ -6,6 +6,7 @@ import { Spinner } from '@/components/common/Spinner'; import { useMarketNetwork } from '@/hooks/useMarketNetwork'; import { v2AgentsBase } from '@/utils/monarch-agent'; import { AgentsTabProps } from './types'; +import { AgentListItem } from './AgentListItem'; export function AgentsTab({ isOwner, @@ -161,24 +162,14 @@ export function AgentsTab({

No allocators assigned

) : (
- {allocators.map((address) => { - const agent = v2AgentsBase.find((a) => a.address.toLowerCase() === address.toLowerCase()); - return ( -
- {agent ? ( -
- {agent.name} - -
- ) : ( - - )} -
- ); - })} + {allocators.map((address) => ( +
+ +
+ ))}
) ) : ( @@ -187,44 +178,32 @@ export function AgentsTab({ {allocators.length > 0 && (

Current Allocators

- {allocators.map((address) => { - const agent = v2AgentsBase.find((a) => a.address.toLowerCase() === address.toLowerCase()); - return ( -
( +
+ + -
- ); - })} + {isUpdatingAllocator && allocatorToRemove === (address as Address) ? ( + + Removing... + + ) : needSwitchChain ? ( + 'Switch Network' + ) : ( + 'Remove' + )} + +
+ ))}
)} @@ -239,9 +218,8 @@ export function AgentsTab({ className="flex items-center justify-between rounded border border-gray-100 bg-gray-50/50 p-3 dark:border-gray-700 dark:bg-gray-900/50" >
- {agent.name} - -

{agent.strategyDescription}

+ +

{agent.strategyDescription}

- {needDeployMarketAdapater && networkConfig?.vaultConfig?.marketV1AdapterFactory && ( + {/* Setup Banners */} + {vault.needsAdapterDeployment && networkConfig?.vaultConfig?.marketV1AdapterFactory && (

Complete vault initialization

- Deploy adapter, configure registry, and optionally choose an agent to automate - this vault. + Deploy adapter, configure registry, and optionally choose an agent to automate this vault.

)} - {hasNoAllocators && isOwner && ( + {vault.hasNoAllocators && vault.isOwner && (

Choose an agent

-

- Add an agent to enable automated allocation and rebalancing. -

+

Add an agent to enable automated allocation and rebalancing.

diff --git a/src/hooks/useVaultPage.ts b/src/hooks/useVaultPage.ts new file mode 100644 index 00000000..6c882cd4 --- /dev/null +++ b/src/hooks/useVaultPage.ts @@ -0,0 +1,142 @@ +import { useCallback, useMemo } from 'react'; +import { Address, zeroAddress } from 'viem'; +import { useAccount } from 'wagmi'; +import { SupportedNetworks } from '@/utils/networks'; +import { useVaultV2Data } from './useVaultV2Data'; +import { useVaultV2 } from './useVaultV2'; +import { useMorphoMarketV1Adapters } from './useMorphoMarketV1Adapters'; +import { useAllocations } from './useAllocations'; + +type UseVaultPageArgs = { + vaultAddress: Address; + chainId: SupportedNetworks; + connectedAddress?: Address; +}; + +/** + * Unified hook for vault page data and actions. + * Combines all vault-related data fetching and provides computed state. + */ +export function useVaultPage({ vaultAddress, chainId, connectedAddress }: UseVaultPageArgs) { + // Fetch vault data from API/subgraph + const { + data: vaultData, + loading: vaultDataLoading, + error: vaultDataError, + refetch: refetchVaultData, + } = useVaultV2Data({ + vaultAddress, + chainId, + }); + + // Fetch vault contract state and actions + const { + isLoading: contractLoading, + refetch: refetchContract, + updateNameAndSymbol, + isUpdatingMetadata, + name: onChainName, + symbol: onChainSymbol, + setAllocator, + isUpdatingAllocator, + updateCaps, + isUpdatingCaps, + totalAssets, + } = useVaultV2({ + vaultAddress, + chainId, + onTransactionSuccess: refetchVaultData, + }); + + // Fetch market adapter + const { + morphoMarketV1Adapter, + loading: adapterLoading, + refetch: refetchAdapter, + } = useMorphoMarketV1Adapters({ vaultAddress, chainId }); + + // Compute derived state + const needsAdapterDeployment = useMemo( + () => !adapterLoading && morphoMarketV1Adapter === zeroAddress, + [adapterLoading, morphoMarketV1Adapter], + ); + + const isOwner = useMemo( + () => + Boolean( + vaultData?.owner && connectedAddress && vaultData.owner.toLowerCase() === connectedAddress.toLowerCase(), + ), + [vaultData?.owner, connectedAddress], + ); + + const hasNoAllocators = useMemo( + () => !needsAdapterDeployment && (vaultData?.allocators ?? []).length === 0, + [needsAdapterDeployment, vaultData?.allocators], + ); + + const capsUninitialized = useMemo( + () => vaultData?.capsData?.needSetupCaps ?? true, + [vaultData?.capsData?.needSetupCaps], + ); + + // Memoize caps array to prevent unnecessary refetches + const allCaps = useMemo(() => { + const collateralCaps = vaultData?.capsData?.collateralCaps ?? []; + const marketCaps = vaultData?.capsData?.marketCaps ?? []; + return [...collateralCaps, ...marketCaps]; + }, [vaultData?.capsData?.collateralCaps, vaultData?.capsData?.marketCaps]); + + // Fetch current allocations + const { allocations, loading: allocationsLoading } = useAllocations({ + vaultAddress, + chainId, + caps: allCaps, + enabled: !needsAdapterDeployment && !!vaultData?.capsData, + }); + + // Unified refetch function + const refetchAll = useCallback(() => { + void refetchVaultData(); + void refetchContract(); + void refetchAdapter(); + }, [refetchVaultData, refetchContract, refetchAdapter]); + + // Loading states + const isLoading = vaultDataLoading || contractLoading || adapterLoading; + const hasError = !!vaultDataError; + + return { + // Data + vaultData, + totalAssets, + allocations, + adapter: morphoMarketV1Adapter, + onChainName, + onChainSymbol, + + // Computed state + isOwner, + needsAdapterDeployment, + hasNoAllocators, + capsUninitialized, + + // Loading/Error states + isLoading, + vaultDataLoading, + allocationsLoading, + adapterLoading, + hasError, + + // Actions + updateNameAndSymbol, + setAllocator, + updateCaps, + refetchAll, + refetchAdapter, + + // Action loading states + isUpdatingMetadata, + isUpdatingAllocator, + isUpdatingCaps, + }; +} diff --git a/src/utils/monarch-agent.ts b/src/utils/monarch-agent.ts index 46f2bd8a..0de6aa31 100644 --- a/src/utils/monarch-agent.ts +++ b/src/utils/monarch-agent.ts @@ -25,6 +25,7 @@ export const v2AgentsBase: AgentMetadata[] = [ name: 'Max APY Agent', address: KnownAgents.MAX_APY, strategyDescription: 'Rebalance every 8 hours, always move to the highest APY', + image: '/placeholder-agent.png', }, ]; diff --git a/src/utils/types.ts b/src/utils/types.ts index fc53d334..bdcce659 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -369,6 +369,7 @@ export type AgentMetadata = { address: Address; name: string; strategyDescription: string; + image: string; }; // Define the comprehensive Market Activity Transaction type From 2360f2289688eba8db6051adfd1716910c372cd8 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 22 Oct 2025 15:12:53 +0800 Subject: [PATCH 23/29] feat: unified market id badge --- .../components/VaultAllocatorCard.tsx | 37 +- .../components/VaultMarketAllocations.tsx | 93 ++--- .../components/allocations/CollateralView.tsx | 79 +++-- .../components/allocations/MarketView.tsx | 154 +++++---- .../components/settings/AgentListItem.tsx | 17 +- .../components/settings/CurrentCaps.tsx | 6 +- .../components/settings/MarketCapsTable.tsx | 12 +- app/markets/components/MarketTableBody.tsx | 2 +- docs/Styling.md | 141 +++++++- src/components/AgentIcon.tsx | 81 +++++ src/components/MarketIdBadge.tsx | 16 + src/components/MarketIdentity.tsx | 327 ++++++++++++++++++ src/components/TokenIcon.tsx | 49 ++- src/components/TooltipContent.tsx | 46 ++- .../common/MarketsTableWithSameLoanAsset.tsx | 140 ++------ src/hooks/useVaultPage.ts | 1 - 16 files changed, 881 insertions(+), 320 deletions(-) create mode 100644 src/components/AgentIcon.tsx create mode 100644 src/components/MarketIdBadge.tsx create mode 100644 src/components/MarketIdentity.tsx diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultAllocatorCard.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultAllocatorCard.tsx index acbfe8e6..479c7517 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultAllocatorCard.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultAllocatorCard.tsx @@ -1,13 +1,12 @@ import { Card, CardBody, CardHeader, Tooltip } from '@heroui/react'; import { GearIcon } from '@radix-ui/react-icons'; import { GrStatusGood } from 'react-icons/gr'; -import { HiQuestionMarkCircle } from 'react-icons/hi'; import { Address } from 'viem'; import { Spinner } from '@/components/common/Spinner'; import { TooltipContent } from '@/components/TooltipContent'; import { SupportedNetworks } from '@/utils/networks'; import { findAgent } from '@/utils/monarch-agent'; -import Image from 'next/image'; +import { AgentIcon } from '@/components/AgentIcon'; type VaultAllocatorCardProps = { allocators: string[]; @@ -48,29 +47,17 @@ export function VaultAllocatorCard({
) : hasAllocators ? (
- {allocators.map((allocatorAddress) => { - const agent = findAgent(allocatorAddress); - return ( - -
- {agent ? ( - {agent.name} - ) : ( - - )} -
-
- ); - })} + {allocators + .map((allocatorAddress) => findAgent(allocatorAddress)) + .filter((agent): agent is NonNullable => agent !== undefined) + .map((agent) => ( + + ))}
) : (
diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx index be59c8e7..71bf2b40 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx @@ -1,5 +1,8 @@ import { useMemo, useState } from 'react'; import { Address } from 'viem'; +import { Switch } from '@heroui/react'; +import { MdOutlineAccountBalance } from 'react-icons/md'; +import { HiOutlineCube } from 'react-icons/hi'; import { Spinner } from '@/components/common/Spinner'; import { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { AllocationData } from '@/hooks/useAllocations'; @@ -24,6 +27,14 @@ type VaultMarketAllocationsProps = { type ViewMode = 'collateral' | 'market'; +function ViewIcon({ isSelected, className }: { isSelected: boolean; className?: string }) { + return isSelected ? ( + + ) : ( + + ); +} + export function VaultMarketAllocations({ totalAssets, marketCaps, @@ -87,10 +98,21 @@ export function VaultMarketAllocations({ const totalAllocation = useMemo(() => { return totalAssets ?? allocations.reduce((sum, allocation) => sum + allocation.allocation, 0n) - }, [totalAssets]) + }, [totalAssets]) const hasAnyAllocations = useMemo(() => totalAllocation > 0n, [totalAllocation]) + const viewDescription = useMemo(() => { + if (viewMode === 'collateral') { + return hasAnyAllocations + ? `Your ${vaultAssetSymbol} deposits are distributed across lending markets, each accepting different collateral types. This view shows how your supply is backed by each collateral asset.` + : `This view will show how your ${vaultAssetSymbol} deposits are backed by different collateral types once assets are allocated.`; + } + return hasAnyAllocations + ? `Your ${vaultAssetSymbol} deposits are actively earning yield across multiple lending markets. Each market has unique terms including APY, collateral requirements, and risk parameters.` + : `This view will show how your ${vaultAssetSymbol} deposits are distributed across different lending markets once assets are allocated.`; + }, [viewMode, hasAnyAllocations, vaultAssetSymbol]); + if (isLoading) { return (
@@ -111,54 +133,33 @@ export function VaultMarketAllocations({
{/* Header */}
-
-

- {hasAnyAllocations ? 'Active Allocations' : 'Market Configuration'} -

-

- {hasAnyAllocations - ? 'Current asset distribution across markets' - : 'Markets are configured but no assets have been allocated yet'} -

-
-
-
- Asset: {vaultAssetSymbol} -
- {hasAnyAllocations && ( -
- Total: {formatBalance(totalAssets ?? 0n, vaultAssetDecimals)} {vaultAssetSymbol} +
+
+

+ {hasAnyAllocations ? 'Active Allocations' : 'Market Configuration'} +

+
+ + {viewMode === 'collateral' ? 'By Collateral' : 'By Market'} + + setViewMode(viewMode === 'collateral' ? 'market' : 'collateral')} + thumbIcon={ViewIcon} + />
- )} +
+

+ {viewDescription} +

- - {/* View Mode Toggle */} -
- - -
- {/* Content */} {viewMode === 'collateral' ? ( - {sortedItems.map((item) => { - const percentage = - totalAllocation > 0n ? parseFloat(calculateAllocationPercent(item.allocation, totalAllocation)) : 0; +
+ + + + + + + + + + + {sortedItems.map((item) => { + const percentage = + totalAllocation > 0n ? parseFloat(calculateAllocationPercent(item.allocation, totalAllocation)) : 0; + const hasAllocation = item.allocation > 0n; - return ( -
-
- -
-

{item.collateralSymbol}

-

Collateral

-
-
-
-
- {item.allocation > 0n ? ( - <> -

- {formatAllocationAmount(item.allocation, vaultAssetDecimals)} {vaultAssetSymbol} -

-

{percentage.toFixed(2)}% of total

- - ) : ( -

No allocation

- )} -
- -
-
- ); - })} + return ( + + + + + + + ); + })} + +
CollateralAmountAllocation
+
+ + {item.collateralSymbol} +
+
+ + {hasAllocation + ? `${formatAllocationAmount(item.allocation, vaultAssetDecimals)} ${vaultAssetSymbol}` + : '-'} + + + + {hasAllocation ? `${percentage.toFixed(2)}%` : '—'} + + +
+ +
+
); } diff --git a/app/autovault/[chainId]/[vaultAddress]/components/allocations/MarketView.tsx b/app/autovault/[chainId]/[vaultAddress]/components/allocations/MarketView.tsx index 52690459..44ce0c87 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/allocations/MarketView.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/allocations/MarketView.tsx @@ -1,8 +1,8 @@ -import { TokenIcon } from '@/components/TokenIcon'; +import { MarketIdentity, MarketIdentityFocus } from '@/components/MarketIdentity'; import { Market } from '@/utils/types'; import { SupportedNetworks } from '@/utils/networks'; import { formatAllocationAmount, calculateAllocationPercent } from '@/utils/vaultAllocation'; -import { getTruncatedAssetName } from '@/utils/oracle'; +import { formatBalance, formatReadable } from '@/utils/balance'; import { AllocationPieChart } from './AllocationPieChart'; type MarketItem = { @@ -33,82 +33,90 @@ export function MarketView({ }); return ( -
- {sortedItems.map((item) => { - const { market, allocation } = item; - const percentage = - totalAllocation > 0n ? parseFloat(calculateAllocationPercent(allocation, totalAllocation)) : 0; - const supplyApy = (market.state.supplyApy * 100).toFixed(2); - const lltv = (Number(market.lltv) / 1e16).toFixed(0); +
+ + + + + + + + + + + + + + {sortedItems.map((item) => { + const { market, allocation } = item; + const percentage = + totalAllocation > 0n ? parseFloat(calculateAllocationPercent(allocation, totalAllocation)) : 0; + const supplyApy = (market.state.supplyApy * 100).toFixed(2); + const hasAllocation = allocation > 0n; + const totalSupply = formatReadable( + formatBalance(BigInt(market.state.supplyAssets || 0), market.loanAsset.decimals).toString() + ); + const liquidity = formatReadable( + formatBalance(BigInt(market.state.liquidityAssets || 0), market.loanAsset.decimals).toString() + ); - return ( -
- {/* Market Identity */} -
-
-
- + {/* Market Info Column */} +
+ + {/* APY */} + + + {/* Total Supply */} + + + {/* Liquidity */} + + + {/* Allocation Amount */} + - {/* Market Stats */} -
-
- {supplyApy}% - APY -
-
- {lltv}% - LLTV -
-
+ {/* Allocation Percentage */} + - {/* Allocation */} -
-
- {allocation > 0n ? ( - <> -

- {formatAllocationAmount(allocation, vaultAssetDecimals)} {vaultAssetSymbol} -

-

{percentage.toFixed(2)}% of total

- - ) : ( -

No allocation

- )} -
- -
- - ); - })} + {/* Pie Chart */} + + + ); + })} + +
MarketAPYTotal SupplyLiquidityAmountAllocation
+ - -
- -
- -
- - {getTruncatedAssetName(market.loanAsset.symbol)} - - - / {getTruncatedAssetName(market.collateralAsset.symbol)} - -
- +
+ {supplyApy}% + + {totalSupply} + + {liquidity} + + + {hasAllocation + ? `${formatAllocationAmount(allocation, vaultAssetDecimals)} ${vaultAssetSymbol}` + : '-'} + + + + {hasAllocation ? `${percentage.toFixed(2)}%` : '—'} + + +
+ +
+
); } diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentListItem.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentListItem.tsx index 4e819fb9..f92b16d8 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentListItem.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentListItem.tsx @@ -1,8 +1,7 @@ -import { HiQuestionMarkCircle } from 'react-icons/hi'; import { Address } from 'viem'; import { AddressDisplay } from '@/components/common/AddressDisplay'; import { findAgent } from '@/utils/monarch-agent'; -import Image from 'next/image'; +import { AgentIcon } from '@/components/AgentIcon'; type AgentListItemProps = { address: Address; @@ -13,19 +12,7 @@ export function AgentListItem({ address }: AgentListItemProps) { return (
-
- {agent ? ( - {agent.name} - ) : ( - - )} -
+ {agent && {agent.name}}
diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentCaps.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentCaps.tsx index 5182bae4..f0a20fd1 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentCaps.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentCaps.tsx @@ -165,14 +165,14 @@ export function CurrentCaps({
{/* Collateral Caps */} {collateralCapsWithMarkets.length > 0 && ( -
+

Collateral Caps ({collateralCapsWithMarkets.length})

{/* Column Headers */} -
+
Collateral
Relative %
Absolute {vaultAssetToken?.symbol ? `(${vaultAssetToken.symbol})` : ''}
@@ -227,7 +227,7 @@ export function CurrentCaps({ {/* Market Caps - Expandable */} {isExpanded && hasMarkets && ( -
+
Market Caps
(
- + {row.isNew && ( New )} diff --git a/app/markets/components/MarketTableBody.tsx b/app/markets/components/MarketTableBody.tsx index f2e98564..7d749dc5 100644 --- a/app/markets/components/MarketTableBody.tsx +++ b/app/markets/components/MarketTableBody.tsx @@ -167,7 +167,7 @@ export function MarketTableBody({ content={ } - detail="This market is whitelisted by Monarch" + detail="This market is recognized by Monarch" /> } > diff --git a/docs/Styling.md b/docs/Styling.md index 7deb75b3..fb63a16b 100644 --- a/docs/Styling.md +++ b/docs/Styling.md @@ -85,7 +85,7 @@ import { Button } from '@/components/common/Button'; // Utility Action - @@ -98,7 +98,25 @@ import { Button } from '@/components/common/Button'; ## Tooltip -Use the nextui tooltip with component for consistent styling. Always use the classNames configuration to remove HeroUI's default wrapper styling: +Use the `TooltipContent` component for consistent tooltip styling. The component supports two modes: + +### Simple Tooltip (no detail) +Shows icon, title, and optional action link on the right: + +```tsx +} title="Tooltip Title" />} +> + {/* Your trigger element */} + +``` + +### Complex Tooltip (with detail) +Shows icon, title, detail text, and optional secondary detail text: ```tsx component for consistent styling. A base: 'p-0 m-0 bg-transparent shadow-sm border-none', content: 'p-0 m-0 bg-transparent shadow-sm border-none', }} - content={} title="Tooltip Title" detail="Tooltip Detail" />} + content={ + } + title="Tooltip Title" + detail="Main description (text-primary, text-sm)" + secondaryDetail="Additional info (text-secondary, text-xs)" + /> + } > {/* Your trigger element */} ``` -**Important:** The `classNames` configuration removes HeroUI's default padding, background, and borders to prevent double-wrapper styling issues. This ensures only your `TooltipContent` component handles the visual styling. +### Tooltip with Action Link +Add an action link (like explorer) in the top-right corner: + +```tsx +} + actionHref="https://explorer.com/address/0x123" + onActionClick={(e) => e.stopPropagation()} +/> +``` + +**Important:** +- Always use the `classNames` configuration shown above to remove HeroUI's default styling +- `detail`: Main description text (text-primary, text-sm) +- `secondaryDetail`: Additional info below detail (text-secondary, text-xs) ## Shared UI Elements - Render token avatars with `TokenIcon` (`@/components/TokenIcon`) so chain-specific fallbacks, glyph sizing, and tooltips stay consistent. - Display oracle provenance data with `OracleVendorBadge` (`@/components/OracleVendorBadge`) instead of plain text to benefit from vendor icons, warnings, and tooltips. +### Market Display Components + +Use the right component for displaying market information: + +**MarketIdentity** (`@/components/MarketIdentity`) +- Use for displaying market info in compact rows (tables, lists, cards) +- Shows token icons, symbols, LLTV badge, and oracle badge +- Three modes: `Normal`, `Focused`, `Minimum` +- Focus parameter: `Loan` or `Collateral` (affects which symbol is emphasized) + +```tsx +import { MarketIdentity, MarketIdentityMode, MarketIdentityFocus } from '@/components/MarketIdentity'; + +// Focused mode (default) - emphasizes one asset + + +// Normal mode - both assets shown equally + + +// Minimum mode - only shows the focused asset (with LLTV and oracle if enabled) + + +// Wide layout - spreads content across full width (useful for tables) +// Icon + name on left, LLTV in middle, oracle on right + +``` + +**MarketDetailsBlock** (`@/components/common/MarketDetailsBlock`) +- Use as an expandable row in modals (e.g., supply/borrow flows) +- Shows market state details when expanded (APY, liquidity, utilization, etc.) +- Includes collapse/expand functionality + +```tsx +import { MarketDetailsBlock } from '@/components/common/MarketDetailsBlock'; + + +``` + +**When to use which:** +- Tables/Lists/Cards → Use `MarketIdentity` +- Modal flows with expandable details → Use `MarketDetailsBlock` + +**MarketIdBadge** (`@/components/MarketIdBadge`) +- Use to display a short market ID badge +- Consistent styling across all tables + +```tsx +import { MarketIdBadge } from '@/components/MarketIdBadge'; + +// Default: shows characters 2-8 of market ID + + +// Custom slice + +``` + ## Input Components The codebase uses two different input approaches depending on the use case: diff --git a/src/components/AgentIcon.tsx b/src/components/AgentIcon.tsx new file mode 100644 index 00000000..b8c274e6 --- /dev/null +++ b/src/components/AgentIcon.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { Tooltip } from '@heroui/react'; +import Image from 'next/image'; +import { HiQuestionMarkCircle } from 'react-icons/hi'; +import { Address } from 'viem'; +import { findAgent } from '@/utils/monarch-agent'; +import { TooltipContent } from './TooltipContent'; + +type AgentIconProps = { + address: Address; + width: number; + height: number; +}; + +export function AgentIcon({ address, width, height }: AgentIconProps) { + const agent = findAgent(address); + + if (!agent) { + return ( + +
+ +
+
+ ); + } + + const icon = ( + {agent.name} { + const target = e.currentTarget; + target.style.display = 'none'; + const fallback = target.nextElementSibling as HTMLElement; + if (fallback) fallback.style.display = 'flex'; + }} + /> + ); + + return ( + + } + > +
+ {agent.name} { + const target = e.currentTarget; + target.style.display = 'none'; + const fallback = target.nextElementSibling as HTMLElement; + if (fallback) fallback.style.display = 'flex'; + }} + /> +
+ +
+
+
+ ); +} diff --git a/src/components/MarketIdBadge.tsx b/src/components/MarketIdBadge.tsx new file mode 100644 index 00000000..458785f7 --- /dev/null +++ b/src/components/MarketIdBadge.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +type MarketIdBadgeProps = { + marketId: string; + slice?: { start: number; end: number }; +}; + +export function MarketIdBadge({ marketId, slice = { start: 2, end: 8 } }: MarketIdBadgeProps) { + const displayId = marketId.slice(slice.start, slice.end); + + return ( + + {displayId} + + ); +} diff --git a/src/components/MarketIdentity.tsx b/src/components/MarketIdentity.tsx new file mode 100644 index 00000000..5b96233e --- /dev/null +++ b/src/components/MarketIdentity.tsx @@ -0,0 +1,327 @@ +import { TokenIcon } from '@/components/TokenIcon'; +import OracleVendorBadge from '@/components/OracleVendorBadge'; +import { Market } from '@/utils/types'; +import { getTruncatedAssetName } from '@/utils/oracle'; + +export enum MarketIdentityMode { + Normal = 'normal', + Focused = 'focused', + Minimum = 'minimum', +} + +export enum MarketIdentityFocus { + Loan = 'loan', + Collateral = 'collateral', +} + +type MarketIdentityProps = { + market: Market; + chainId: number; + mode?: MarketIdentityMode; + focus?: MarketIdentityFocus; + showLltv?: boolean; + showOracle?: boolean; + iconSize?: number; + showExplorerLink?: boolean; + wide?: boolean; +}; + +export function MarketIdentity({ + market, + chainId, + mode = MarketIdentityMode.Focused, + focus = MarketIdentityFocus.Loan, + showLltv = true, + showOracle = true, + iconSize = 20, + showExplorerLink = false, + wide = false, +}: MarketIdentityProps) { + const lltv = (Number(market.lltv) / 1e16).toFixed(0); + const loanSymbol = getTruncatedAssetName(market.loanAsset.symbol); + const collateralSymbol = getTruncatedAssetName(market.collateralAsset.symbol); + + // Minimum mode: only show focused token + if (mode === MarketIdentityMode.Minimum) { + const token = focus === MarketIdentityFocus.Loan ? market.loanAsset : market.collateralAsset; + const role = focus === MarketIdentityFocus.Loan ? 'Loan Asset' : 'Collateral Asset'; + + if (wide) { + return ( +
+
+ + {getTruncatedAssetName(token.symbol)} +
+ {showLltv && ( + + {lltv}% LLTV + + )} + {showOracle && ( + + )} +
+ ); + } + + return ( +
+ + {getTruncatedAssetName(token.symbol)} + {showLltv && ( + + {lltv}% LLTV + + )} + {showOracle && ( + + )} +
+ ); + } + + // Focused mode: show both tokens with focus styling (always loan first) + if (mode === MarketIdentityMode.Focused) { + const isLoanFocused = focus === MarketIdentityFocus.Loan; + + if (wide) { + return ( +
+
+
+
+ +
+
+ +
+
+
+ + {loanSymbol} + + / + + {collateralSymbol} + +
+
+ {showLltv && ( + + {lltv}% LLTV + + )} + {showOracle && ( + + )} +
+ ); + } + + return ( +
+
+
+ +
+
+ +
+
+
+ + {loanSymbol} + + / + + {collateralSymbol} + + {showLltv && ( + + {lltv}% LLTV + + )} + {showOracle && ( + + )} +
+
+ ); + } + + // Normal mode: show both tokens equally (no styling difference) + if (wide) { + return ( +
+
+
+
+ +
+
+ +
+
+
+ {loanSymbol} + / {collateralSymbol} +
+
+ {showLltv && ( + + {lltv}% LLTV + + )} + {showOracle && ( + + )} +
+ ); + } + + return ( +
+
+
+ +
+
+ +
+
+
+ {loanSymbol} + / {collateralSymbol} + {showLltv && ( + + {lltv}% LLTV + + )} + {showOracle && ( + + )} +
+
+ ); +} diff --git a/src/components/TokenIcon.tsx b/src/components/TokenIcon.tsx index 55723565..81c88628 100644 --- a/src/components/TokenIcon.tsx +++ b/src/components/TokenIcon.tsx @@ -1,8 +1,11 @@ import React, { useMemo } from 'react'; import { Tooltip } from '@heroui/react'; import Image from 'next/image'; +import { FiExternalLink } from 'react-icons/fi'; import { useTokens } from '@/components/providers/TokenProvider'; -import { TooltipContent } from './TooltipContent'; +import { getExplorerUrl } from '@/utils/networks'; +import { TooltipContent } from '@/components/TooltipContent'; + type TokenIconProps = { address: string; chainId: number; @@ -10,9 +13,23 @@ type TokenIconProps = { height: number; opacity?: number; symbol?: string; + customTooltipTitle?: string; + customTooltipDetail?: string; + showExplorerLink?: boolean; + showTokenSource?: boolean; }; -export function TokenIcon({ address, chainId, width, height, opacity }: TokenIconProps) { +export function TokenIcon({ + address, + chainId, + width, + height, + opacity, + customTooltipTitle, + customTooltipDetail, + showExplorerLink = false, + showTokenSource = true, +}: TokenIconProps) { const { findToken } = useTokens(); const token = useMemo(() => findToken(address, chainId), [address, chainId, findToken]); @@ -29,17 +46,35 @@ export function TokenIcon({ address, chainId, width, height, opacity }: TokenIco /> ); - const detail = token.isFactoryToken - ? `This token is auto-detected from ${token.protocol?.name} ` - : `This token is whitelisted by Monarch`; + const title = customTooltipTitle ?? token.symbol; + + const tokenSource = token.isFactoryToken + ? `This token is auto-detected from ${token.protocol?.name}` + : `This token is recognized by Monarch`; + + const explorerUrl = showExplorerLink ? `${getExplorerUrl(chainId)}/address/${address}` : null; + + // Build detail/secondaryDetail based on what's provided + const detail = customTooltipDetail || (showTokenSource ? tokenSource : undefined); + const secondaryDetail = customTooltipDetail && showTokenSource ? tokenSource : undefined; return ( - } + content={ + : undefined} + actionHref={explorerUrl ?? undefined} + onActionClick={(e) => e.stopPropagation()} + /> + } > void; }; -export function TooltipContent({ icon, title, detail, className = '' }: TooltipContentProps) { +export function TooltipContent({ + icon, + title, + detail, + secondaryDetail, + className = '', + actionIcon, + actionHref, + onActionClick, +}: TooltipContentProps) { // Simple tooltip with just an icon and title - if (!detail) { + if (!detail && !secondaryDetail) { return (
{icon &&
{icon}
} {title} + {actionIcon && actionHref && ( + + {actionIcon} + + )}
); } @@ -25,14 +49,26 @@ export function TooltipContent({ icon, title, detail, className = '' }: TooltipC // Complex tooltip with additional details return (
{icon &&
{icon}
} -
+
{title &&
{title}
} -
{detail}
+ {detail &&
{detail}
} + {secondaryDetail &&
{secondaryDetail}
}
+ {actionIcon && actionHref && ( + + {actionIcon} + + )}
); diff --git a/src/components/common/MarketsTableWithSameLoanAsset.tsx b/src/components/common/MarketsTableWithSameLoanAsset.tsx index d7ae61a6..203d1823 100644 --- a/src/components/common/MarketsTableWithSameLoanAsset.tsx +++ b/src/components/common/MarketsTableWithSameLoanAsset.tsx @@ -13,8 +13,8 @@ import { ERC20Token, UnknownERC20Token, infoToKey, findToken } from '@/utils/tok import { Market } from '@/utils/types'; import { Pagination } from '../../../app/markets/components/Pagination'; import { MarketAssetIndicator, MarketOracleIndicator, MarketDebtIndicator } from '../../../app/markets/components/RiskIndicator'; -import OracleVendorBadge from '../OracleVendorBadge'; -import { TokenIcon } from '../TokenIcon'; +import { MarketIdentity, MarketIdentityMode, MarketIdentityFocus } from '../MarketIdentity'; +import { MarketIdBadge } from '../MarketIdBadge'; export type MarketWithSelection = { market: Market; @@ -32,12 +32,11 @@ type MarketsTableWithSameLoanAssetProps = { }; enum SortColumn { - Collateral = 0, - Oracle = 1, - LLTV = 2, - Supply = 3, - APY = 4, - Liquidity = 5, + Market = 0, + Supply = 1, + APY = 2, + Liquidity = 3, + Risk = 4, } const ITEMS_PER_PAGE = 8; @@ -383,39 +382,23 @@ function MarketRow({ className="h-4 w-4 cursor-pointer rounded border-gray-300 text-primary" onClick={(e) => e.stopPropagation()} /> - - {market.uniqueKey.slice(2, 8)} - -
- - -
- -

- {market.collateralAsset.symbol.length > 8 - ? `${market.collateralAsset.symbol.slice(0, 8)}...` - : market.collateralAsset.symbol} -

-
- -
+ - -

{formatUnits(BigInt(market.lltv), 16)}%

+ +

@@ -526,19 +509,11 @@ export function MarketsTableWithSameLoanAsset({ filtered.sort((a, b) => { let comparison = 0; switch (sortColumn) { - case SortColumn.Collateral: + case SortColumn.Market: comparison = a.market.collateralAsset.symbol.localeCompare( b.market.collateralAsset.symbol, ); break; - case SortColumn.Oracle: - const oracleA = getOracleType(a.market.oracle?.data) ?? ''; - const oracleB = getOracleType(b.market.oracle?.data) ?? ''; - comparison = oracleA.localeCompare(oracleB); - break; - case SortColumn.LLTV: - comparison = Number(a.market.lltv) - Number(b.market.lltv); - break; case SortColumn.Supply: comparison = Number(a.market.state.supplyAssetsUsd) - Number(b.market.state.supplyAssetsUsd); @@ -582,46 +557,16 @@ export function MarketsTableWithSameLoanAsset({ className="bg-hovered rounded transition-colors" >

-
-
-
- -
-
- -
-
-
- - {getTruncatedAssetName(market.loanAsset.symbol)} - - - / {getTruncatedAssetName(market.collateralAsset.symbol)} - -
-
- · - - · - {(Number(market.lltv) / 1e16).toFixed(0)}% LLTV -
-
+
{renderCartItemExtra && renderCartItemExtra(market)} @@ -658,24 +603,11 @@ export function MarketsTableWithSameLoanAsset({ - - - + + {paginatedMarkets.length === 0 ? ( - diff --git a/src/hooks/useVaultPage.ts b/src/hooks/useVaultPage.ts index 6c882cd4..0e03165f 100644 --- a/src/hooks/useVaultPage.ts +++ b/src/hooks/useVaultPage.ts @@ -1,6 +1,5 @@ import { useCallback, useMemo } from 'react'; import { Address, zeroAddress } from 'viem'; -import { useAccount } from 'wagmi'; import { SupportedNetworks } from '@/utils/networks'; import { useVaultV2Data } from './useVaultV2Data'; import { useVaultV2 } from './useVaultV2'; From 4730888e4b70213df54581bbe6dd3af026eccdee Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 22 Oct 2025 15:13:03 +0800 Subject: [PATCH 24/29] feat: market identity --- app/markets/components/MarketTableBody.tsx | 31 ++++---- .../components/SuppliedMarketsDetail.tsx | 29 ++----- docs/Styling.md | 42 ++++++++-- src/components/MarketIdBadge.tsx | 76 +++++++++++++++++-- .../common/MarketsTableWithSameLoanAsset.tsx | 2 +- 5 files changed, 132 insertions(+), 48 deletions(-) diff --git a/app/markets/components/MarketTableBody.tsx b/app/markets/components/MarketTableBody.tsx index 7d749dc5..6fab09e2 100644 --- a/app/markets/components/MarketTableBody.tsx +++ b/app/markets/components/MarketTableBody.tsx @@ -5,9 +5,9 @@ import Image from 'next/image'; import { FaShieldAlt } from 'react-icons/fa'; import { GoStarFill, GoStar } from 'react-icons/go'; import { Button } from '@/components/common/Button'; +import { MarketIdBadge } from '@/components/MarketIdBadge'; import OracleVendorBadge from '@/components/OracleVendorBadge'; import { TooltipContent } from '@/components/TooltipContent'; -import { getNetworkImg } from '@/utils/networks'; import { Market } from '@/utils/types'; import logo from '../../../imgs/logo.png'; import { APYCell } from './APYBreakdownTooltip'; @@ -46,7 +46,6 @@ export function MarketTableBody({ .slice(0, 6) .concat(item.collateralAsset.symbol.length > 6 ? '...' : ''); const isStared = staredIds.includes(item.uniqueKey); - const chainImg = getNetworkImg(item.morphoBlue.chain.id); return ( @@ -77,19 +76,21 @@ export function MarketTableBody({ void; @@ -71,27 +72,13 @@ function MarketRow({ +``` + **MarketDetailsBlock** (`@/components/common/MarketDetailsBlock`) - Use as an expandable row in modals (e.g., supply/borrow flows) - Shows market state details when expanded (APY, liquidity, utilization, etc.) @@ -239,17 +263,25 @@ import { MarketDetailsBlock } from '@/components/common/MarketDetailsBlock'; - Modal flows with expandable details → Use `MarketDetailsBlock` **MarketIdBadge** (`@/components/MarketIdBadge`) -- Use to display a short market ID badge +- Use to display a short market ID badge with optional network icon and warning indicator - Consistent styling across all tables +- `chainId` is required +- Warning indicator reserves space for alignment even when no warnings present ```tsx import { MarketIdBadge } from '@/components/MarketIdBadge'; -// Default: shows characters 2-8 of market ID - +// Basic usage (required chainId) + + +// With network icon and warnings + -// Custom slice - ``` ## Input Components diff --git a/src/components/MarketIdBadge.tsx b/src/components/MarketIdBadge.tsx index 458785f7..16907441 100644 --- a/src/components/MarketIdBadge.tsx +++ b/src/components/MarketIdBadge.tsx @@ -1,16 +1,80 @@ import React from 'react'; +import Image from 'next/image'; +import { Link, Tooltip } from '@heroui/react'; +import { TooltipContent } from '@/components/TooltipContent'; +import { getNetworkImg } from '@/utils/networks'; +import { Market } from '@/utils/types'; +import { computeMarketWarnings } from '@/hooks/useMarketWarnings'; type MarketIdBadgeProps = { marketId: string; - slice?: { start: number; end: number }; + chainId: number; + showNetworkIcon?: boolean; + showWarnings?: boolean; + showLink?: boolean; + market?: Market; }; -export function MarketIdBadge({ marketId, slice = { start: 2, end: 8 } }: MarketIdBadgeProps) { - const displayId = marketId.slice(slice.start, slice.end); +export function MarketIdBadge({ + marketId, + chainId, + showNetworkIcon = false, + showWarnings = false, + showLink = true, + market, +}: MarketIdBadgeProps) { + const displayId = marketId.slice(2, 8); + const chainImg = getNetworkImg(chainId); + + // Compute warnings if needed + const warnings = showWarnings && market ? computeMarketWarnings(market, true) : []; + const hasWarnings = warnings.length > 0; + const alertWarning = warnings.find((w) => w.level === 'alert'); + const warningLevel = alertWarning ? 'alert' : warnings.length > 0 ? 'warning' : null; return ( - - {displayId} - +
+ {showNetworkIcon && chainImg && ( + + )} + { + showLink ? ( + + {displayId} + + ) : ( + + {displayId} + + )} + + {showWarnings && ( +
+ {hasWarnings && ( + + } + > +
+ + )} +
+ )} +
); } diff --git a/src/components/common/MarketsTableWithSameLoanAsset.tsx b/src/components/common/MarketsTableWithSameLoanAsset.tsx index 203d1823..c2fe6b53 100644 --- a/src/components/common/MarketsTableWithSameLoanAsset.tsx +++ b/src/components/common/MarketsTableWithSameLoanAsset.tsx @@ -385,7 +385,7 @@ function MarketRow({
- + diff --git a/app/autovault/[chainId]/[vaultAddress]/components/allocations/MarketView.tsx b/app/autovault/[chainId]/[vaultAddress]/components/allocations/MarketView.tsx index 44ce0c87..f7806f6f 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/allocations/MarketView.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/allocations/MarketView.tsx @@ -1,8 +1,8 @@ import { MarketIdentity, MarketIdentityFocus } from '@/components/MarketIdentity'; -import { Market } from '@/utils/types'; +import { formatBalance, formatReadable } from '@/utils/balance'; import { SupportedNetworks } from '@/utils/networks'; +import { Market } from '@/utils/types'; import { formatAllocationAmount, calculateAllocationPercent } from '@/utils/vaultAllocation'; -import { formatBalance, formatReadable } from '@/utils/balance'; import { AllocationPieChart } from './AllocationPieChart'; type MarketItem = { @@ -43,7 +43,7 @@ export function MarketView({ - + @@ -68,10 +68,10 @@ export function MarketView({ market={market} chainId={chainId} focus={MarketIdentityFocus.Collateral} - showLltv={true} - showOracle={true} + showLltv + showOracle iconSize={20} - showExplorerLink={true} + showExplorerLink /> diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/AddMarketCapModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/AddMarketCapModal.tsx index 654a0be0..b1a7d599 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/AddMarketCapModal.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/AddMarketCapModal.tsx @@ -1,7 +1,7 @@ import { Address } from 'viem'; import { MarketSelectionModal } from '@/components/common/MarketSelectionModal'; -import { Market } from '@/utils/types'; import { SupportedNetworks } from '@/utils/networks'; +import { Market } from '@/utils/types'; type AddMarketCapModalProps = { vaultAsset: Address; @@ -29,7 +29,7 @@ export function AddMarketCapModal({ vaultAsset={vaultAsset} chainId={chainId} excludeMarketIds={existingMarketIds} - multiSelect={true} + multiSelect onClose={onClose} onSelect={onAdd} confirmButtonText={undefined} // Use default dynamic text diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentListItem.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentListItem.tsx index f92b16d8..0afd1ab0 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentListItem.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentListItem.tsx @@ -1,7 +1,7 @@ import { Address } from 'viem'; +import { AgentIcon } from '@/components/AgentIcon'; import { AddressDisplay } from '@/components/common/AddressDisplay'; import { findAgent } from '@/utils/monarch-agent'; -import { AgentIcon } from '@/components/AgentIcon'; type AgentListItemProps = { address: Address; diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentsTab.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentsTab.tsx index ceea2b04..ea94a730 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentsTab.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentsTab.tsx @@ -5,8 +5,8 @@ import { Button } from '@/components/common/Button'; import { Spinner } from '@/components/common/Spinner'; import { useMarketNetwork } from '@/hooks/useMarketNetwork'; import { v2AgentsBase } from '@/utils/monarch-agent'; -import { AgentsTabProps } from './types'; import { AgentListItem } from './AgentListItem'; +import { AgentsTabProps } from './types'; export function AgentsTab({ isOwner, diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentCaps.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentCaps.tsx index f0a20fd1..0c5a007d 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentCaps.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentCaps.tsx @@ -1,15 +1,15 @@ import { useMemo, useState } from 'react'; +import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons'; +import { Address, maxUint128 } from 'viem'; import { Button } from '@/components/common/Button'; +import { MarketDetailsBlock } from '@/components/common/MarketDetailsBlock'; import { Spinner } from '@/components/common/Spinner'; import { TokenIcon } from '@/components/TokenIcon'; import { useMarkets } from '@/hooks/useMarkets'; -import { parseCapIdParams } from '@/utils/morpho'; import { CapData } from '@/hooks/useVaultV2Data'; -import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons'; -import { Address, maxUint128 } from 'viem'; +import { parseCapIdParams } from '@/utils/morpho'; import { findToken } from '@/utils/tokens'; import { MarketCapsTable } from './MarketCapsTable'; -import { MarketDetailsBlock } from '@/components/common/MarketDetailsBlock'; import { CollateralCapTooltip } from './Tooltips'; type CurrentCapsProps = { @@ -60,11 +60,11 @@ export function CurrentCaps({ // Group market caps by collateral const marketCapsByCollateral = useMemo(() => { - const grouped = new Map['marketCaps'][0]; market: typeof markets[0] | null; capPercent: string; - }>>(); + }[]>(); if (!existingCaps) return grouped; @@ -190,9 +190,12 @@ export function CurrentCaps({ className="rounded bg-surface overflow-hidden" > {/* Collateral Cap Row */} -
hasMarkets && toggleCollateral(collateralAddr)} + disabled={!hasMarkets} + aria-expanded={hasMarkets ? isExpanded : undefined} > )} -
+ {/* Market Caps - Expandable */} {isExpanded && hasMarkets && ( diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/EditCaps.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/EditCaps.tsx index c419ea54..0576f89c 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/EditCaps.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/EditCaps.tsx @@ -1,21 +1,21 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; +import { PlusIcon } from '@radix-ui/react-icons'; import { Address, parseUnits, maxUint128 } from 'viem'; +import { Badge } from '@/components/common/Badge'; import { Button } from '@/components/common/Button'; import { Spinner } from '@/components/common/Spinner'; +import { useTokens } from '@/components/providers/TokenProvider'; import { TokenIcon } from '@/components/TokenIcon'; import { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { useMarketNetwork } from '@/hooks/useMarketNetwork'; import { useMarkets } from '@/hooks/useMarkets'; -import { useTokens } from '@/components/providers/TokenProvider'; +import { CapData } from '@/hooks/useVaultV2Data'; import { getMarketCapId, getCollateralCapId, getAdapterCapId, parseCapIdParams } from '@/utils/morpho'; import { SupportedNetworks } from '@/utils/networks'; -import { CapData } from '@/hooks/useVaultV2Data'; -import { CollateralCapTooltip, MarketCapTooltip } from './Tooltips'; -import { MarketCapsTable } from './MarketCapsTable'; -import { AddMarketCapModal } from './AddMarketCapModal'; import { Market } from '@/utils/types'; -import { PlusIcon } from '@radix-ui/react-icons'; -import { Badge } from '@/components/common/Badge'; +import { AddMarketCapModal } from './AddMarketCapModal'; +import { MarketCapsTable } from './MarketCapsTable'; +import { CollateralCapTooltip, MarketCapTooltip } from './Tooltips'; type EditCapsProps = { existingCaps?: CapData; @@ -164,37 +164,6 @@ export function EditCaps({ }); }, []); - const handleRemoveMarket = useCallback((marketId: string) => { - setMarketCaps((prev) => { - const next = new Map(prev); - const marketInfo = next.get(marketId.toLowerCase()); - next.delete(marketId.toLowerCase()); - - // Check if collateral is still used by other markets - if (marketInfo) { - const collateralAddr = marketInfo.market.collateralAsset.address.toLowerCase(); - const stillUsed = Array.from(next.values()).some( - (m) => m.market.collateralAsset.address.toLowerCase() === collateralAddr - ); - - // Remove collateral cap if no longer used and it's a new cap - if (!stillUsed) { - setCollateralCaps((prevCaps) => { - const capInfo = prevCaps.get(collateralAddr); - if (capInfo && !capInfo.existingCapId) { - const newCaps = new Map(prevCaps); - newCaps.delete(collateralAddr); - return newCaps; - } - return prevCaps; - }); - } - } - - return next; - }); - }, []); - const handleUpdateMarketCap = useCallback((marketId: string, field: 'relativeCap' | 'absoluteCap', value: string) => { setMarketCaps((prev) => { const next = new Map(prev); @@ -339,19 +308,6 @@ export function EditCaps({ const existingMarketIds = new Set(Array.from(marketCaps.keys())); - // Group markets by collateral - const marketsByCollateral = useMemo(() => { - const grouped = new Map(); - marketCaps.forEach((info) => { - const collateralAddr = info.market.collateralAsset.address.toLowerCase(); - if (!grouped.has(collateralAddr)) { - grouped.set(collateralAddr, []); - } - grouped.get(collateralAddr)!.push(info); - }); - return grouped; - }, [marketCaps]); - return ( <>
@@ -546,7 +502,7 @@ export function EditCaps({ {/* Actions */}
-
+
@@ -283,8 +311,8 @@ export default function VaultContent() { chainId={chainId} marketAdapter={vault.adapter} marketAdapterLoading={vault.adapterLoading} - refetchMarketAdapter={vault.refetchAdapter} - onAdapterConfigured={vault.refetchAll} + refetchMarketAdapter={handleRefetchAdapter} + onAdapterConfigured={handleAdapterConfigured} /> )}
diff --git a/app/markets/components/MarketTableBody.tsx b/app/markets/components/MarketTableBody.tsx index 6fab09e2..22a84e6f 100644 --- a/app/markets/components/MarketTableBody.tsx +++ b/app/markets/components/MarketTableBody.tsx @@ -87,7 +87,7 @@ export function MarketTableBody({ diff --git a/app/positions/components/SuppliedMarketsDetail.tsx b/app/positions/components/SuppliedMarketsDetail.tsx index 48b109c3..6f5f20cc 100644 --- a/app/positions/components/SuppliedMarketsDetail.tsx +++ b/app/positions/components/SuppliedMarketsDetail.tsx @@ -1,16 +1,11 @@ import React from 'react'; -import { Tooltip } from '@heroui/react'; import { motion } from 'framer-motion'; -import Link from 'next/link'; -import { IoWarningOutline } from 'react-icons/io5'; import { Button } from '@/components/common'; -import OracleVendorBadge from '@/components/OracleVendorBadge'; -import { TokenIcon } from '@/components/TokenIcon'; -import { useMarketWarnings } from '@/hooks/useMarketWarnings'; +import { MarketIdBadge } from '@/components/MarketIdBadge'; +import { MarketIdentity, MarketIdentityFocus, MarketIdentityMode } from '@/components/MarketIdentity'; import { formatReadable, formatBalance } from '@/utils/balance'; -import { MarketPosition, GroupedPosition, WarningWithDetail, WarningCategory } from '@/utils/types'; +import { MarketPosition, GroupedPosition } from '@/utils/types'; import { getCollateralColor } from '../utils/colors'; -import { MarketIdBadge } from '@/components/MarketIdBadge'; type SuppliedMarketsDetailProps = { groupedPosition: GroupedPosition; setShowWithdrawModal: (show: boolean) => void; @@ -20,27 +15,6 @@ type SuppliedMarketsDetailProps = { showCollateralExposure: boolean; }; -function WarningTooltip({ warnings }: { warnings: WarningWithDetail[] }) { - return ( -
- {Object.values(WarningCategory).map((category) => { - const categoryWarnings = warnings.filter((w) => w.category === category); - if (categoryWarnings.length === 0) return null; - return ( -
-

{category}

-
    - {categoryWarnings.map((warning, index) => ( -
  • - {warning.description}
  • - ))} -
-
- ); - })} -
- ); -} - function MarketRow({ position, totalSupply, @@ -54,19 +28,11 @@ function MarketRow({ setShowSupplyModal: (show: boolean) => void; setSelectedPosition: (position: MarketPosition) => void; }) { - const warningsWithDetail = useMarketWarnings(position.market, true); - - const getWarningColor = (warnings: WarningWithDetail[]) => { - if (warnings.some((w) => w.level === 'alert')) return 'text-red-500'; - if (warnings.some((w) => w.level === 'warning')) return 'text-yellow-500'; - return ''; - }; const suppliedAmount = Number( formatBalance(position.state.supplyAssets, position.market.loanAsset.decimals), ); const percentageOfPortfolio = totalSupply > 0 ? (suppliedAmount / totalSupply) * 100 : 0; - const warningColor = getWarningColor(warningsWithDetail); return (
@@ -77,37 +43,18 @@ function MarketRow({ chainId={position.market.morphoBlue.chain.id} showNetworkIcon={false} market={position.market} - showWarnings={true} + showWarnings /> - - - - - - + diff --git a/src/components/MarketIdBadge.tsx b/src/components/MarketIdBadge.tsx index 16907441..560caa59 100644 --- a/src/components/MarketIdBadge.tsx +++ b/src/components/MarketIdBadge.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import Image from 'next/image'; import { Link, Tooltip } from '@heroui/react'; +import Image from 'next/image'; import { TooltipContent } from '@/components/TooltipContent'; +import { computeMarketWarnings } from '@/hooks/useMarketWarnings'; import { getNetworkImg } from '@/utils/networks'; import { Market } from '@/utils/types'; -import { computeMarketWarnings } from '@/hooks/useMarketWarnings'; type MarketIdBadgeProps = { marketId: string; diff --git a/src/components/MarketIdentity.tsx b/src/components/MarketIdentity.tsx index 5b96233e..af630c8a 100644 --- a/src/components/MarketIdentity.tsx +++ b/src/components/MarketIdentity.tsx @@ -1,7 +1,7 @@ -import { TokenIcon } from '@/components/TokenIcon'; import OracleVendorBadge from '@/components/OracleVendorBadge'; -import { Market } from '@/utils/types'; +import { TokenIcon } from '@/components/TokenIcon'; import { getTruncatedAssetName } from '@/utils/oracle'; +import { Market, TokenInfo } from '@/utils/types'; export enum MarketIdentityMode { Normal = 'normal', @@ -39,28 +39,70 @@ export function MarketIdentity({ }: MarketIdentityProps) { const lltv = (Number(market.lltv) / 1e16).toFixed(0); const loanSymbol = getTruncatedAssetName(market.loanAsset.symbol); - const collateralSymbol = getTruncatedAssetName(market.collateralAsset.symbol); + const collateralAsset = (market.collateralAsset as TokenInfo | null) ?? null; + const collateralSymbol = collateralAsset + ? getTruncatedAssetName(collateralAsset.symbol) + : 'Idle Market'; + + const tokenStack = ( +
+
+ +
+ {collateralAsset ? ( +
+ +
+ ) : null} +
+ ); // Minimum mode: only show focused token if (mode === MarketIdentityMode.Minimum) { - const token = focus === MarketIdentityFocus.Loan ? market.loanAsset : market.collateralAsset; + const isLoanFocus = focus === MarketIdentityFocus.Loan; + const token = isLoanFocus ? market.loanAsset : collateralAsset; const role = focus === MarketIdentityFocus.Loan ? 'Loan Asset' : 'Collateral Asset'; + const label = isLoanFocus ? loanSymbol : collateralSymbol; if (wide) { return (
- - {getTruncatedAssetName(token.symbol)} + {token ? ( + <> + + {label} + + ) : ( + {label} + )}
{showLltv && ( @@ -71,7 +113,7 @@ export function MarketIdentity({ )} @@ -81,17 +123,23 @@ export function MarketIdentity({ return (
- - {getTruncatedAssetName(token.symbol)} + {token ? ( + <> + + {label} + + ) : ( + {label} + )} {showLltv && ( {lltv}% LLTV @@ -101,7 +149,7 @@ export function MarketIdentity({ )} @@ -117,40 +165,31 @@ export function MarketIdentity({ return (
-
-
- -
-
- -
-
+ {tokenStack}
{loanSymbol} - / - - {collateralSymbol} - + {collateralAsset ? ( + <> + / + + {collateralSymbol} + + + ) : ( + + {collateralSymbol} + + )}
{showLltv && ( @@ -162,7 +201,7 @@ export function MarketIdentity({ )} @@ -172,40 +211,31 @@ export function MarketIdentity({ return (
-
-
- -
-
- -
-
+ {tokenStack}
{loanSymbol} - / - - {collateralSymbol} - + {collateralAsset ? ( + <> + / + + {collateralSymbol} + + + ) : ( + + {collateralSymbol} + + )} {showLltv && ( {lltv}% LLTV @@ -215,7 +245,7 @@ export function MarketIdentity({ )} @@ -229,35 +259,14 @@ export function MarketIdentity({ return (
-
-
- -
-
- -
-
+ {tokenStack}
{loanSymbol} - / {collateralSymbol} + {collateralAsset ? ( + / {collateralSymbol} + ) : ( + {collateralSymbol} + )}
{showLltv && ( @@ -269,7 +278,7 @@ export function MarketIdentity({ )} @@ -279,35 +288,14 @@ export function MarketIdentity({ return (
-
-
- -
-
- -
-
+ {tokenStack}
{loanSymbol} - / {collateralSymbol} + {collateralAsset ? ( + / {collateralSymbol} + ) : ( + {collateralSymbol} + )} {showLltv && ( {lltv}% LLTV @@ -317,7 +305,7 @@ export function MarketIdentity({ )} diff --git a/src/components/TokenIcon.tsx b/src/components/TokenIcon.tsx index 81c88628..c8cc4f85 100644 --- a/src/components/TokenIcon.tsx +++ b/src/components/TokenIcon.tsx @@ -3,8 +3,8 @@ import { Tooltip } from '@heroui/react'; import Image from 'next/image'; import { FiExternalLink } from 'react-icons/fi'; import { useTokens } from '@/components/providers/TokenProvider'; -import { getExplorerUrl } from '@/utils/networks'; import { TooltipContent } from '@/components/TooltipContent'; +import { getExplorerUrl } from '@/utils/networks'; type TokenIconProps = { address: string; diff --git a/src/components/common/MarketSelectionModal.tsx b/src/components/common/MarketSelectionModal.tsx index 3e49a4f8..0a6d5b5b 100644 --- a/src/components/common/MarketSelectionModal.tsx +++ b/src/components/common/MarketSelectionModal.tsx @@ -4,8 +4,8 @@ import { Button } from '@/components/common/Button'; import { MarketsTableWithSameLoanAsset } from '@/components/common/MarketsTableWithSameLoanAsset'; import { Spinner } from '@/components/common/Spinner'; import { useMarkets } from '@/hooks/useMarkets'; -import { Market } from '@/utils/types'; import { SupportedNetworks } from '@/utils/networks'; +import { Market } from '@/utils/types'; type MarketSelectionModalProps = { title?: string; @@ -92,11 +92,22 @@ export function MarketSelectionModal({ } }; + const handleBackdropKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape' || event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onClose(); + } + }; + if (marketsLoading) { return (
@@ -118,6 +129,10 @@ export function MarketSelectionModal({
diff --git a/src/components/common/MarketsTableWithSameLoanAsset.tsx b/src/components/common/MarketsTableWithSameLoanAsset.tsx index c2fe6b53..36a6af6c 100644 --- a/src/components/common/MarketsTableWithSameLoanAsset.tsx +++ b/src/components/common/MarketsTableWithSameLoanAsset.tsx @@ -4,17 +4,15 @@ import { motion, AnimatePresence } from 'framer-motion'; import Image from 'next/image'; import { IoHelpCircleOutline } from 'react-icons/io5'; import { LuX } from 'react-icons/lu'; -import { formatUnits } from 'viem'; import { formatBalance, formatReadable } from '@/utils/balance'; import { getViemChain } from '@/utils/networks'; -import { getTruncatedAssetName } from '@/utils/oracle'; -import { getOracleType, parsePriceFeedVendors, PriceFeedVendors, OracleVendorIcons } from '@/utils/oracle'; +import { parsePriceFeedVendors, PriceFeedVendors, OracleVendorIcons } from '@/utils/oracle'; import { ERC20Token, UnknownERC20Token, infoToKey, findToken } from '@/utils/tokens'; import { Market } from '@/utils/types'; import { Pagination } from '../../../app/markets/components/Pagination'; import { MarketAssetIndicator, MarketOracleIndicator, MarketDebtIndicator } from '../../../app/markets/components/RiskIndicator'; -import { MarketIdentity, MarketIdentityMode, MarketIdentityFocus } from '../MarketIdentity'; import { MarketIdBadge } from '../MarketIdBadge'; +import { MarketIdentity, MarketIdentityMode, MarketIdentityFocus } from '../MarketIdentity'; export type MarketWithSelection = { market: Market; @@ -32,7 +30,7 @@ type MarketsTableWithSameLoanAssetProps = { }; enum SortColumn { - Market = 0, + MarketName = 0, Supply = 1, APY = 2, Liquidity = 3, @@ -393,11 +391,11 @@ function MarketRow({ chainId={market.morphoBlue.chain.id} mode={MarketIdentityMode.Minimum} focus={MarketIdentityFocus.Collateral} - showLltv={true} - showOracle={true} + showLltv + showOracle iconSize={20} showExplorerLink={false} - wide={true} + wide />
; + }[]; caps: { items: ApiVaultV2Cap[]; }; @@ -72,7 +72,7 @@ type VaultV2ApiResponse = { items: ApiVaultV2[]; }; }; - errors?: Array<{ message: string }>; + errors?: { message: string }[]; }; /** diff --git a/src/hooks/useVaultPage.ts b/src/hooks/useVaultPage.ts index 0e03165f..3845f6a8 100644 --- a/src/hooks/useVaultPage.ts +++ b/src/hooks/useVaultPage.ts @@ -1,10 +1,10 @@ import { useCallback, useMemo } from 'react'; import { Address, zeroAddress } from 'viem'; import { SupportedNetworks } from '@/utils/networks'; -import { useVaultV2Data } from './useVaultV2Data'; -import { useVaultV2 } from './useVaultV2'; -import { useMorphoMarketV1Adapters } from './useMorphoMarketV1Adapters'; import { useAllocations } from './useAllocations'; +import { useMorphoMarketV1Adapters } from './useMorphoMarketV1Adapters'; +import { useVaultV2 } from './useVaultV2'; +import { useVaultV2Data } from './useVaultV2Data'; type UseVaultPageArgs = { vaultAddress: Address; @@ -44,7 +44,9 @@ export function useVaultPage({ vaultAddress, chainId, connectedAddress }: UseVau } = useVaultV2({ vaultAddress, chainId, - onTransactionSuccess: refetchVaultData, + onTransactionSuccess: () => { + void refetchVaultData(); + }, }); // Fetch market adapter diff --git a/src/hooks/useVaultV2.ts b/src/hooks/useVaultV2.ts index 600963bc..7f349421 100644 --- a/src/hooks/useVaultV2.ts +++ b/src/hooks/useVaultV2.ts @@ -67,8 +67,8 @@ export function useVaultV2({ const currentCurator = useMemo(() => (curator as Address | undefined) ?? zeroAddress, [curator]); const refetchAll = useCallback(() => { - refetchBalance() - }, [refetchBalance]) + void refetchBalance(); + }, [refetchBalance]); const handleInitializationSuccess = useCallback(() => { void refetchAll(); diff --git a/src/hooks/useVaultV2Data.ts b/src/hooks/useVaultV2Data.ts index 04813d1e..9ace3915 100644 --- a/src/hooks/useVaultV2Data.ts +++ b/src/hooks/useVaultV2Data.ts @@ -121,7 +121,7 @@ export function useVaultV2Data({ } finally { setLoading(false); } - // eslint-disable-next-line react-hooks/exhaustive-deps + }, [vaultAddress, chainId]); useEffect(() => { diff --git a/src/imgs/agent/agent-apy.png b/src/imgs/agent/agent-apy.png new file mode 100644 index 0000000000000000000000000000000000000000..f3dc6beaabd839c5f0ae5b962c080fd13ca5eec8 GIT binary patch literal 79575 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

oAK2$!3{zPk0>y7RTv9C2wtNo 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 Y*0=(%cuJ;y#1kY%S+!Rc(&nN62V%%*i2wiq literal 0 HcmV?d00001 diff --git a/src/imgs/agent/agent-liquid.png b/src/imgs/agent/agent-liquid.png new file mode 100644 index 0000000000000000000000000000000000000000..af19462e6911888d2261ae15cb62e85dab0e801e GIT binary patch literal 82865 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%0DE>GJKk#0HcP39Q)@t)x9<^r6@c02?Z055k=D& zkza=uI(%>v$l~|wgr)QJP|YWfVvzir53y8SFi}vcA&*rThIvV~YcB*b_DK7U9eb5b zOl1?p8<2kg!DultY|?@o1bwUA{B<;W$yYV2E51;@LJ^YChq*&VtY`ejnS`5Yc-s4e z;WU9^NmcWQv+-A>|KAL`#2rwn1mU;=PEn+e)KAbtoI-`I16&=7iY?bWCN` z5Nb0-$usxUUokXjFYF7B-C9(&WGq4&rC!RK>K=Z9T_8C-kz$k8X{M~4{4ipCauU4H z7)wO(+El!&dDHa2-+5Vuq$bRyhF3j-K`@><;g z&FI~=kp&I7zY@=1cDu?%i4(i~ogiAzbvJp!5CIN1A1PvD71UPcF4c*Io!1_|Lty^M z)Zj&}7HXfH?xS;OQHUyCh%ZAE<0ShP0zIl1v;Qhom8O(YND6!Yq2uo>XKALKx%SIe*QYGwq!nw?qQF%J#cDQd=%++ruU=vx-(&R;+-kXpu z`US(1^gVqjK%M5RKemP6D`#8*xD|ZsPqKKzEH1sSxOz&pr4&Kc6p9vX#&#IS3z5xv zG}|V=-J=qMML(Me>-O2D2qMImTUL7$IC?(Uy+0m52_EtTlLuX!nSp;cVv#<3oT?XsGQw*V8tq;OZKH<7JyTB%W?fWVfC>fFeM}r5~$o^aMTl z>EL_k{+_-D-!AtARdodji!%=Gg98=v7r0q_0(+-&oa5tDSz=U#B<>fqQj#y;6Dly3 zPd!4t^ghY+miG%Gjh z6(7VfncemIgGC#N+P60GRRQykiK;Lg8#&nN(4O)%xObhrhiSg3_j62@4j{V5q zK+$N{GaG? z%}h|aVo2XqQNyE2MoUN?Y-{y*;ra6BSMn2ne@>l`m z+Dy;qPH)A}4#L&Np?tP#_Z!P&ooDgbj=_DqJo(|!^=KCTWhja?Ou4FKEt=z}%XEoZif z3tvvG%|#|rR)UyJpX0=;6VzLHQBF$AfC;KK@@Kxu3sGBUyPrK$*NaYTK zLBp*e4)RMr408#)gP$UjC~($E)iO)_N0ml>uUYLJj4Cq4CT4j^1G9=cF;A+|0XlQ> z9>nk9F=Nbm@O<9N5wwm4>Fx=_)6^7P86CwR(|?p6M0MP2Y`JVgO1C9U()mLzn-`$jSnPZbrSr0K=H;Mcj4y5 z4wTS&uM6n^Z zYS*92h+ccxa;UM?1^jfhkex6g(n&(FMEfOF5kvcpsra>1OC=zQZr9?oi`Hs=8z6Xk z6m;E>xknW@{)2pZKzmBaK>tT7aha37hN=>qk88|iWGi#>(%^I>`Hy(XZL;gVb~TX0 zy%&Cl&pV3i_AwjsA4z!N!mn_c|vy!=gs3yrQcGg>i2 zdCIeXhNJ%X1mQseE;DEH8nN!FYR_w?c>&~49$!^xG z2FcO=(19J5U?-&;U&wb8&d0s&DA;)~iW$%8Z4-s}tXU}q^t9j9Z^hYV9az9`0`P3y z{*_GRb>@A6v3&i+2=2ia*Zqju%Adr{PwQr?pxX3)=~d{rUPZ}X9ucWc75@; z`Um5ONAk99l~nmw)st5u0IX8+u`9;C7t>U^;SNutyd8U8;T zThGWc@waVxZR(@rFcB?Euvaw8h!zeLMk~xfR|ve2$#>olb;?8YBGuN@&>iDUF)Itt z(cs0m*Wz{5?R)FvGiBo$W*JZT%l8MMo28W?jy65ecm}ZA?{2ZvVJ7J0KI5KcUZjaW z=LzXJ@V%bXgB2(lCS7$wY^r@HtAD?AUPO$uBkk;}fBy&2V_{y44v>=kLx@D$c{2`E>Od7gVD@|MatFUQCQJ@97S7amCn>w>X%dXPl@Oe09I% z>Gf&ys*JGtP<=$Lloe<-KL1HM2-a)TVZ!)$skoqc+s%dFV#Csz{Qg|3?L%Fx+viJB zF-F+HUY3f#sk_axCA4^$2AP=dC&!*NsB^ey?Jvpimrmy!B<_B-ehkptT`D77JXB{- zbD;9Cc0Y|{_~rRI*wfVgG#P!$AwNStE87^naN;Yp%nQkkI3x9XH=l09Ry%KhH|RGA z^I*w|dT)@W8YGL+V4N5I6G{KhrxwAt^EXx=?E7+O!JdsO!H)5nPj}?^cz)GEIUqHY z-RXM9{bq-}+1$@oK{0qK(+XmEudOPpOL14gHhC1Y=I=M5jziI4o9W3tChfVeLn|VFR zcZpg2@pqJ3%f>O4z;*jEhq%|#Er;;-6i`(Z)Jv7^ZZoZ}4_l!izpQOlAZt3RE>m_u z@Y#&^`fZorio-gzxFWeGgJ(DHHppj)IX%!}h(xiAEXa3FwV^@N8C$x`w zS>4}^QtU8ExishPHKVqA>rQZ4j@u%3Y5P%6jk@UPAc1WH6-mirXLIt;;Dp>OIvhf<~r13oZo`v}}LSJ(!g_i87! z-HB?e2|oN}wl%(ZVlL|dGHR=3UlzYGd7!%iqiBzt2aGk>uUk`Hb~ZHoL9;CyH?Net z0uJrc0Y3I?E>5=MxqV{!ZhzEE9X+nTRvhJdFA`|0iDd<4^Z?9mikTU^fb!aEkbzWf zH4Cz_=B>!5x2s&cJIVmAUHnp@_tK*F7`>b=4@BiM@7LY5f*+VzOncbENIme@r!3F= zUcEF==vrQTw(EA$rR_vlzQguN5eQzJ;mv?iJcz&Dof$4u6{H&ng)=U$BBA^l0HA{=-h+>g;=45aB=`AOIvCZ->e)v<1psm{ZlS$FsouJD{~&*?okP( zvz_YNrPdvz=6pu}!WQ}>y%j+qtC6dj;HSLlwD}A8@r*hA&W{9pw8TZ@A?z=h>|Z>WQeP+fL8_I(B>w;~(tI#P|w?vFHEQ+;I`raS<(Om@)tc zaer0>#_16EoaEyrRaqzax5WLKA>yuYE&T+h{oklr%*f{+G3?F0{I5rB*n$&rAdFHQrd9aSOXcJ}Un3iCaG9`H#HgZyDOg#SlF1BYjqIUEGa{b$TL^5?dK zR+~m!xWuMNfrrl?v-$k&vFr1PF~4W5609Wc*)>K(3Shwh#()%jdQ`313_aVV2@*N4=X8MaV{mS;d90 z_ds~_pK%xoJ(nEvcHgeUy7*haP|T0dPDA}*_W3^}N4|P?+A9;Y#=naGsaW!CQG7HQ zO5rbW!-i2B`#i9-h5NK;wJ1Y3WxLZm6`xJ|n$NiN$A8WsCWpyG>HpLJPsDbg({3o6 zjX%rFgTMask5ynJw4#Q!?UPuYKg1n}UrUCAL-1CVk<|G+zF-f{5*onfztKhknB%Vw zVJhrJJ*%*u@j&^nZO>`BXq9i#f@H8_T~huV(j3OKB{4Y1Pyh4jrmQ*pb4B)0(Bl6r zne-g!^q(VLV*l3`FO-Jms)23Q_P<#O(>45A*B7CS`dj~Bq72j3=4sJ?ktu?ZE&got zC##cygMYmMhhP*UwT>V+=`;C{ryG4?VY9wf7J~Bk41_HbD9^FguX(lj-yGq>`%AUB zFqiQEsfPPJEZpwR(KF@S|NJuXT=9bd>^;!GXD{qN`-kVaob2Uo{|x#ohMuEvFZ|hd z;|33T|2mW~4pw4xzJcvBAM_l$e@#uA=h%`iIz9dyy!+ge>)FR#@W-(f|HSxT6-8hp zb?+I9QEI { switch (chain) { @@ -25,7 +27,7 @@ export const v2AgentsBase: AgentMetadata[] = [ name: 'Max APY Agent', address: KnownAgents.MAX_APY, strategyDescription: 'Rebalance every 8 hours, always move to the highest APY', - image: '/placeholder-agent.png', + image: agentApyImage, }, ]; diff --git a/src/utils/morpho.ts b/src/utils/morpho.ts index f96f435b..44a743e0 100644 --- a/src/utils/morpho.ts +++ b/src/utils/morpho.ts @@ -1,7 +1,6 @@ import { Address, decodeAbiParameters, encodeAbiParameters, keccak256, parseAbiParameters, zeroAddress } from 'viem'; import { SupportedNetworks } from './networks'; import { MarketParams, UserTxTypes } from './types'; -import abi from '@/abis/permit2'; // appended to the end of datahash to identify a monarch tx export const MONARCH_TX_IDENTIFIER = 'beef'; @@ -201,23 +200,42 @@ export function parseCapIdParams(idParams: string): { // Try to decode as market cap: (string, address, marketParams) // Pattern: ("this/marketParams", adapterAddress, marketParams) try { - const marketParamsType = parseAbiParameters('(address loanToken, address collateralToken, address oracle, address irm, uint256 lltv)'); + const marketParamsComponents = parseAbiParameters( + '(address loanToken, address collateralToken, address oracle, address irm, uint256 lltv)', + ); const decoded = decodeAbiParameters( [ { type: 'string' }, { type: 'address' }, - { type: 'tuple', components: marketParamsType } + { type: 'tuple', components: marketParamsComponents }, ], - idParams as `0x${string}` + idParams as `0x${string}`, ); if (decoded[0] === 'this/marketParams') { - const marketParamsBlock = decoded[2] as any; - const marketParams = marketParamsBlock[0] as any as MarketParams; + const paramsTuple = decoded[2] as unknown as readonly [ + Address, + Address, + Address, + Address, + bigint, + ]; + + const marketParams: MarketParams = { + loanToken: paramsTuple[0], + collateralToken: paramsTuple[1], + oracle: paramsTuple[2], + irm: paramsTuple[3], + lltv: paramsTuple[4], + }; - // Create a market ID hash from the market params - const marketId = keccak256(encodeAbiParameters(marketParamsType, [marketParams])); + const marketId = keccak256( + encodeAbiParameters( + [{ type: 'tuple', components: marketParamsComponents }], + [paramsTuple], + ), + ); return { type: 'market', From fc5eb3e552d407b136cfbacd1fcbd364d81d26b0 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 22 Oct 2025 16:22:37 +0800 Subject: [PATCH 26/29] fix: details --- .../components/VaultMarketAllocations.tsx | 10 +++------- .../components/allocations/CollateralView.tsx | 2 +- .../components/allocations/MarketView.tsx | 2 +- app/positions/components/SuppliedMarketsDetail.tsx | 2 +- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx index 182c81fc..673e5fa9 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx @@ -102,14 +102,10 @@ export function VaultMarketAllocations({ const viewDescription = useMemo(() => { if (viewMode === 'collateral') { - return hasAnyAllocations - ? `Your ${vaultAssetSymbol} deposits are distributed across lending markets, each accepting different collateral types. This view shows how your supply is backed by each collateral asset.` - : `This view will show how your ${vaultAssetSymbol} deposits are backed by different collateral types once assets are allocated.`; + return `See how your ${vaultAssetSymbol} supply is collateralized across assets shared by multiple markets.`; } - return hasAnyAllocations - ? `Your ${vaultAssetSymbol} deposits are actively earning yield across multiple lending markets. Each market has unique terms including APY, collateral requirements, and risk parameters.` - : `This view will show how your ${vaultAssetSymbol} deposits are distributed across different lending markets once assets are allocated.`; - }, [viewMode, hasAnyAllocations, vaultAssetSymbol]); + return `See where your ${vaultAssetSymbol} supply is deployed across markets.`; + }, [viewMode, vaultAssetSymbol]); if (isLoading) { return ( diff --git a/app/autovault/[chainId]/[vaultAddress]/components/allocations/CollateralView.tsx b/app/autovault/[chainId]/[vaultAddress]/components/allocations/CollateralView.tsx index 9abf3dbe..f4e89060 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/allocations/CollateralView.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/allocations/CollateralView.tsx @@ -66,7 +66,7 @@ export function CollateralView({

diff --git a/app/positions/components/SuppliedMarketsDetail.tsx b/app/positions/components/SuppliedMarketsDetail.tsx index 6f5f20cc..bc5bb043 100644 --- a/app/positions/components/SuppliedMarketsDetail.tsx +++ b/app/positions/components/SuppliedMarketsDetail.tsx @@ -47,7 +47,7 @@ function MarketRow({ /> -
MarketSelectId
+ No markets found
-
- {chainImg && icon} - -
+
-
- {warningsWithDetail.length > 0 ? ( - } - placement="top" - > -
- -
-
- ) : ( -
- )} -
- - {position.market.uniqueKey.slice(2, 8)} - +
diff --git a/docs/Styling.md b/docs/Styling.md index fb63a16b..4ae7c530 100644 --- a/docs/Styling.md +++ b/docs/Styling.md @@ -217,6 +217,30 @@ import { MarketIdentity, MarketIdentityMode, MarketIdentityFocus } from '@/compo /> ``` +**Wide Layout:** + +The `wide` prop changes the layout to use `justify-between` with full width, perfect for table cells: + +- **Left side**: Token icon(s) + symbol(s) +- **Middle**: LLTV badge (if enabled) +- **Right side**: Oracle badge (if enabled) + +Works with all three modes (Normal, Focused, Minimum). Use in table cells with a fixed width for consistent alignment: + +```tsx + + + - + Date: Wed, 22 Oct 2025 16:15:18 +0800 Subject: [PATCH 25/29] feat: MarketIdentity and MarketIdBadge --- .../components/DepositToVaultModal.tsx | 4 +- .../components/TotalSupplyCard.tsx | 2 - .../components/VaultAllocatorCard.tsx | 5 +- .../components/VaultCollateralsCard.tsx | 2 +- .../components/VaultInitializationModal.tsx | 5 +- .../components/VaultMarketAllocations.tsx | 6 +- .../components/VaultSettingsModal.tsx | 5 +- .../components/allocations/CollateralView.tsx | 2 +- .../components/allocations/MarketView.tsx | 12 +- .../components/settings/AddMarketCapModal.tsx | 4 +- .../components/settings/AgentListItem.tsx | 2 +- .../components/settings/AgentsTab.tsx | 2 +- .../components/settings/CurrentCaps.tsx | 21 +- .../components/settings/EditCaps.tsx | 60 +--- .../components/settings/MarketCapsTable.tsx | 12 +- .../components/settings/Tooltips.tsx | 2 +- .../components/settings/types.ts | 2 +- .../[chainId]/[vaultAddress]/content.tsx | 46 ++- app/markets/components/MarketTableBody.tsx | 2 +- .../components/SuppliedMarketsDetail.tsx | 81 +---- src/components/MarketIdBadge.tsx | 4 +- src/components/MarketIdentity.tsx | 280 +++++++++--------- src/components/TokenIcon.tsx | 2 +- .../common/MarketSelectionModal.tsx | 17 +- .../common/MarketsTableWithSameLoanAsset.tsx | 27 +- src/data-sources/morpho-api/v2-vaults.ts | 6 +- src/hooks/useVaultPage.ts | 10 +- src/hooks/useVaultV2.ts | 4 +- src/hooks/useVaultV2Data.ts | 2 +- src/imgs/agent/agent-apy.png | Bin 0 -> 79575 bytes src/imgs/agent/agent-liquid.png | Bin 0 -> 82865 bytes src/utils/monarch-agent.ts | 4 +- src/utils/morpho.ts | 34 ++- 33 files changed, 308 insertions(+), 359 deletions(-) create mode 100644 src/imgs/agent/agent-apy.png create mode 100644 src/imgs/agent/agent-liquid.png diff --git a/app/autovault/[chainId]/[vaultAddress]/components/DepositToVaultModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/DepositToVaultModal.tsx index 3ead8bc3..2218b57b 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/DepositToVaultModal.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/DepositToVaultModal.tsx @@ -4,8 +4,8 @@ import { Address } from 'viem'; import { useAccount } from 'wagmi'; import { Button } from '@/components/common'; import Input from '@/components/Input/Input'; -import { TokenIcon } from '@/components/TokenIcon'; import AccountConnect from '@/components/layout/header/AccountConnect'; +import { TokenIcon } from '@/components/TokenIcon'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useVaultV2Deposit } from '@/hooks/useVaultV2Deposit'; import { formatBalance } from '@/utils/balance'; @@ -32,7 +32,7 @@ export function DepositToVaultModal({ onClose, onSuccess, }: DepositToVaultModalProps): JSX.Element { - const { address: account, isConnected } = useAccount(); + const { isConnected } = useAccount(); const [usePermit2Setting] = useLocalStorage('usePermit2', true); const { diff --git a/app/autovault/[chainId]/[vaultAddress]/components/TotalSupplyCard.tsx b/app/autovault/[chainId]/[vaultAddress]/components/TotalSupplyCard.tsx index 41e83d25..68f1271d 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/TotalSupplyCard.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/TotalSupplyCard.tsx @@ -1,9 +1,7 @@ import React, { useMemo, useState } from 'react'; import { PlusIcon } from '@radix-ui/react-icons'; import { Address } from 'viem'; -import { useReadContract } from 'wagmi'; import { TokenIcon } from '@/components/TokenIcon'; -import { vaultv2Abi } from '@/abis/vaultv2'; import { formatBalance } from '@/utils/balance'; import { DepositToVaultModal } from './DepositToVaultModal'; diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultAllocatorCard.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultAllocatorCard.tsx index 479c7517..a28c139e 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultAllocatorCard.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultAllocatorCard.tsx @@ -2,15 +2,13 @@ import { Card, CardBody, CardHeader, Tooltip } from '@heroui/react'; import { GearIcon } from '@radix-ui/react-icons'; import { GrStatusGood } from 'react-icons/gr'; import { Address } from 'viem'; +import { AgentIcon } from '@/components/AgentIcon'; import { Spinner } from '@/components/common/Spinner'; import { TooltipContent } from '@/components/TooltipContent'; -import { SupportedNetworks } from '@/utils/networks'; import { findAgent } from '@/utils/monarch-agent'; -import { AgentIcon } from '@/components/AgentIcon'; type VaultAllocatorCardProps = { allocators: string[]; - chainId: SupportedNetworks; onManageAgents: () => void; needsSetup?: boolean; isOwner?: boolean; @@ -19,7 +17,6 @@ type VaultAllocatorCardProps = { export function VaultAllocatorCard({ allocators, - chainId, onManageAgents, needsSetup = false, isOwner = false, diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultCollateralsCard.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultCollateralsCard.tsx index 08cda6bf..59c34b05 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultCollateralsCard.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultCollateralsCard.tsx @@ -3,9 +3,9 @@ import { GearIcon } from '@radix-ui/react-icons'; import { Address } from 'viem'; import { Spinner } from '@/components/common/Spinner'; import { TokenIcon } from '@/components/TokenIcon'; +import type { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { parseCapIdParams } from '@/utils/morpho'; import { SupportedNetworks } from '@/utils/networks'; -import type { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; type VaultCollateralsCardProps = { collateralCaps: VaultV2Cap[]; diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultInitializationModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultInitializationModal.tsx index 89ca0be9..b6cce01c 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultInitializationModal.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultInitializationModal.tsx @@ -6,7 +6,6 @@ import { AddressDisplay } from '@/components/common/AddressDisplay'; import { AllocatorCard } from '@/components/common/AllocatorCard'; import { Spinner } from '@/components/common/Spinner'; import { useDeployMorphoMarketV1Adapter } from '@/hooks/useDeployMorphoMarketV1Adapter'; -import { useMorphoMarketV1Adapters } from '@/hooks/useMorphoMarketV1Adapters'; import { useVaultV2 } from '@/hooks/useVaultV2'; import { v2AgentsBase } from '@/utils/monarch-agent'; import { getMorphoAddress } from '@/utils/morpho'; @@ -95,7 +94,7 @@ function AdapterCapStep({
- + Adapter cap (%) { setStatusVisible(true); await deploy(); - await refetchMarketAdapter(); + void refetchMarketAdapter(); }, [deploy, refetchMarketAdapter]); diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx index 71bf2b40..182c81fc 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx @@ -1,18 +1,16 @@ import { useMemo, useState } from 'react'; -import { Address } from 'viem'; import { Switch } from '@heroui/react'; -import { MdOutlineAccountBalance } from 'react-icons/md'; import { HiOutlineCube } from 'react-icons/hi'; +import { MdOutlineAccountBalance } from 'react-icons/md'; import { Spinner } from '@/components/common/Spinner'; import { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { AllocationData } from '@/hooks/useAllocations'; import { useMarkets } from '@/hooks/useMarkets'; import { parseCapIdParams } from '@/utils/morpho'; -import { findToken } from '@/utils/tokens'; import { SupportedNetworks } from '@/utils/networks'; +import { findToken } from '@/utils/tokens'; import { CollateralView } from './allocations/CollateralView'; import { MarketView } from './allocations/MarketView'; -import { formatBalance } from '@/utils/balance'; type VaultMarketAllocationsProps = { totalAssets?: bigint diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx index eda4ce4c..0da90183 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx @@ -1,13 +1,12 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { ReloadIcon } from '@radix-ui/react-icons'; import { createPortal } from 'react-dom'; import { LuX } from 'react-icons/lu'; -import { ReloadIcon } from '@radix-ui/react-icons'; import { Address } from 'viem'; import { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; +import { CapData } from '@/hooks/useVaultV2Data'; import { SupportedNetworks } from '@/utils/networks'; import { GeneralTab, AgentsTab, CapsTab, SettingsTab } from './settings'; -import { CapData } from '@/hooks/useVaultV2Data'; -import { useMorphoMarketV1Adapters } from '@/hooks/useMorphoMarketV1Adapters'; const TABS: { id: SettingsTab; label: string }[] = [ { id: 'general', label: 'General' }, diff --git a/app/autovault/[chainId]/[vaultAddress]/components/allocations/CollateralView.tsx b/app/autovault/[chainId]/[vaultAddress]/components/allocations/CollateralView.tsx index 0099e54a..9abf3dbe 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/allocations/CollateralView.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/allocations/CollateralView.tsx @@ -40,7 +40,7 @@ export function CollateralView({
Collateral Amount Allocation
Liquidity Amount Allocation
- {position.market.collateralAsset ? ( -
- - {position.market.collateralAsset.symbol} -
- ) : ( - 'N/A' - )} -
-
- -
-
- {formatBalance(position.market.lltv, 16)}% + + {formatReadable(position.market.state.supplyApy * 100)}% @@ -236,9 +183,7 @@ export function SuppliedMarketsDetail({
MarketCollateralOracleLLTV Collateral & Parameters APY Supplied % of Portfolio @@ -506,10 +504,10 @@ export function MarketsTableWithSameLoanAsset({ } // Sort - filtered.sort((a, b) => { + filtered.sort((a, b) => { let comparison = 0; switch (sortColumn) { - case SortColumn.Market: + case SortColumn.MarketName: comparison = a.market.collateralAsset.symbol.localeCompare( b.market.collateralAsset.symbol, ); @@ -525,6 +523,9 @@ export function MarketsTableWithSameLoanAsset({ comparison = Number(a.market.state.liquidityAssets) - Number(b.market.state.liquidityAssets); break; + case SortColumn.Risk: + comparison = 0; + break; } return comparison * sortDirection; }); @@ -562,8 +563,8 @@ export function MarketsTableWithSameLoanAsset({ chainId={market.morphoBlue.chain.id} mode={MarketIdentityMode.Focused} focus={MarketIdentityFocus.Collateral} - showLltv={true} - showOracle={true} + showLltv + showOracle iconSize={20} showExplorerLink={false} /> @@ -607,7 +608,7 @@ export function MarketsTableWithSameLoanAsset({ Id - {hasAllocation ? `${percentage.toFixed(2)}%` : '—'} + {hasAllocation ? `${percentage.toFixed(2)}%` : '-'} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/allocations/MarketView.tsx b/app/autovault/[chainId]/[vaultAddress]/components/allocations/MarketView.tsx index f7806f6f..c3f038f1 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/allocations/MarketView.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/allocations/MarketView.tsx @@ -102,7 +102,7 @@ export function MarketView({ {/* Allocation Percentage */} - {hasAllocation ? `${percentage.toFixed(2)}%` : '—'} + {hasAllocation ? `${percentage.toFixed(2)}%` : '-'} + Date: Wed, 22 Oct 2025 17:08:31 +0800 Subject: [PATCH 27/29] chore: feedbacks --- .../components/DepositToVaultModal.tsx | 4 +- .../components/TotalSupplyCard.tsx | 2 +- .../components/VaultAssetMovements.tsx | 70 ------------------- .../components/settings/AgentsTab.tsx | 5 +- src/components/common/AddressDisplay.tsx | 49 +++++++------ .../common/MarketSelectionModal.tsx | 2 +- src/utils/morpho.ts | 28 ++------ 7 files changed, 40 insertions(+), 120 deletions(-) delete mode 100644 app/autovault/[chainId]/[vaultAddress]/components/VaultAssetMovements.tsx diff --git a/app/autovault/[chainId]/[vaultAddress]/components/DepositToVaultModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/DepositToVaultModal.tsx index 2218b57b..f6a1ddaf 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/DepositToVaultModal.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/DepositToVaultModal.tsx @@ -127,7 +127,7 @@ export function DepositToVaultModal({ {!permit2Authorized || (!usePermit2Setting && !isApproved) ? ( ) : (