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/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..25f8fcd2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,40 @@ +# 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. 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. + +## 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. + +## 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. + +## 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/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/[chainId]/[vaultAddress]/components/DepositToVaultModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/DepositToVaultModal.tsx new file mode 100644 index 00000000..f6a1ddaf --- /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 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'; +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 { 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..a812fdc0 --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/TotalSupplyCard.tsx @@ -0,0 +1,90 @@ +import React, { useMemo, useState } from 'react'; +import { PlusIcon } from '@radix-ui/react-icons'; +import { Address } from 'viem'; +import { TokenIcon } from '@/components/TokenIcon'; +import { formatBalance } from '@/utils/balance'; +import { DepositToVaultModal } from './DepositToVaultModal'; + +type VaultTotalAssetsCardProps = { + totalAssets?: bigint + tokenDecimals?: number; + tokenSymbol?: string; + assetAddress?: Address; + chainId: number; + vaultAddress: Address; + vaultName: string; + onRefresh?: () => void; +}; + +export function TotalSupplyCard({ + tokenDecimals, + tokenSymbol, + assetAddress, + chainId, + vaultAddress, + vaultName, + totalAssets, + onRefresh, +}: VaultTotalAssetsCardProps): JSX.Element { + const [showDepositModal, setShowDepositModal] = useState(false); + + + 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); + 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/VaultAllocatorCard.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultAllocatorCard.tsx new file mode 100644 index 00000000..a28c139e --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultAllocatorCard.tsx @@ -0,0 +1,85 @@ +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 { findAgent } from '@/utils/monarch-agent'; + +type VaultAllocatorCardProps = { + allocators: string[]; + onManageAgents: () => void; + needsSetup?: boolean; + isOwner?: boolean; + isLoading?: boolean; +}; + +export function VaultAllocatorCard({ + allocators, + 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) => findAgent(allocatorAddress)) + .filter((agent): agent is NonNullable => agent !== undefined) + .map((agent) => ( + + ))} +
+ ) : ( +
+
+ + 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..59c34b05 --- /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 type { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; +import { parseCapIdParams } from '@/utils/morpho'; +import { SupportedNetworks } from '@/utils/networks'; + +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/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]/components/VaultInitializationModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultInitializationModal.tsx new file mode 100644 index 00000000..b6cce01c --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultInitializationModal.tsx @@ -0,0 +1,458 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@heroui/react'; +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 { useVaultV2 } from '@/hooks/useVaultV2'; +import { v2AgentsBase } from '@/utils/monarch-agent'; +import { getMorphoAddress } from '@/utils/morpho'; +import { SupportedNetworks, getNetworkConfig } from '@/utils/networks'; + +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', 'adapter-cap', 'finalize', 'agents'] 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 AdapterCapStep({ + adapterAddress, + adapterCapRelative, + onSetAdapterCap, +}: { + adapterAddress: Address; + adapterCapRelative: string; + onSetAdapterCap: (relativeCap: string) => void; +}) { + const handleCapChange = (e: React.ChangeEvent) => { + const value = e.target.value; + // Allow empty or valid numbers between 0-100 + if (value === '' || (!isNaN(parseFloat(value)) && parseFloat(value) >= 0 && parseFloat(value) <= 100)) { + onSetAdapterCap(value); + } + }; + + return ( +
+

+ Set a maximum allocation cap for the Morpho adapter. This controls the total percentage of vault assets that can be allocated through this adapter. +

+
+
+ Adapter address + +
+
+ Adapter cap (%) + +

+ Maximum percentage of vault assets that can be allocated via this adapter (0-100%) +

+
+
+
+ ); +} + +function FinalizeSetupStep({ + adapter, + registryAddress, + isInitializing, +}: { + adapter: Address; + registryAddress: Address; + isInitializing: boolean; +}) { + const adapterIsReady = adapter !== ZERO_ADDRESS; + + return ( +
+
+ {isInitializing && } + + 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.
  • +
+
+
+ ); +} + +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, + 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; + onAdapterConfigured: () => void; +}) { + const [stepIndex, setStepIndex] = useState(0); + const [statusVisible, setStatusVisible] = useState(false); + const [selectedAgent, setSelectedAgent] = useState
(null); + const [adapterCapRelative, setAdapterCapRelative] = useState('100'); + 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 { + completeInitialization, + isInitializing, + } = useVaultV2({ + vaultAddress, + chainId, + }); + + + const adapterDetected = marketAdapter !== ZERO_ADDRESS; + + const { deploy, isDeploying, canDeploy } = useDeployMorphoMarketV1Adapter({ + vaultAddress, + chainId, + morphoAddress, + }); + + const handleDeploy = useCallback(async () => { + setStatusVisible(true); + await deploy(); + void refetchMarketAdapter(); + }, [deploy, refetchMarketAdapter]); + + + const handleCompleteInitialization = useCallback(async () => { + if (marketAdapter === ZERO_ADDRESS || registryAddress === ZERO_ADDRESS) return; + + try { + const success = await completeInitialization( + registryAddress, + marketAdapter, + selectedAgent ?? undefined, + ); + if (!success) { + return; + } + onAdapterConfigured(); + onClose(); + } catch (error) { + console.error('Failed to complete initialization', error); + } + }, [ + completeInitialization, + onAdapterConfigured, + onClose, + registryAddress, + selectedAgent, + marketAdapter, + ]); + + useEffect(() => { + if (!isOpen) { + setStepIndex(0); + setStatusVisible(false); + setSelectedAgent(null); + setAdapterCapRelative('100'); + } + }, [isOpen]); + + useEffect(() => { + if (adapterDetected && stepIndex === 0) { + setStepIndex(1); + } + }, [adapterDetected, stepIndex]); + + const stepTitle = useMemo(() => { + switch (currentStep) { + case 'deploy': + return 'Deploy Morpho Market adapter'; + case 'adapter-cap': + return 'Set adapter allocation cap'; + case 'finalize': + return 'Configure vault registry'; + case 'agents': + return 'Choose an agent (optional)'; + default: + return ''; + } + }, [currentStep]); + + const canProceedToAgents = adapterDetected && registryAddress !== ZERO_ADDRESS; + const showLoading = statusVisible && (isDeploying || marketAdapterLoading); + const showBackButton = stepIndex > 0 && stepIndex < 3; + const canProceedFromAdapterCap = adapterCapRelative !== '' && parseFloat(adapterCapRelative) > 0; + + const renderCta = () => { + // Step 0: Deploy adapter + if (stepIndex === 0) { + return ( + + ); + } + + // Step 1: Set adapter cap + if (stepIndex === 1) { + return ( + + ); + } + + // Step 2: Finalize setup -> move to agent selection + if (stepIndex === 2) { + return ( + + ); + } + + // Step 3: Agent selection -> complete with optional agent + return ( + <> + + + + ); + }; + + return ( + + + +
+

{stepTitle}

+

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

+
+
+ + + {currentStep === 'deploy' && ( + + )} + {currentStep === 'adapter-cap' && ( + + )} + {currentStep === 'finalize' && ( + + )} + {currentStep === 'agents' && ( + + )} + + + + {showBackButton && ( + + )} + {renderCta()} + +
+ +
+
+
+ ); +} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx new file mode 100644 index 00000000..65b321a8 --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultMarketAllocations.tsx @@ -0,0 +1,177 @@ +import { useMemo, useState } from 'react'; +import { Switch } from '@heroui/react'; +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 { SupportedNetworks } from '@/utils/networks'; +import { findToken } from '@/utils/tokens'; +import { CollateralView } from './allocations/CollateralView'; +import { MarketView } from './allocations/MarketView'; + +type VaultMarketAllocationsProps = { + totalAssets?: bigint + marketCaps: VaultV2Cap[]; + collateralCaps: VaultV2Cap[]; + allocations: AllocationData[]; + vaultAssetSymbol: string; + vaultAssetDecimals: number; + chainId: SupportedNetworks; + isLoading: boolean; +}; + +type ViewMode = 'collateral' | 'market'; + +function ViewIcon({ isSelected, className }: { isSelected: boolean; className?: string }) { + return isSelected ? ( + + ) : ( + + ); +} + +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, allocations]) + + const hasAnyAllocations = useMemo(() => totalAllocation > 0n, [totalAllocation]) + + const viewDescription = useMemo(() => { + if (viewMode === 'collateral') { + return `See how your ${vaultAssetSymbol} supply is collateralized across assets shared by multiple markets.`; + } + return `See where your ${vaultAssetSymbol} supply is deployed across markets.`; + }, [viewMode, vaultAssetSymbol]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (collateralData.length === 0 && marketData.length === 0) { + return ( +
+ No markets configured yet. Configure caps in settings to start allocating assets. +
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+

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

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

+ {viewDescription} +

+
+
+ {/* Content */} + {viewMode === 'collateral' ? ( + + ) : ( + + )} +
+ ); +} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx new file mode 100644 index 00000000..0da90183 --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultSettingsModal.tsx @@ -0,0 +1,237 @@ +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 { 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'; + +const TABS: { id: SettingsTab; label: string }[] = [ + { id: 'general', label: 'General' }, + { id: 'agents', label: 'Agent' }, + { id: 'caps', label: 'Caps' }, +]; + +type VaultSettingsModalProps = { + isOpen: boolean; + onClose: () => void; + initialTab?: SettingsTab; + isOwner: boolean; + onUpdateMetadata: (values: { name?: string; symbol?: string }) => Promise; + updatingMetadata: boolean; + defaultName: string; + defaultSymbol: string; + currentName: string; + currentSymbol: string; + owner?: string; + curator?: string; + allocators: string[]; + sentinels?: string[]; + chainId: SupportedNetworks; + vaultAsset?: Address; + marketAdapter: Address; // the deploy morpho market v1 adapter + capData?: CapData; + onSetAllocator: (allocator: Address, isAllocator: boolean) => Promise; + updateCaps: (caps: VaultV2Cap[]) => Promise; + isUpdatingAllocator: boolean; + isUpdatingCaps: boolean; + onRefresh?: () => void; + isRefreshing?: boolean; +}; + +export function VaultSettingsModal({ + isOpen, + onClose, + initialTab = 'general', + isOwner, + onUpdateMetadata, + updatingMetadata, + defaultName, + defaultSymbol, + currentName, + currentSymbol, + owner, + curator, + allocators, + sentinels = [], + chainId, + vaultAsset, + marketAdapter, + capData = undefined, + onSetAllocator, + updateCaps, + isUpdatingAllocator, + isUpdatingCaps, + onRefresh, + isRefreshing = false, +}: VaultSettingsModalProps) { + const [activeTab, setActiveTab] = useState(initialTab); + const [mounted, setMounted] = useState(false); + const wasOpenRef = useRef(false); + + // Reset to initial tab when modal opens + useEffect(() => { + const wasOpen = wasOpenRef.current; + + if (isOpen && !wasOpen) { + setActiveTab(initialTab); + } + + wasOpenRef.current = isOpen; + }, [initialTab, isOpen]); + + // Handle mounting + useEffect(() => { + setMounted(true); + }, []); + + // Prevent body scroll when modal is open + useEffect(() => { + if (!isOpen) return; + const originalOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = originalOverflow; + }; + }, [isOpen]); + + // Handle ESC key + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && isOpen) { + onClose(); + } + }; + + if (isOpen) { + window.addEventListener('keydown', handleKeyDown); + } + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen, onClose]); + + const handleTabChange = useCallback((tab: SettingsTab) => { + setActiveTab(tab); + }, []); + + const renderActiveTab = () => { + switch (activeTab) { + case 'general': + return ( + + ); + case 'agents': + return ( + + ); + case 'caps': + return ( + + ); + default: + return null; + } + }; + + if (!mounted || !isOpen) { + return null; + } + + return createPortal( +
+
event.stopPropagation()} + > +
+ {/* Header */} +
+

Vault Settings

+
+ {onRefresh && ( + + )} + +
+
+ + {/* Content */} +
+ {/* Sidebar */} + + + {/* Tab Content */} +
+
{renderActiveTab()}
+
+
+
+
+
, + document.body, + ); +} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultSummaryMetrics.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultSummaryMetrics.tsx new file mode 100644 index 00000000..41c5dfbc --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultSummaryMetrics.tsx @@ -0,0 +1,10 @@ +import { PropsWithChildren } from 'react'; + +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/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..f4e89060 --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/allocations/CollateralView.tsx @@ -0,0 +1,84 @@ +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; + const hasAllocation = item.allocation > 0n; + + 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 new file mode 100644 index 00000000..c3f038f1 --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/allocations/MarketView.tsx @@ -0,0 +1,122 @@ +import { MarketIdentity, MarketIdentityFocus } from '@/components/MarketIdentity'; +import { formatBalance, formatReadable } from '@/utils/balance'; +import { SupportedNetworks } from '@/utils/networks'; +import { Market } from '@/utils/types'; +import { formatAllocationAmount, calculateAllocationPercent } from '@/utils/vaultAllocation'; +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 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 Info Column */} + + + {/* APY */} + + + {/* Total Supply */} + + + {/* Liquidity */} + + + {/* Allocation Amount */} + + + {/* Allocation Percentage */} + + + {/* Pie Chart */} + + + ); + })} + +
MarketAPYTotal SupplyLiquidityAmountAllocation +
+ + + {supplyApy}% + + {totalSupply} + + {liquidity} + + + {hasAllocation + ? `${formatAllocationAmount(allocation, vaultAssetDecimals)} ${vaultAssetSymbol}` + : '-'} + + + + {hasAllocation ? `${percentage.toFixed(2)}%` : '-'} + + +
+ +
+
+
+ ); +} 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..b1a7d599 --- /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 { SupportedNetworks } from '@/utils/networks'; +import { Market } from '@/utils/types'; + +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/AgentListItem.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentListItem.tsx new file mode 100644 index 00000000..0afd1ab0 --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentListItem.tsx @@ -0,0 +1,20 @@ +import { Address } from 'viem'; +import { AgentIcon } from '@/components/AgentIcon'; +import { AddressDisplay } from '@/components/common/AddressDisplay'; +import { findAgent } from '@/utils/monarch-agent'; + +type AgentListItemProps = { + address: Address; +}; + +export function AgentListItem({ address }: AgentListItemProps) { + const agent = findAgent(address); + + return ( +
+ + {agent && {agent.name}} + +
+ ); +} 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..afbb119b --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentsTab.tsx @@ -0,0 +1,269 @@ +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 { useMarketNetwork } from '@/hooks/useMarketNetwork'; +import { v2AgentsBase } from '@/utils/monarch-agent'; +import { AgentListItem } from './AgentListItem'; +import { AgentsTabProps } from './types'; + +export function AgentsTab({ + isOwner, + owner, + curator, + allocators, + 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); + try { + await onSetAllocator(allocator, true); + } finally { + setAllocatorToAdd(null); + } + }, + [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, needSwitchChain, switchToNetwork], + ); + + 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) => ( +
+ +
+ ))} +
+ ) + ) : ( + // Edit mode +
+ {allocators.length > 0 && ( +
+

Current Allocators

+ {allocators.map((address) => ( +
+ + +
+ ))} +
+ )} + + {availableAllocators.length > 0 && ( +
+

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

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

{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/CapsTab.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/CapsTab.tsx new file mode 100644 index 00000000..07b5c10d --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/CapsTab.tsx @@ -0,0 +1,43 @@ +import { useState } from 'react'; +import { CurrentCaps } from './CurrentCaps'; +import { EditCaps } from './EditCaps'; +import { CapsTabProps } from './types'; + +export function CapsTab({ + isOwner, + chainId, + vaultAsset, + adapterAddress, + existingCaps, + updateCaps, + isUpdatingCaps, +}: CapsTabProps) { + const [isEditing, setIsEditing] = useState(false); + + return isEditing ? ( + setIsEditing(false)} + onSave={async (caps) => { + const success = await updateCaps(caps); + if (success) { + setIsEditing(false); + } + return success; + }} + /> + ) : ( + setIsEditing(true)} + vaultAsset={vaultAsset} + chainId={chainId} + /> + ); +} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentCaps.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentCaps.tsx new file mode 100644 index 00000000..0c5a007d --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/CurrentCaps.tsx @@ -0,0 +1,331 @@ +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 { CapData } from '@/hooks/useVaultV2Data'; +import { parseCapIdParams } from '@/utils/morpho'; +import { findToken } from '@/utils/tokens'; +import { MarketCapsTable } from './MarketCapsTable'; +import { CollateralCapTooltip } from './Tooltips'; + +type CurrentCapsProps = { + existingCaps?: CapData; + isOwner: boolean; + onStartEdit: () => void; + vaultAsset?: Address; + chainId: number +}; + +export function CurrentCaps({ + existingCaps, + isOwner, + onStartEdit, + chainId, + vaultAsset +}: CurrentCapsProps) { + const { markets, loading: marketsLoading } = useMarkets(); + const [expandedCollaterals, setExpandedCollaterals] = useState>(new Set()); + + 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 || + 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 === 'market' && parsed.marketParams?.collateralToken) { + 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 grouped; + }, [existingCaps, markets]); + + // 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) || []; + + // 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, + collateralToken: parsed.collateralToken ?? 'Unknown', + collateralSymbol, + capPercent: (parseFloat(cap.relativeCap) / 1e16).toFixed(2), + markets: marketsForCollateral, + }; + }) || []; + }, [existingCaps, marketCapsByCollateral, chainId]); + + const toggleCollateral = (collateralAddr: string) => { + setExpandedCollaterals((prev) => { + const next = new Set(prev); + if (next.has(collateralAddr)) { + next.delete(collateralAddr); + } else { + next.add(collateralAddr); + } + return next; + }); + }; + + if (marketsLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+

Cap Settings

+

+ Define allocation limits across markets and collaterals to control agent behavior. +

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

No caps configured yet

+

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

+
+ ) : ( +
+ {/* Collateral Caps */} + {collateralCapsWithMarkets.length > 0 && ( +
+
+

Collateral Caps ({collateralCapsWithMarkets.length})

+ +
+ + {/* Column Headers */} +
+
Collateral
+
Relative %
+
Absolute {vaultAssetToken?.symbol ? `(${vaultAssetToken.symbol})` : ''}
+
+ +
+ {collateralCapsWithMarkets.map((item) => { + const collateralAddr = item.collateralToken.toLowerCase(); + const isExpanded = expandedCollaterals.has(collateralAddr); + const hasMarkets = item.markets.length > 0; + + return ( +
+ {/* Collateral Cap Row */} + + + {/* Market Caps - Expandable */} + {isExpanded && hasMarkets && ( +
+
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} + /> +
+ )} +
+ ); + })} +
+
+ )} + + {/* 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?.collateralToken) { + const collateralAddr = parsed.marketParams.collateralToken.toLowerCase(); + return !collateralsWithCaps.has(collateralAddr); + } + return false; + }); + + if (orphanedMarkets.length === 0) return null; + + return ( +
+
+

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 ( +
+
+ +
+
+ Caps: +
+
+ + {(parseFloat(cap.relativeCap) / 1e16).toFixed(2)}% + + relative +
+
+ {formatAbsoluteCap(cap.absoluteCap)} + absolute +
+
+
+
+ ); + })} +
+
+ ); + })() + )} +
+ )} +
+ ); +} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/EditCaps.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/EditCaps.tsx new file mode 100644 index 00000000..faab78dd --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/EditCaps.tsx @@ -0,0 +1,579 @@ +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 { CapData } from '@/hooks/useVaultV2Data'; +import { getMarketCapId, getCollateralCapId, getAdapterCapId, parseCapIdParams } from '@/utils/morpho'; +import { SupportedNetworks } from '@/utils/networks'; +import { Market } from '@/utils/types'; +import { AddMarketCapModal } from './AddMarketCapModal'; +import { MarketCapsTable } from './MarketCapsTable'; +import { CollateralCapTooltip, MarketCapTooltip } from './Tooltips'; + +type EditCapsProps = { + existingCaps?: CapData; + vaultAsset?: Address; + chainId: SupportedNetworks; + isOwner: boolean; + isUpdating: boolean; + adapterAddress?: Address; + onCancel: () => void; + onSave: (caps: VaultV2Cap[]) => Promise; +}; + +type CollateralCapInfo = { + collateralAddress: Address; + collateralSymbol: string; + relativeCap: string; + absoluteCap: string; + existingCapId?: string; +}; + +type MarketCapInfo = { + market: Market; + relativeCap: string; + absoluteCap: string; + existingCapId?: string; +}; + +export function EditCaps({ + existingCaps, + vaultAsset, + chainId, + isOwner, + isUpdating, + adapterAddress, + onCancel, + onSave +}: EditCapsProps) { + 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 and token + const vaultAssetToken = useMemo(() => { + if (!vaultAsset) return undefined; + return findToken(vaultAsset, chainId); + }, [vaultAsset, chainId, findToken]); + + const vaultAssetDecimals = vaultAssetToken?.decimals ?? 18; + + // Filter available markets for adding + 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]); + + // 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); + + 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, + absoluteCap, + existingCapId: cap.capId, + }); + } + }); + setCollateralCaps(collateralCapsMap); + + // 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) { + 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, + absoluteCap, + existingCapId: cap.capId, + }); + } + }); + setMarketCaps(marketCapsMap); + }, [availableMarkets, chainId, existingCaps, findToken, vaultAssetDecimals]); + + const handleAddMarkets = useCallback((newMarkets: Market[]) => { + setMarketCaps((prev) => { + const next = new Map(prev); + newMarkets.forEach((market) => { + next.set(market.uniqueKey.toLowerCase(), { + 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: '', + }); + return newCaps; + } + return prevCaps; + }); + }); + return next; + }); + }, []); + + const handleUpdateMarketCap = useCallback((marketId: string, field: 'relativeCap' | 'absoluteCap', value: string) => { + setMarketCaps((prev) => { + const next = new Map(prev); + const existing = next.get(marketId.toLowerCase()); + if (existing) { + next.set(marketId.toLowerCase(), { ...existing, [field]: value }); + } + return next; + }); + }, []); + + 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 next; + }); + }, []); + + const hasChanges = useMemo(() => { + // Check for new caps + const hasNewMarkets = Array.from(marketCaps.values()).some(m => !m.existingCapId); + const hasNewCollaterals = Array.from(collateralCaps.values()).some(c => !c.existingCapId); + + // Check for modified caps + const hasModifiedMarkets = Array.from(marketCaps.values()).some(info => { + if (!info.existingCapId) return false; + const existing = existingCaps?.marketCaps.find(cap => { + const parsed = parseCapIdParams(cap.idParams); + return parsed.marketId?.toLowerCase() === info.market.uniqueKey.toLowerCase(); + }); + if (!existing) return false; + const existingRelative = (Number(BigInt(existing.relativeCap)) / 1e16).toString(); + const existingAbsoluteBigInt = BigInt(existing.absoluteCap); + const existingAbsolute = existingAbsoluteBigInt === 0n || existingAbsoluteBigInt >= maxUint128 + ? '' + : (Number(existingAbsoluteBigInt) / 10 ** vaultAssetDecimals).toString(); + return info.relativeCap !== existingRelative || info.absoluteCap !== existingAbsolute; + }); + + const hasModifiedCollaterals = Array.from(collateralCaps.values()).some(info => { + if (!info.existingCapId) return false; + const existing = existingCaps?.collateralCaps.find(cap => { + const parsed = parseCapIdParams(cap.idParams); + return parsed.collateralToken?.toLowerCase() === info.collateralAddress.toLowerCase(); + }); + if (!existing) return false; + const existingRelative = (Number(BigInt(existing.relativeCap)) / 1e16).toString(); + const existingAbsoluteBigInt = BigInt(existing.absoluteCap); + const existingAbsolute = existingAbsoluteBigInt === 0n || existingAbsoluteBigInt >= maxUint128 + ? '' + : (Number(existingAbsoluteBigInt) / 10 ** vaultAssetDecimals).toString(); + return info.relativeCap !== existingRelative || info.absoluteCap !== existingAbsolute; + }); + + return hasNewMarkets || hasNewCollaterals || hasModifiedMarkets || hasModifiedCollaterals; + }, [marketCaps, collateralCaps, existingCaps, vaultAssetDecimals]); + + const handleSave = useCallback(async () => { + if (needSwitchChain) { + switchToNetwork(); + return; + } + + if (!adapterAddress || !vaultAsset) { + console.error('Adapter address and vault asset are required'); + return; + } + + const capsToUpdate: VaultV2Cap[] = []; + + // 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 (only when changed) + for (const [, info] of collateralCaps.entries()) { + const newRelativeCapBigInt = info.relativeCap && info.relativeCap !== '' && parseFloat(info.relativeCap) > 0 + ? parseUnits(info.relativeCap, 16) + : 0n; + + const newAbsoluteCapBigInt = info.absoluteCap && info.absoluteCap !== '' && parseFloat(info.absoluteCap) > 0 + ? parseUnits(info.absoluteCap, vaultAssetDecimals) + : maxUint128; + + // Find existing cap to calculate delta + const existingCap = existingCaps?.collateralCaps.find(cap => { + const parsed = parseCapIdParams(cap.idParams); + return parsed.collateralToken?.toLowerCase() === info.collateralAddress.toLowerCase(); + }); + + const oldRelativeCap = existingCap ? BigInt(existingCap.relativeCap) : 0n; + const oldAbsoluteCap = existingCap ? BigInt(existingCap.absoluteCap) : 0n; + + // Only include if changed + if (oldRelativeCap !== newRelativeCapBigInt || oldAbsoluteCap !== newAbsoluteCapBigInt) { + const { params, id } = getCollateralCapId(info.collateralAddress); + + capsToUpdate.push({ + capId: id, + idParams: params, + relativeCap: newRelativeCapBigInt.toString(), + absoluteCap: newAbsoluteCapBigInt.toString(), + oldRelativeCap: oldRelativeCap.toString(), + oldAbsoluteCap: oldAbsoluteCap.toString(), + }); + } + } + + // Add market caps with delta calculation (only when changed) + for (const [, info] of marketCaps.entries()) { + const newRelativeCapBigInt = info.relativeCap && info.relativeCap !== '' && parseFloat(info.relativeCap) > 0 + ? parseUnits(info.relativeCap, 16) + : 0n; + + const newAbsoluteCapBigInt = info.absoluteCap && info.absoluteCap !== '' && parseFloat(info.absoluteCap) > 0 + ? parseUnits(info.absoluteCap, vaultAssetDecimals) + : 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; + + // Only include if changed + if (oldRelativeCap !== newRelativeCapBigInt || oldAbsoluteCap !== newAbsoluteCapBigInt) { + 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); + + capsToUpdate.push({ + capId: id, + idParams: params, + relativeCap: newRelativeCapBigInt.toString(), + absoluteCap: newAbsoluteCapBigInt.toString(), + oldRelativeCap: oldRelativeCap.toString(), + oldAbsoluteCap: oldAbsoluteCap.toString(), + }); + } + } + + if (capsToUpdate.length === 0) return; + + const success = await onSave(capsToUpdate); + if (success) { + // Parent handles switching back to read mode + } + }, [marketCaps, collateralCaps, needSwitchChain, switchToNetwork, onSave, adapterAddress, vaultAsset, vaultAssetDecimals, existingCaps]); + + if (marketsLoading) { + return ( +
+ +
+ ); + } + + const existingMarketIds = new Set(Array.from(marketCaps.keys())); + + return ( + <> +
+
+
+

Edit Cap Settings

+

Modify allocation limits or add new market caps

+
+
+ + {/* 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. +

+
+
+
+ ); + } + + 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 ( +
+
+
+
+

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. +

+
+
+
+ ); + } + + 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/GeneralTab.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/GeneralTab.tsx new file mode 100644 index 00000000..3e3dda6b --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/GeneralTab.tsx @@ -0,0 +1,146 @@ +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({ + isOwner, + defaultName, + defaultSymbol, + currentName, + currentSymbol, + onUpdateMetadata, + updatingMetadata, + chainId, +}: 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); + + const { needSwitchChain, switchToNetwork } = useMarketNetwork({ + targetChainId: chainId, + }); + + // 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); + + // Switch network if needed + if (needSwitchChain) { + switchToNetwork(); + return; + } + + 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, needSwitchChain, switchToNetwork]); + + 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/MarketCapsTable.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/MarketCapsTable.tsx new file mode 100644 index 00000000..e1255a20 --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/MarketCapsTable.tsx @@ -0,0 +1,153 @@ +import { maxUint128 } from 'viem'; +import { Address } from 'viem'; +import { Badge } from '@/components/common/Badge'; +import { MarketIdentity, MarketIdentityFocus } from '@/components/MarketIdentity'; +import { findToken } from '@/utils/tokens'; +import { Market } from '@/utils/types'; + +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]/components/settings/Tooltips.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/Tooltips.tsx new file mode 100644 index 00000000..e1870a9d --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/Tooltips.tsx @@ -0,0 +1,39 @@ +import { Tooltip } from "@heroui/react"; +import { InfoCircledIcon } from "@radix-ui/react-icons"; +import { TooltipContent } from "@/components/TooltipContent"; + +export function CollateralCapTooltip() { + return ( + } + > + + + ) +} + +export function MarketCapTooltip() { + return ( + } + > + + + ) +} \ No newline at end of file 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..005eaebd --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/index.ts @@ -0,0 +1,4 @@ +export { GeneralTab } from './GeneralTab'; +export { AgentsTab } from './AgentsTab'; +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 new file mode 100644 index 00000000..be98f145 --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/types.ts @@ -0,0 +1,45 @@ +import { Address } from 'viem'; +import { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; +import { CapData } from '@/hooks/useVaultV2Data'; +import { SupportedNetworks } from '@/utils/networks'; +import { Market } from '@/utils/types'; + +export type SettingsTab = 'general' | 'agents' | 'caps'; + +export type MarketCapState = { + market: Market; + relativeCap: 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; + chainId: SupportedNetworks; +}; + +export type AgentsTabProps = { + isOwner: boolean; + owner?: string; + curator?: string; + allocators: string[]; + sentinels?: string[]; + onSetAllocator: (allocator: Address, isAllocator: boolean) => Promise; + isUpdatingAllocator: boolean; + chainId: SupportedNetworks; +}; + +export type CapsTabProps = { + isOwner: boolean; + chainId: SupportedNetworks; + vaultAsset?: Address; + adapterAddress?: Address; + existingCaps?: CapData; + updateCaps: (caps: VaultV2Cap[]) => Promise; + isUpdatingCaps: boolean; +}; diff --git a/app/autovault/[chainId]/[vaultAddress]/content.tsx b/app/autovault/[chainId]/[vaultAddress]/content.tsx new file mode 100644 index 00000000..cf71f701 --- /dev/null +++ b/app/autovault/[chainId]/[vaultAddress]/content.tsx @@ -0,0 +1,320 @@ +'use client'; + +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 { Button } from '@/components/common'; +import { AddressDisplay } from '@/components/common/AddressDisplay'; +import Header from '@/components/layout/header/Header'; +import { useVaultPage } from '@/hooks/useVaultPage'; +import { getSlicedAddress } from '@/utils/address'; +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'; +import { VaultMarketAllocations } from './components/VaultMarketAllocations'; +import { VaultSettingsModal } from './components/VaultSettingsModal'; +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 chainId = useMemo(() => { + 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 { + return getNetworkConfig(chainId); + } catch (error) { + return null; + } + }, [chainId]); + + // Unified data hook - all vault data and actions in one place + const vault = useVaultPage({ + vaultAddress: vaultAddressValue, + chainId, + connectedAddress, + }); + + const { + refetchAll, + updateNameAndSymbol, + setAllocator, + refetchAdapter, + } = vault; + + const handleRefreshVault = useCallback(() => { + void refetchAll(); + }, [refetchAll]); + + const handleUpdateMetadata = useCallback( + async (values: { name?: string; symbol?: string }) => updateNameAndSymbol(values), + [updateNameAndSymbol], + ); + + const handleSetAllocator = useCallback( + async (allocator: Address, isAllocator: boolean) => setAllocator(allocator, isAllocator), + [setAllocator], + ); + + const handleRefetchAdapter = useCallback(() => { + void refetchAdapter(); + }, [refetchAdapter]); + + const handleAdapterConfigured = useCallback(() => { + void refetchAll(); + }, [refetchAll]); + + // UI state + const [settingsTab, setSettingsTab] = useState<'general' | 'agents' | 'caps'>('general'); + const [showSettings, setShowSettings] = useState(false); + const [showInitializationModal, setShowInitializationModal] = useState(false); + + // Derived display data + const fallbackTitle = `Vault ${getSlicedAddress(vaultAddressValue)}`; + const title = vault.vaultData?.displayName ?? fallbackTitle; + const symbolToDisplay = vault.vaultData?.displaySymbol; + const allocators = vault.vaultData?.allocators ?? []; + const sentinels = vault.vaultData?.sentinels ?? []; + const capData = vault.vaultData?.capsData; + const collateralCaps = capData?.collateralCaps ?? []; + const marketCaps = capData?.marketCaps ?? []; + const assetAddress = vault.vaultData?.assetAddress; + + // TODO: Get real APY from subgraph or calculate from market allocations + const apyLabel = '0%'; + + if (vault.hasError) { + return ( +
+
+
+
+

Vault data unavailable

+

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

+ + + +
+
+
+ ); + } + + return ( +
+
+
+
+ {/* Vault Header */} +
+
+

{title}

+ {symbolToDisplay && ( + {symbolToDisplay} + )} +
+
+ + {vault.isOwner && ( + + )} +
+
+ + {/* Setup Banners */} + {vault.needsAdapterDeployment && networkConfig?.vaultConfig?.marketV1AdapterFactory && ( +
+
+

Complete vault initialization

+

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

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

Choose an agent

+

Add an agent to enable automated allocation and rebalancing.

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

Configure allocation caps

+

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

+
+ +
+ )} + + {/* Summary Metrics */} + + +
+ Current APY +
{apyLabel}
+
+ { + if (vault.needsAdapterDeployment && networkConfig?.vaultConfig?.marketV1AdapterFactory) { + setShowInitializationModal(true); + return; + } + setSettingsTab('agents'); + setShowSettings(true); + }} + needsSetup={vault.needsAdapterDeployment} + isOwner={vault.isOwner} + isLoading={vault.vaultDataLoading} + /> + { + setSettingsTab('caps'); + setShowSettings(true); + }} + needsSetup={vault.needsAdapterDeployment} + isOwner={vault.isOwner} + isLoading={vault.vaultDataLoading} + /> +
+ + {/* Market Allocations */} + + + {/* Settings Modal */} + setShowSettings(false)} + initialTab={settingsTab} + isOwner={vault.isOwner} + onUpdateMetadata={handleUpdateMetadata} + updatingMetadata={vault.isUpdatingMetadata} + defaultName={vault.vaultData?.displayName ?? ''} + defaultSymbol={vault.vaultData?.displaySymbol ?? ''} + currentName={vault.onChainName ?? ''} + currentSymbol={vault.onChainSymbol ?? ''} + owner={vault.vaultData?.owner} + curator={vault.vaultData?.curator} + allocators={allocators} + sentinels={sentinels} + chainId={chainId} + vaultAsset={assetAddress as Address | undefined} + marketAdapter={vault.adapter} + capData={capData} + onSetAllocator={handleSetAllocator} + updateCaps={vault.updateCaps} + isUpdatingAllocator={vault.isUpdatingAllocator} + isUpdatingCaps={vault.isUpdatingCaps} + onRefresh={handleRefreshVault} + isRefreshing={vault.vaultDataLoading} + /> +
+
+ + {/* Initialization Modal */} + {networkConfig?.vaultConfig?.marketV1AdapterFactory && ( + setShowInitializationModal(false)} + vaultAddress={vaultAddressValue} + chainId={chainId} + marketAdapter={vault.adapter} + marketAdapterLoading={vault.adapterLoading} + refetchMarketAdapter={handleRefetchAdapter} + onAdapterConfigured={handleAdapterConfigured} + /> + )} +
+ ); +} diff --git a/app/autovault/[chainId]/[vaultAddress]/page.tsx b/app/autovault/[chainId]/[vaultAddress]/page.tsx new file mode 100644 index 00000000..3a3a902d --- /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: Promise<{ chainId: string; vaultAddress: string }>; +}) { + const { chainId, vaultAddress } = await 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]/components/VaultSettings.tsx b/app/autovault/[vaultAddress]/components/VaultSettings.tsx deleted file mode 100644 index 5d6353b0..00000000 --- a/app/autovault/[vaultAddress]/components/VaultSettings.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { Card, CardHeader, CardBody } from '@heroui/react'; -import { Button } from '@/components/common/Button'; -import { AutovaultData } from '@/hooks/useAutovaultData'; - -type VaultSettingsProps = { - vault: AutovaultData; - onClose: () => void; -}; - -export function VaultSettings({ onClose }: VaultSettingsProps) { - return ( -
-
-

Vault Settings

-

Configure your autovault automation and strategies

-
- - {/* 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 -

-
- -
-
-
-
- -
- -
-
- ); -} diff --git a/app/autovault/[vaultAddress]/content.tsx b/app/autovault/[vaultAddress]/content.tsx deleted file mode 100644 index 970ab88e..00000000 --- a/app/autovault/[vaultAddress]/content.tsx +++ /dev/null @@ -1,218 +0,0 @@ -'use client'; - -import { useMemo, useState } from 'react'; -import { Card, CardHeader, CardBody } from '@heroui/react'; -import { ChevronLeftIcon, GearIcon } from '@radix-ui/react-icons'; -import Link from 'next/link'; -import { useParams, useRouter } from 'next/navigation'; -import { Address } from 'viem'; -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 { useVaultDetails } from '@/hooks/useAutovaultData'; -import { VaultSettings } from './components/VaultSettings'; - -export default function VaultContent() { - const router = useRouter(); - const { vaultAddress } = useParams<{ vaultAddress: string }>(); - const { address } = useAccount(); - const [showSettings, setShowSettings] = useState(false); - - const { vault, isLoading, isError } = useVaultDetails(vaultAddress as Address); - - const isOwner = useMemo(() => { - if (!vault || !address) return false; - return vault.owner.toLowerCase() === address.toLowerCase(); - }, [vault, address]); - - if (isLoading) { - return ( -
-
- -
- ); - } - - if (isError || !vault) { - return ( -
-
-
-
-
-

Vault Not Found

-

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

- - - -
-
-
-
- ); - } - - return ( -
-
-
- {/* Header Section */} -
- -
-

{vault.name}

-

{vault.description}

-
- {isOwner && ( - - )} -
- - {/* 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

-
-
-
-
- - {/* 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)} /> - - -
- )} - - {/* TODO: Add charts and more detailed analytics */} - {!showSettings && ( -
- - -

Analytics & Charts

-
- -
-

Performance charts and analytics coming soon...

-
-
-
-
- )} -
-
- ); -} 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/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/VaultListV2.tsx b/app/autovault/components/VaultListV2.tsx index 324d7c8d..1b084603 100644 --- a/app/autovault/components/VaultListV2.tsx +++ b/app/autovault/components/VaultListV2.tsx @@ -1,11 +1,13 @@ 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'; 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[]; @@ -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/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/app/markets/components/MarketTableBody.tsx b/app/markets/components/MarketTableBody.tsx index f2e98564..22a84e6f 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({ -
- {chainImg && icon} - -
+ } - detail="This market is whitelisted by Monarch" + detail="This market is recognized by Monarch" /> } > diff --git a/app/positions/components/SuppliedMarketsDetail.tsx b/app/positions/components/SuppliedMarketsDetail.tsx index 9190a4e2..bc5bb043 100644 --- a/app/positions/components/SuppliedMarketsDetail.tsx +++ b/app/positions/components/SuppliedMarketsDetail.tsx @@ -1,14 +1,10 @@ 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'; type SuppliedMarketsDetailProps = { groupedPosition: GroupedPosition; @@ -19,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, @@ -53,74 +28,33 @@ 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 (
-
- {warningsWithDetail.length > 0 ? ( - } - placement="top" - > -
- -
-
- ) : ( -
- )} -
- - {position.market.uniqueKey.slice(2, 8)} - -
- - - {position.market.collateralAsset ? ( -
- - {position.market.collateralAsset.symbol} -
- ) : ( - 'N/A' - )} - - -
-
- - {formatBalance(position.market.lltv, 16)}% + + {formatReadable(position.market.state.supplyApy * 100)}% @@ -249,9 +183,7 @@ export function SuppliedMarketsDetail({ Market - Collateral - Oracle - LLTV + Collateral & Parameters APY Supplied % of Portfolio diff --git a/docs/Styling.md b/docs/Styling.md index f48336cc..4ae7c530 100644 --- a/docs/Styling.md +++ b/docs/Styling.md @@ -85,7 +85,7 @@ import { Button } from '@/components/common/Button'; // Utility Action - @@ -98,7 +98,10 @@ 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 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" />} > {/* 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. +### Complex Tooltip (with detail) +Shows icon, title, detail text, and optional secondary detail text: + +```tsx +} + title="Tooltip Title" + detail="Main description (text-primary, text-sm)" + secondaryDetail="Additional info (text-secondary, text-xs)" + /> + } +> + {/* Your trigger element */} + +``` + +### 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 + +``` + +**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 + + + +``` + +**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 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'; + +// Basic usage (required chainId) + + +// With network icon and warnings + + +``` ## Input Components @@ -212,3 +382,8 @@ const { success, error } = useStyledToast(); success('Success', 'Detail of the success'); error('Error', 'Detail of the error'); ``` + +### Typography Notes + +- Avoid bold weights for emphasis. Use color, size, or layout treatments (e.g., `text-secondary`, `text-primary`, spacing) instead of `font-semibold`/`font-bold`. +- Prefer `font-zen` for vault UI surfaces; keep typography consistent with existing components. 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/components/AgentIcon.tsx b/src/components/AgentIcon.tsx new file mode 100644 index 00000000..3c46347d --- /dev/null +++ b/src/components/AgentIcon.tsx @@ -0,0 +1,89 @@ +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..560caa59 --- /dev/null +++ b/src/components/MarketIdBadge.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +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'; + +type MarketIdBadgeProps = { + marketId: string; + chainId: number; + showNetworkIcon?: boolean; + showWarnings?: boolean; + showLink?: boolean; + market?: Market; +}; + +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 ( +
+ {showNetworkIcon && chainImg && ( + {`Chain + )} + { + showLink ? ( + + {displayId} + + ) : ( + + {displayId} + + )} + + {showWarnings && ( +
+ {hasWarnings && ( + + } + > +
+ + )} +
+ )} +
+ ); +} diff --git a/src/components/MarketIdentity.tsx b/src/components/MarketIdentity.tsx new file mode 100644 index 00000000..af630c8a --- /dev/null +++ b/src/components/MarketIdentity.tsx @@ -0,0 +1,315 @@ +import OracleVendorBadge from '@/components/OracleVendorBadge'; +import { TokenIcon } from '@/components/TokenIcon'; +import { getTruncatedAssetName } from '@/utils/oracle'; +import { Market, TokenInfo } from '@/utils/types'; + +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 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 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 ( +
+
+ {token ? ( + <> + + {label} + + ) : ( + {label} + )} +
+ {showLltv && ( + + {lltv}% LLTV + + )} + {showOracle && ( + + )} +
+ ); + } + + return ( +
+ {token ? ( + <> + + {label} + + ) : ( + {label} + )} + {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 ( +
+
+ {tokenStack} +
+ + {loanSymbol} + + {collateralAsset ? ( + <> + / + + {collateralSymbol} + + + ) : ( + + {collateralSymbol} + + )} +
+
+ {showLltv && ( + + {lltv}% LLTV + + )} + {showOracle && ( + + )} +
+ ); + } + + return ( +
+ {tokenStack} +
+ + {loanSymbol} + + {collateralAsset ? ( + <> + / + + {collateralSymbol} + + + ) : ( + + {collateralSymbol} + + )} + {showLltv && ( + + {lltv}% LLTV + + )} + {showOracle && ( + + )} +
+
+ ); + } + + // Normal mode: show both tokens equally (no styling difference) + if (wide) { + return ( +
+
+ {tokenStack} +
+ {loanSymbol} + {collateralAsset ? ( + / {collateralSymbol} + ) : ( + {collateralSymbol} + )} +
+
+ {showLltv && ( + + {lltv}% LLTV + + )} + {showOracle && ( + + )} +
+ ); + } + + return ( +
+ {tokenStack} +
+ {loanSymbol} + {collateralAsset ? ( + / {collateralSymbol} + ) : ( + {collateralSymbol} + )} + {showLltv && ( + + {lltv}% LLTV + + )} + {showOracle && ( + + )} +
+
+ ); +} diff --git a/src/components/TokenIcon.tsx b/src/components/TokenIcon.tsx index 55723565..c8cc4f85 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 { TooltipContent } from '@/components/TooltipContent'; +import { getExplorerUrl } from '@/utils/networks'; + 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/AddressDisplay.tsx b/src/components/common/AddressDisplay.tsx index 63af7e62..6b25beec 100644 --- a/src/components/common/AddressDisplay.tsx +++ b/src/components/common/AddressDisplay.tsx @@ -1,19 +1,37 @@ 'use client'; -import { useMemo, useState, useEffect } from 'react'; +import { useMemo, useState, useEffect, useCallback, HTMLAttributes } from 'react'; +import clsx from 'clsx'; import { FaCircle } from 'react-icons/fa'; +import { LuExternalLink } from 'react-icons/lu'; 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'; type AddressDisplayProps = { address: Address; + chainId?: SupportedNetworks | number; + size?: 'md' | 'sm'; + showExplorerLink?: boolean; + className?: string; + copyable?: boolean; }; -export function AddressDisplay({ address }: AddressDisplayProps) { +export function AddressDisplay({ + address, + chainId, + 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); @@ -23,8 +41,89 @@ export function AddressDisplay({ address }: AddressDisplayProps) { return address === connectedAddress; }, [address, connectedAddress]); + const explorerHref = useMemo(() => { + if (!showExplorerLink) return null; + const numericChainId = Number(chainId ?? 1); + if (!Number.isFinite(numericChainId)) return null; + 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]); + + const handleKeyDown = useCallback['onKeyDown']>>( + (event) => { + if (!copyable) return; + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + void handleCopy(); + } + }, + [copyable, handleCopy], + ); + + // Only add interactive props when copyable=true to satisfy a11y lint rules + const interactiveProps: HTMLAttributes = copyable + ? { + role: 'button', + tabIndex: 0, + onClick: () => void handleCopy(), + onKeyDown: handleKeyDown, + } + : {}; + + if (size === 'sm') { + return ( +
+ + {explorerHref && ( + event.stopPropagation()} + > + + + )} +
+ ); + } + return ( -
+
{mounted && isOwner && isConnected && ( @@ -36,12 +135,29 @@ export function AddressDisplay({ address }: AddressDisplayProps) { )}
- +
+ + {explorerHref && ( + event.stopPropagation()} + > + + + )} +
); 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/Button.tsx b/src/components/common/Button.tsx index abdf25c2..c8213d43 100644 --- a/src/components/common/Button.tsx +++ b/src/components/common/Button.tsx @@ -11,6 +11,7 @@ export const Button = extendVariants(NextUIButton, { interactive: 'bg-hovered text-foreground hover:bg-primary hover:text-white transition-all duration-200 ease-in-out', // Starts subtle, strong hover effect ghost: 'bg-transparent hover:bg-surface/5 transition-all duration-200 ease-in-out', // Most subtle variant + subtle: 'bg-surface shadow-sm hover:shadow text-foreground hover:bg-default-100 transition-all duration-200 ease-in-out', // Subtle button with shadow, background and shadow change on hover }, // Size variants size: { 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..7de0a0fd --- /dev/null +++ b/src/components/common/MarketSelectionModal.tsx @@ -0,0 +1,189 @@ +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 { SupportedNetworks } from '@/utils/networks'; +import { Market } from '@/utils/types'; + +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(); + } + }; + + const handleBackdropKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + 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/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..fc4a60d3 --- /dev/null +++ b/src/components/common/MarketsTableWithSameLoanAsset.tsx @@ -0,0 +1,673 @@ +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 { formatBalance, formatReadable } from '@/utils/balance'; +import { getViemChain } from '@/utils/networks'; +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 { MarketIdBadge } from '../MarketIdBadge'; +import { MarketIdentity, MarketIdentityMode, MarketIdentityFocus } from '../MarketIdentity'; + +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 { + MarketName = 0, + Supply = 1, + APY = 2, + Liquidity = 3, + Risk = 4, +} + +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()} + /> +
+ + + + + + + + +

+ {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.split('|').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.MarketName: + comparison = a.market.collateralAsset.symbol.localeCompare( + b.market.collateralAsset.symbol, + ); + 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; + case SortColumn.Risk: + comparison = 0; + 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 }) => ( +
+
+ + +
+ {renderCartItemExtra && renderCartItemExtra(market)} + +
+
+
+ ))} +
+ )} + + {/* Filters */} +
+ + +
+ + {/* Table */} +
+ + + + + + + + + + + + + + {paginatedMarkets.length === 0 ? ( + + + + ) : ( + paginatedMarkets.map((marketWithSelection) => ( + onToggleMarket(marketWithSelection.market.uniqueKey)} + disabled={disabled} + /> + )) + )} + +
SelectIdRisk
+ 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/v2-vaults.ts b/src/data-sources/morpho-api/v2-vaults.ts new file mode 100644 index 00000000..d3649ac4 --- /dev/null +++ b/src/data-sources/morpho-api/v2-vaults.ts @@ -0,0 +1,150 @@ +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; + oldRelativeCap?: string; // For delta calculation + oldAbsoluteCap?: string; // For delta calculation +}; + +export type VaultV2Details = { + id: string; + asset: string; + symbol: string; + name: string; + curator: string; + owner: string; + allocators: string[]; + sentinels: string[]; + caps: VaultV2Cap[]; + totalSupply: string; + adapters: 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: { + allocator: { + address: string; + }; + }[]; + caps: { + items: ApiVaultV2Cap[]; + }; +}; + +type VaultV2ApiResponse = { + data: { + vaultV2s: { + items: ApiVaultV2[]; + }; + }; + errors?: { 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), + adapters: [], // 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/data-sources/subgraph/morpho-market-v1-adapters.ts b/src/data-sources/subgraph/morpho-market-v1-adapters.ts new file mode 100644 index 00000000..6109ba09 --- /dev/null +++ b/src/data-sources/subgraph/morpho-market-v1-adapters.ts @@ -0,0 +1,49 @@ +import { Address } from 'viem'; +import { morphoMarketV1AdaptersQuery } from '@/graphql/morpho-market-v1-adapter-queries'; +import { subgraphGraphqlFetcher } from './fetchers'; + +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..125c60d4 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,18 +24,59 @@ export type UserVaultV2 = SubgraphVaultV2 & { balance?: bigint; // vault total assets }; +// Vault V2 details from subgraph +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; + adapters: 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; + adapters: { address: string }[]; + } | null; + }; + errors?: any[]; +}; + export const fetchUserVaultsV2 = async ( owner: string, network: SupportedNetworks, ): 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 { @@ -91,4 +132,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, + adapters: vault.adapters.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-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/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/graphql/morpho-v2-subgraph-queries.ts b/src/graphql/morpho-v2-subgraph-queries.ts index 3377e4c6..5e388b23 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 + adapters(where: {isAdapter: true}) { + address + } + } + } `; \ No newline at end of file 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/useAutovaultData.ts b/src/hooks/useAutovaultData.ts index 77fc8a3b..fe754320 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'; @@ -19,6 +33,7 @@ export type AutovaultData = { id: string; address: Address; name: string; + symbol?: string; description: string; totalValue: bigint; currentApy: number; @@ -34,6 +49,29 @@ export type AutovaultData = { amount: bigint; reason: string; }[]; + 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 = { @@ -119,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; } @@ -153,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); } @@ -167,6 +206,7 @@ export function useVaultDetails(vaultAddress?: Address): { }; useEffect(() => { + setVault(createEmptyVault(vaultAddress)); void fetchVaultDetails(); }, [vaultAddress]); diff --git a/src/hooks/useDeployMorphoMarketV1Adapter.ts b/src/hooks/useDeployMorphoMarketV1Adapter.ts new file mode 100644 index 00000000..85f322fd --- /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 { getMorphoAddress } from '@/utils/morpho'; +import { getNetworkConfig, SupportedNetworks } from '@/utils/networks'; +import { useTransactionWithToast } from './useTransactionWithToast'; + +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/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/useMorphoMarketV1Adapters.ts b/src/hooks/useMorphoMarketV1Adapters.ts new file mode 100644 index 00000000..44a6d953 --- /dev/null +++ b/src/hooks/useMorphoMarketV1Adapters.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +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'; + +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]); + + const morphoMarketV1Adapter = useMemo(() => adapters.length == 0? zeroAddress : adapters[0].adapter, [adapters]) + + return { + morphoMarketV1Adapter, + adapters, // all market adapters (should only be just one) + loading, + error, + refetch: fetchAdapters, + hasAdapters: adapters.length > 0, + }; +} 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); diff --git a/src/hooks/useUserVaultsV2.ts b/src/hooks/useUserVaultsV2.ts index 6cd0620b..e972005e 100644 --- a/src/hooks/useUserVaultsV2.ts +++ b/src/hooks/useUserVaultsV2.ts @@ -100,9 +100,89 @@ export function useUserVaultsV2(): UseUserVaultsV2Return { } }, [address]); + // Fetch vaults only when address changes, not when fetchVaults function reference changes useEffect(() => { - void fetchVaults(); - }, [fetchVaults]); + // Abort any previous request + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + if (!address) { + setVaults([]); + setLoading(false); + return; + } + + // Increment fetch ID and create new abort controller + const currentFetchId = ++fetchIdRef.current; + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + setLoading(true); + setError(null); + + const doFetch = async () => { + try { + // Check if request was cancelled + if (abortController.signal.aborted) return; + + const userVaults = await fetchUserVaultsV2AllNetworks(address); + + // Check if this is still the current request + if (abortController.signal.aborted || currentFetchId !== fetchIdRef.current) return; + + // Filter out vaults with incomplete data + const validVaults = userVaults.filter(vault => + vault.owner && + vault.asset && + vault.newVaultV2 + ); + + // Check again before proceeding with balance fetches + if (abortController.signal.aborted || currentFetchId !== fetchIdRef.current) return; + + // Fetch balances for each vault + const vaultsWithBalances = await Promise.all( + validVaults.map(async (vault) => { + // Check cancellation before each balance fetch + if (abortController.signal.aborted || currentFetchId !== fetchIdRef.current) { + throw new Error('Request cancelled'); + } + + const balance = await getERC20Balance( + vault.asset as Address, + vault.newVaultV2 as Address, + vault.networkId + ); + + return { + ...vault, + balance: balance ? balance : BigInt(0), + }; + }) + ); + + // Final check before updating state + if (abortController.signal.aborted || currentFetchId !== fetchIdRef.current) return; + + setVaults(vaultsWithBalances); + } catch (err) { + // Only set error if this is still the current request and not cancelled + if (!abortController.signal.aborted && currentFetchId === fetchIdRef.current) { + const fetchError = err instanceof Error ? err : new Error('Failed to fetch user vaults'); + setError(fetchError); + console.error('Error fetching user V2 vaults:', fetchError); + } + } finally { + // Only update loading if this is still the current request + if (!abortController.signal.aborted && currentFetchId === fetchIdRef.current) { + setLoading(false); + } + } + }; + + void doFetch(); + }, [address]); // Cleanup: abort any pending requests when component unmounts or address changes useEffect(() => { diff --git a/src/hooks/useVaultPage.ts b/src/hooks/useVaultPage.ts new file mode 100644 index 00000000..3845f6a8 --- /dev/null +++ b/src/hooks/useVaultPage.ts @@ -0,0 +1,143 @@ +import { useCallback, useMemo } from 'react'; +import { Address, zeroAddress } from 'viem'; +import { SupportedNetworks } from '@/utils/networks'; +import { useAllocations } from './useAllocations'; +import { useMorphoMarketV1Adapters } from './useMorphoMarketV1Adapters'; +import { useVaultV2 } from './useVaultV2'; +import { useVaultV2Data } from './useVaultV2Data'; + +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: () => { + void 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/hooks/useVaultV2.ts b/src/hooks/useVaultV2.ts new file mode 100644 index 00000000..1ae1f938 --- /dev/null +++ b/src/hooks/useVaultV2.ts @@ -0,0 +1,559 @@ +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/morpho-api/v2-vaults'; +import { SupportedNetworks } from '@/utils/networks'; +import { useTransactionWithToast } from './useTransactionWithToast'; + +export function useVaultV2({ + vaultAddress, + chainId, + onTransactionSuccess, +}: { + vaultAddress?: Address; + chainId?: SupportedNetworks | number; + onTransactionSuccess?: () => void; +}) { + const connectedChainId = useChainId(); + const chainIdToUse = (chainId ?? connectedChainId) as SupportedNetworks; + const { address: account } = useAccount(); + + const { data: curator } = useReadContract({ + address: vaultAddress, + abi: vaultv2Abi, + functionName: 'curator', + args: [], + chainId: chainIdToUse, + query: { + enabled: Boolean(vaultAddress), + }, + }); + + 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), + }, + }); + + + // Read totalAssets directly from the vault contract + const { data: totalAssets, refetch: refetchBalance, isLoading: loadingBalance } = useReadContract({ + address: vaultAddress, + abi: vaultv2Abi, + functionName: 'totalAssets', + chainId: chainIdToUse, + query: { + enabled: Boolean(vaultAddress) + }, + }); + + const currentCurator = useMemo(() => (curator as Address | undefined) ?? zeroAddress, [curator]); + + const refetchAll = useCallback(() => { + void refetchBalance(); + }, [refetchBalance]); + + const handleInitializationSuccess = useCallback(() => { + void refetchAll(); + onTransactionSuccess?.(); + }, [refetchAll, 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: handleInitializationSuccess, + }); + + 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, + }); + + 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: onTransactionSuccess, + }); + + 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: onTransactionSuccess, + }); + + + // All morpho v2 vault operations have to be proposed first, and then execute + const completeInitialization = useCallback( + async ( + morphoRegistry: Address, + marketV1Adapter: Address, + allocator?: 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 (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', + args: [txs], + }); + + try { + await sendInitializationTx({ + account, + to: vaultAddress, + data: multicallTx, + chainId: chainIdToUse, + }); + return true; + } catch (initError) { + if ( + initError instanceof Error && + initError.message.toLowerCase().includes('reject') + ) { + // user rejected the transaction; treat as graceful cancellation + return false; + } + console.error('Failed to complete vault initialization', initError); + throw initError; + } + }, + [account, chainIdToUse, currentCurator, sendInitializationTx, 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 (metadataUpdateError) { + if ( + metadataUpdateError instanceof Error && + metadataUpdateError.message.toLowerCase().includes('reject') + ) { + return false; + } + console.error('Failed to update vault metadata', metadataUpdateError); + throw metadataUpdateError; + } + }, + [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 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}`; + + // 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], + }); + + txs.push(submitIncreaseRelativeCapTx, increaseRelativeCapTx); + } else if (newRelativeCap < oldRelativeCap) { + // Decrease, no need to use submit for timelock + const decreaseRelativeCapTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'decreaseRelativeCap', + args: [idData, newRelativeCap], + }); + txs.push(decreaseRelativeCapTx); + } + } + + // 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], + }); + + txs.push(submitIncreaseAbsoluteCapTx, increaseAbsoluteCapTx); + } else if (newAbsoluteCap < oldAbsoluteCap) { + // Decrease + const decreaseAbsoluteCapTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'decreaseAbsoluteCap', + args: [idData, newAbsoluteCap], + }); + + 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', + 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 { isConfirming: isDepositing, sendTransactionAsync: sendDepositTx } = useTransactionWithToast({ + toastId: 'vault-deposit', + pendingText: 'Depositing to vault', + successText: 'Deposit successful', + errorText: 'Failed to deposit', + pendingDescription: 'Depositing assets to vault', + successDescription: 'Assets deposited successfully', + chainId: chainIdToUse, + onSuccess: onTransactionSuccess, + }); + + const { isConfirming: isWithdrawing, sendTransactionAsync: sendWithdrawTx } = useTransactionWithToast({ + toastId: 'vault-withdraw', + pendingText: 'Withdrawing from vault', + successText: 'Withdrawal successful', + errorText: 'Failed to withdraw', + pendingDescription: 'Withdrawing assets from vault', + successDescription: 'Assets withdrawn successfully', + chainId: chainIdToUse, + onSuccess: onTransactionSuccess, + }); + + const deposit = useCallback( + async (amount: bigint, receiver: Address): Promise => { + 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 name = useMemo(() => { + if (!rawName) return ''; + return String(rawName); + }, [rawName]); + + const symbol = useMemo(() => { + if (!rawSymbol) return ''; + return String(rawSymbol); + }, [rawSymbol]); + + + return { + isLoading: loadingBalance, + refetch: refetchAll, + completeInitialization, + isInitializing, + name, + symbol, + updateNameAndSymbol, + isUpdatingMetadata, + setAllocator, + isUpdatingAllocator, + updateCaps, + isUpdatingCaps, + deposit, + isDepositing, + withdraw, + isWithdrawing, + totalAssets, + }; +} diff --git a/src/hooks/useVaultV2Data.ts b/src/hooks/useVaultV2Data.ts new file mode 100644 index 00000000..9ace3915 --- /dev/null +++ b/src/hooks/useVaultV2Data.ts @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +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 { parseCapIdParams } from '@/utils/morpho'; +import { SupportedNetworks } from '@/utils/networks'; + +type UseVaultV2DataArgs = { + vaultAddress?: Address; + chainId: SupportedNetworks; + fallbackName?: string; + fallbackSymbol?: string; +}; + +export type CapData = { + adapterCap: VaultV2Cap | null, + collateralCaps: VaultV2Cap[], + marketCaps: VaultV2Cap[], + needSetupCaps: boolean +} + +export type VaultV2Data = { + displayName: string; + displaySymbol: string; + assetAddress: string; + tokenSymbol?: string; + tokenDecimals?: number; + totalSupply: string; + allocators: string[]; + sentinels: string[]; + owner: string; + curator: string; + capsData: CapData + adapters: string[]; + curatorDisplay: string; +}; + +type UseVaultV2DataReturn = { + data: VaultV2Data | null; + loading: boolean; + error: Error | null; + refetch: () => Promise; +}; + +export function useVaultV2Data({ + vaultAddress, + chainId, + fallbackName = '', + fallbackSymbol = '', +}: UseVaultV2DataArgs): UseVaultV2DataReturn { + const { findToken } = useTokens(); + + 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 { + const result = await fetchVaultV2Details(vaultAddress, chainId); + + if (!result) { + setData(null); + return; + } + + const token = result.asset ? findToken(result.asset, chainId) : undefined; + const curatorDisplay = result.curator ? getSlicedAddress(result.curator as Address) : '--'; + + // Parse caps by level using parseCapIdParams + let adapterCap: VaultV2Cap | null = null; + const collateralCaps: VaultV2Cap[] = []; + const marketCaps: VaultV2Cap[] = []; + + result.caps.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 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, + assetAddress: result.asset, + tokenSymbol: token?.symbol, + tokenDecimals: token?.decimals, + totalSupply: result.totalSupply, + allocators: result.allocators, + sentinels: result.sentinels, + owner: result.owner, + curator: result.curator, + capsData: { + adapterCap, + collateralCaps, + marketCaps, + needSetupCaps + }, + adapters: result.adapters, + curatorDisplay, + }); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to fetch vault data')); + setData(null); + } finally { + setLoading(false); + } + + }, [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, + }), + [data, error, loading, refetch], + ); +} 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, + }; +} diff --git a/src/imgs/agent/agent-apy.png b/src/imgs/agent/agent-apy.png new file mode 100644 index 00000000..f3dc6bea Binary files /dev/null and b/src/imgs/agent/agent-apy.png differ diff --git a/src/imgs/agent/agent-liquid.png b/src/imgs/agent/agent-liquid.png new file mode 100644 index 00000000..af19462e Binary files /dev/null and b/src/imgs/agent/agent-liquid.png differ diff --git a/src/utils/monarch-agent.ts b/src/utils/monarch-agent.ts index c54dc82f..1ca65e91 100644 --- a/src/utils/monarch-agent.ts +++ b/src/utils/monarch-agent.ts @@ -2,6 +2,8 @@ import { zeroAddress } from 'viem'; import { SupportedNetworks } from './networks'; import { AgentMetadata } from './types'; +const agentApyImage: string = require('@/imgs/agent/agent-apy.png') as string; + // todo: remove this after v2 agent config refactor export const getAgentContract = (chain: SupportedNetworks) => { switch (chain) { @@ -15,17 +17,9 @@ export const getAgentContract = (chain: SupportedNetworks) => { }; export enum KnownAgents { - MAX_APY = '0xe0e04468A54937244BEc3bc6C1CA8Bc36ECE6704', + MAX_APY = '0x038cC0fFf3aBc20dcd644B1136F42A33df135c52', } -// 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[] = [ @@ -33,10 +27,11 @@ export const v2AgentsBase: AgentMetadata[] = [ name: 'Max APY Agent', address: KnownAgents.MAX_APY, strategyDescription: 'Rebalance every 8 hours, always move to the highest APY', + image: agentApyImage, }, ]; 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()); }; diff --git a/src/utils/morpho.ts b/src/utils/morpho.ts index 60638585..d85b0a72 100644 --- a/src/utils/morpho.ts +++ b/src/utils/morpho.ts @@ -1,6 +1,6 @@ -import { zeroAddress } from 'viem'; +import { Address, decodeAbiParameters, encodeAbiParameters, keccak256, parseAbiParameters, zeroAddress } from 'viem'; import { SupportedNetworks } from './networks'; -import { UserTxTypes } from './types'; +import { MarketParams, UserTxTypes } from './types'; // appended to the end of datahash to identify a monarch tx export const MONARCH_TX_IDENTIFIER = 'beef'; @@ -95,3 +95,149 @@ export function getMorphoGenesisDate(chainId: number): Date { return MAINNET_GENESIS_DATE; // default to mainnet } } + +// ============================================================================ +// Cap ID Utilities for Morpho Market Adapters +// ============================================================================ + + +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)} +} + +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)} +} + +export function getMarketCapId(adopterAddress: Address, marketParams: MarketParams): {params: string, id: string} { + // Solidity + // id = keccak256(abi.encode("this/marketParams", address(this), marketParams)); + const encoded = encodeAbiParameters( + [ + { type: 'string' }, + { type: 'address' }, + { + 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, + { + loanToken: marketParams.loanToken, + collateralToken: marketParams.collateralToken, + oracle: marketParams.oracle, + irm: marketParams.irm, + lltv: marketParams.lltv + } + ] + ) + const id = keccak256(encoded) + + return { params: encoded, id } +} + +/** + * 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' | 'unknown'; + adapterAddress?: Address; + collateralToken?: Address; + marketParams?: MarketParams; + marketId?: string; +} { + 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 marketParamsComponents = parseAbiParameters( + '(address loanToken, address collateralToken, address oracle, address irm, uint256 lltv)', + ); + + const decoded = decodeAbiParameters( + [ + { type: 'string' }, + { type: 'address' }, + { type: 'tuple', components: marketParamsComponents }, + ], + idParams as `0x${string}`, + ); + + if (decoded[0] === 'this/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])); + + return { + type: 'market', + adapterAddress: decoded[1] as Address, + marketParams, + marketId, + }; + } + } catch { + // Not a market pattern + } + + // Fallback: could not decode + return { type: 'unknown' }; + } catch (error) { + console.error('Error parsing idParams:', error); + return { type: 'unknown' }; + } +} 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 => { diff --git a/src/utils/types.ts b/src/utils/types.ts index b7314237..bdcce659 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; @@ -361,6 +369,7 @@ export type AgentMetadata = { address: Address; name: string; strategyDescription: string; + image: string; }; // Define the comprehensive Market Activity Transaction type 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); +}