-
- {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
-
+
Refresh
@@ -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 = (
+ <>
+ {
+ const target = e.currentTarget;
+ target.style.display = 'none';
+ const fallback = target.nextElementSibling as HTMLElement;
+ if (fallback) fallback.style.display = 'flex';
+ }}
+ />
+
+
+
+ >
+ );
+
+ return (
+
+ }
+ >
+
+
{
+ 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 && (
+
+ )}
+ {
+ 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 (
+
+ );
+ }
+
return (
-
+
{mounted && isOwner && isConnected && (
@@ -36,12 +135,29 @@ export function AddressDisplay({ address }: AddressDisplayProps) {
)}
);
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 (
+
+
+
+
{name}
+ {isSelected && (
+
+ )}
+
+
+
{description}
+
+
+ );
+}
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 &&
}
+
+
+ Cancel
+
+
+ {buttonText}
+
+
+
+
+
+ );
+}
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 (
+
+
+
+
+
+
+ {getTruncatedAssetName(market.loanAsset.symbol)}
+
+
+ / {getTruncatedAssetName(market.collateralAsset.symbol)}
+
+
+
+
+ ·
+ {market.state?.supplyApy ? (market.state.supplyApy * 100).toFixed(2) : '0.00'}% APY
+ ·
+ {formatUnits(BigInt(market.lltv), 16)}% LTV
+
+
+
+ Add
+
+
+
+ );
+}
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 ? (
+
+ ) : (
+
+ ?
+
+ )
+ ) : 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 ? (
+
+ ) : (
+
+ ?
+
+ )}
+
+ );
+ })}
+
+
+
+ Clear All
+
+
+
+
+
+ )}
+
+
+ );
+}
+
+// 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] ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+ ) : (
+
Filter oracles
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+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)}
+ onToggleMarket(market.uniqueKey)}
+ disabled={disabled}
+ className="flex h-6 w-6 items-center justify-center rounded-full text-secondary transition-colors hover:bg-red-500/10 hover:text-red-500 disabled:opacity-50"
+ >
+
+
+
+
+
+ ))}
+
+ )}
+
+ {/* Filters */}
+
+
+
+
+
+ {/* Table */}
+
+
+
+
+ Select
+ Id
+
+
+
+
+ Risk
+
+
+
+ {paginatedMarkets.length === 0 ? (
+
+
+ No markets found
+
+
+ ) : (
+ paginatedMarkets.map((marketWithSelection) => (
+ onToggleMarket(marketWithSelection.market.uniqueKey)}
+ disabled={disabled}
+ />
+ ))
+ )}
+
+
+
+
+ {/* 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);
+}