diff --git a/.env.test b/.env.test index 5fbcc063..4224bf66 100644 --- a/.env.test +++ b/.env.test @@ -1,3 +1,6 @@ NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=GA_TEST_1234567890 NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=TEST_1234567890 -ENVIRONMENT=localhost \ No newline at end of file +ENVIRONMENT=localhost + +# +ALCHEMY_API_KEY=test \ No newline at end of file diff --git a/app/api/balances/route.ts b/app/api/balances/route.ts new file mode 100644 index 00000000..f4376d62 --- /dev/null +++ b/app/api/balances/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const ALCHEMY_API_KEY = process.env.ALCHEMY_API_KEY; +const ALCHEMY_URLS = { + '1': `https://eth-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}`, + '8453': `https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}`, +}; + +type TokenBalance = { + contractAddress: string; + tokenBalance: string; +}; + +export async function GET(req: NextRequest) { + const searchParams = req.nextUrl.searchParams; + const address = searchParams.get('address'); + const chainId = searchParams.get('chainId'); + + if (!address || !chainId) { + return NextResponse.json({ error: 'Missing address or chainId' }, { status: 400 }); + } + + try { + const alchemyUrl = ALCHEMY_URLS[chainId as keyof typeof ALCHEMY_URLS]; + if (!alchemyUrl) { + throw new Error(`Chain ${chainId} not supported`); + } + + // Get token balances + const balancesResponse = await fetch(alchemyUrl, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + id: 1, + jsonrpc: '2.0', + method: 'alchemy_getTokenBalances', + params: [address], + }), + }); + + if (!balancesResponse.ok) { + throw new Error(`HTTP error! status: ${balancesResponse.status}`); + } + + const balancesData = (await balancesResponse.json()) as { + id: number; + jsonrpc: string; + result: { + tokenBalances: TokenBalance[]; + }; + }; + + const nonZeroBalances: TokenBalance[] = balancesData.result.tokenBalances.filter( + (token: TokenBalance) => + token.tokenBalance !== '0x0000000000000000000000000000000000000000000000000000000000000000', + ); + + // Filter out failed metadata requests + const tokens = nonZeroBalances + .filter((token) => token !== null) + .map((token) => ({ + address: token.contractAddress.toLowerCase(), + balance: BigInt(token.tokenBalance).toString(10), + })); + + return NextResponse.json({ + tokens, + }); + } catch (error) { + console.error('Failed to fetch balances:', error); + return NextResponse.json({ error: 'Failed to fetch balances' }, { status: 500 }); + } +} diff --git a/app/history/components/HistoryTable.tsx b/app/history/components/HistoryTable.tsx index c2ca125e..638dc1a8 100644 --- a/app/history/components/HistoryTable.tsx +++ b/app/history/components/HistoryTable.tsx @@ -88,6 +88,7 @@ export function HistoryTable({ history }: HistoryTableProps) {

{tx.data.market.uniqueKey.slice(2, 8)}

diff --git a/app/home/_components/HomeHeader.tsx b/app/home/_components/HomeHeader.tsx index 08de5c9e..42597294 100644 --- a/app/home/_components/HomeHeader.tsx +++ b/app/home/_components/HomeHeader.tsx @@ -3,7 +3,7 @@ import styles from './Home.module.css'; export default function HomeHeader() { return ( -

+
); diff --git a/app/info/components/info.tsx b/app/info/components/info.tsx index 91829267..422128ed 100644 --- a/app/info/components/info.tsx +++ b/app/info/components/info.tsx @@ -76,7 +76,7 @@ function InfoPage() { const renderImage = (section: (typeof sections)[0], index: number) => (
); @@ -108,7 +108,7 @@ function InfoPage() { > {sections.map((section, index) => (
-
+

{section.mainTitle}

diff --git a/app/info/components/sectionData.tsx b/app/info/components/sectionData.tsx index d980a43e..e7dcfa00 100644 --- a/app/info/components/sectionData.tsx +++ b/app/info/components/sectionData.tsx @@ -6,7 +6,7 @@ import vaultsImage from '../../../src/imgs/intro/vaults.png'; function Card({ title, items }: { title: string; items: string[] }) { return ( -
+

{title}

    {items.map((item, index) => ( diff --git a/app/markets/components/OracleFilter.tsx b/app/markets/components/OracleFilter.tsx index 34e5e185..39f27cd4 100644 --- a/app/markets/components/OracleFilter.tsx +++ b/app/markets/components/OracleFilter.tsx @@ -13,22 +13,16 @@ type OracleFilterProps = { export default function OracleFilter({ selectedOracles, setSelectedOracles }: OracleFilterProps) { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); - const mounted = useRef(true); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if ( - mounted.current && - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) - ) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { setIsOpen(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => { - mounted.current = false; document.removeEventListener('mousedown', handleClickOutside); }; }, []); @@ -43,16 +37,11 @@ export default function OracleFilter({ selectedOracles, setSelectedOracles }: Or } }; - const clearSelection = () => { - setSelectedOracles([]); - setIsOpen(false); - }; - return (
    - {isOpen && ( -
    -
      - {Object.values(OracleVendors).map((oracle) => ( -
    • toggleOracle(oracle)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - toggleOracle(oracle); - } - }} - role="option" - aria-selected={selectedOracles.includes(oracle)} - tabIndex={0} - > - {oracle} - {OracleVendorIcons[oracle] && ( - {oracle} - )} -
    • - ))} -
    -
    - -
    -
    - )} +
    + {oracle} + {oracle} +
    + + ))} +
+
); } diff --git a/app/markets/components/markets.tsx b/app/markets/components/markets.tsx index 14b4c3f7..ecf4059b 100644 --- a/app/markets/components/markets.tsx +++ b/app/markets/components/markets.tsx @@ -339,13 +339,13 @@ export default function Markets() { />
-
+
+ + - +
@@ -75,7 +88,17 @@ export default function Positions() { {loading ? ( ) : !hasSuppliedMarkets ? ( - +
+ + + + +
) : (
)} - -
- View All Markets -
-
- - Search Address - -
); diff --git a/app/positions/components/RebalanceActionInput.tsx b/app/positions/components/RebalanceActionInput.tsx index 37b634bd..05f646ca 100644 --- a/app/positions/components/RebalanceActionInput.tsx +++ b/app/positions/components/RebalanceActionInput.tsx @@ -28,7 +28,7 @@ export function RebalanceActionInput({ onAddAction, }: RebalanceActionInputProps) { return ( -
+
Rebalance chainId !== groupedPosition.chainId, - [chainId, groupedPosition.chainId] + [chainId, groupedPosition.chainId], ); console.log('needSwitchChain', needSwitchChain); @@ -230,7 +230,7 @@ export function RebalanceModal({ -
+

Optimize your {groupedPosition.loanAsset} lending strategy by redistributing funds across markets, add "Rebalance" actions to fine-tune your portfolio. @@ -297,9 +297,7 @@ export function RebalanceModal({ isLoading={isConfirming} className="rounded-sm bg-orange-500 p-4 px-10 font-zen text-white opacity-80 transition-all duration-200 ease-in-out hover:scale-105 hover:opacity-100 disabled:opacity-50 dark:bg-orange-600" > - {needSwitchChain - ? 'Switch Network & Execute' - : 'Execute Rebalance'} + {needSwitchChain ? 'Switch Network & Execute' : 'Execute Rebalance'} diff --git a/app/positions/components/SmartOnboarding.tsx b/app/positions/components/SmartOnboarding.tsx new file mode 100644 index 00000000..64226b9c --- /dev/null +++ b/app/positions/components/SmartOnboarding.tsx @@ -0,0 +1,14 @@ +import { AssetSelection } from './onboarding/AssetSelection'; +import { OnboardingProvider } from './onboarding/OnboardingContext'; +import { RiskSelection } from './onboarding/RiskSelection'; + +export function SmartOnboarding() { + return ( +

+ + + + +
+ ); +} diff --git a/app/positions/components/onboarding/AssetSelection.tsx b/app/positions/components/onboarding/AssetSelection.tsx new file mode 100644 index 00000000..486d84d8 --- /dev/null +++ b/app/positions/components/onboarding/AssetSelection.tsx @@ -0,0 +1,170 @@ +import { useMemo } from 'react'; +import { Button } from '@nextui-org/react'; +import { motion } from 'framer-motion'; +import Image from 'next/image'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useMarkets } from '@/hooks/useMarkets'; +import { useUserBalances } from '@/hooks/useUserBalances'; +import { formatBalance } from '@/utils/balance'; +import { getNetworkImg, getNetworkName } from '@/utils/networks'; +import { useOnboarding } from './OnboardingContext'; +import { TokenWithMarkets } from './types'; + +function NetworkIcon({ networkId }: { networkId: number }) { + const url = getNetworkImg(networkId); + return ( + {`networkId-${networkId}`} + ); +} + +export function AssetSelection() { + const { balances, loading: balancesLoading } = useUserBalances(); + const { markets, loading: marketsLoading } = useMarkets(); + const { setSelectedToken, setSelectedMarkets } = useOnboarding(); + const router = useRouter(); + + const tokensWithMarkets = useMemo(() => { + if (!balances || !markets) return []; + + const result: TokenWithMarkets[] = []; + + balances.forEach((balance) => { + // Filter markets for this specific token and network + const relevantMarkets = markets.filter( + (market) => + market.morphoBlue.chain.id === balance.chainId && + market.loanAsset.address.toLowerCase() === balance.address.toLowerCase(), + ); + + if (relevantMarkets.length === 0) return; + + // Calculate min and max APY + const apys = relevantMarkets.map((market) => market.state.supplyApy); + const minApy = Math.min(...apys); + const maxApy = Math.max(...apys); + + // Get network name + const network = balance.chainId; + + result.push({ + symbol: balance.symbol, + markets: relevantMarkets, + minApy, + maxApy, + logoURI: balance.logoURI, + decimals: balance.decimals, + network, + address: balance.address, + balance: balance.balance, + }); + }); + + return result; + }, [balances, markets]); + + const handleTokenSelect = (token: TokenWithMarkets) => { + setSelectedToken(token); + setSelectedMarkets([]); // Reset selected markets when changing token + router.push('/positions/onboarding?step=risk-selection'); + }; + + if (balancesLoading || marketsLoading) { + return ( +
+
+

Select an Asset

+

Choose which asset you want to supply

+
+
Loading...
+
+ ); + } + + return ( +
+
+

Select an Asset

+

Choose which asset you want to supply

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

No assets available

+

+ You need to have some assets in your wallet to supply +

+ + + +
+ ) : ( +
+ {tokensWithMarkets.map((token) => ( + handleTokenSelect(token)} + className="group relative flex items-start gap-4 rounded-lg border border-gray-200 bg-white p-4 text-left transition-all duration-300 hover:border-primary hover:shadow-lg dark:border-gray-700 dark:bg-gray-800/50 dark:hover:bg-gray-800" + whileHover={{ scale: 1.02 }} + transition={{ type: 'spring', stiffness: 300, damping: 20 }} + > +
+ {token.logoURI && ( + {token.symbol} + )} +
+
+
+
+

+ {token.symbol} +

+
+ + + {getNetworkName(token.network)} + +
+
+
+ +
+

+ Balance: {formatBalance(token.balance, token.decimals)} {token.symbol} +

+
+
+

+ {token.markets.length} market{token.markets.length !== 1 ? 's' : ''} +

+ +

+ {(token.minApy * 100).toFixed(2)}% - {(token.maxApy * 100).toFixed(2)}% APY +

+
+
+
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/app/positions/components/onboarding/OnboardingContent.tsx b/app/positions/components/onboarding/OnboardingContent.tsx new file mode 100644 index 00000000..15fbc870 --- /dev/null +++ b/app/positions/components/onboarding/OnboardingContent.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import Header from '@/components/layout/header/Header'; +import { AssetSelection } from './AssetSelection'; +import { OnboardingProvider } from './OnboardingContext'; +import { RiskSelection } from './RiskSelection'; +import { SetupPositions } from './SetupPositions'; +import { SuccessPage } from './SuccessPage'; + +export function OnboardingContent() { + const searchParams = useSearchParams(); + const step = searchParams.get('step') ?? 'asset-selection'; + + const renderStep = () => { + switch (step) { + case 'asset-selection': + return ; + case 'risk-selection': + return ; + case 'setup': + return ; + case 'success': + return ; + default: + return ; + } + }; + + return ( +
+
+
+ +
{renderStep()}
+
+
+
+ ); +} diff --git a/app/positions/components/onboarding/OnboardingContext.tsx b/app/positions/components/onboarding/OnboardingContext.tsx new file mode 100644 index 00000000..750d3605 --- /dev/null +++ b/app/positions/components/onboarding/OnboardingContext.tsx @@ -0,0 +1,72 @@ +import { createContext, useContext, useState, useMemo } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Market } from '@/utils/types'; +import { TokenWithMarkets } from './types'; + +type OnboardingStep = 'asset-selection' | 'risk-selection' | 'setup' | 'success'; + +type OnboardingContextType = { + selectedToken: TokenWithMarkets | null; + setSelectedToken: (token: TokenWithMarkets | null) => void; + selectedMarkets: Market[]; + setSelectedMarkets: (markets: Market[]) => void; + step: OnboardingStep; + setStep: (step: OnboardingStep) => void; +}; + +const OnboardingContext = createContext(null); + +export function OnboardingProvider({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const searchParams = useSearchParams(); + const currentStep = (searchParams.get('step') as OnboardingStep) || 'asset-selection'; + + const [selectedToken, setSelectedToken] = useState(null); + const [selectedMarkets, setSelectedMarkets] = useState([]); + + const setStep = (newStep: OnboardingStep) => { + const params = new URLSearchParams(searchParams.toString()); + params.set('step', newStep); + router.push(`/positions/onboarding?${params.toString()}`); + }; + + const contextValue = useMemo( + () => ({ + selectedToken, + setSelectedToken: (token: TokenWithMarkets | null) => { + setSelectedToken(token); + // Reset markets when token changes + setSelectedMarkets([]); + }, + selectedMarkets, + setSelectedMarkets: (markets: Market[]) => { + setSelectedMarkets(markets); + }, + step: currentStep, + setStep: (newStep: OnboardingStep) => { + // Validate step transitions + if (newStep !== 'asset-selection' && !selectedToken) { + throw new Error('Token must be selected before proceeding'); + } + if (newStep === 'setup' && selectedMarkets.length === 0) { + throw new Error('Markets must be selected before setup'); + } + if (newStep === 'success' && !selectedToken) { + throw new Error('Token must be selected before showing success'); + } + setStep(newStep); + }, + }), + [selectedToken, selectedMarkets, currentStep], + ); + + return {children}; +} + +export function useOnboarding() { + const context = useContext(OnboardingContext); + if (!context) { + throw new Error('useOnboarding must be used within an OnboardingProvider'); + } + return context; +} diff --git a/app/positions/components/onboarding/RiskSelection.tsx b/app/positions/components/onboarding/RiskSelection.tsx new file mode 100644 index 00000000..4cc1a7db --- /dev/null +++ b/app/positions/components/onboarding/RiskSelection.tsx @@ -0,0 +1,285 @@ +import { useMemo, useState } from 'react'; +import { Button } from '@nextui-org/react'; +import Image from 'next/image'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { formatUnits } from 'viem'; +import OracleVendorBadge from '@/components/OracleVendorBadge'; +import { formatReadable } from '@/utils/balance'; +import { getAssetURL } from '@/utils/external'; +import { OracleVendors, parseOracleVendors } from '@/utils/oracle'; +import { findToken, getUniqueTokens } from '@/utils/tokens'; +import { Market } from '@/utils/types'; +import AssetFilter from 'app/markets/components/AssetFilter'; +import OracleFilter from 'app/markets/components/OracleFilter'; +import { + MarketDebtIndicator, + MarketAssetIndicator, + MarketOracleIndicator, +} from 'app/markets/components/RiskIndicator'; +import { useOnboarding } from './OnboardingContext'; + +export function RiskSelection() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { selectedToken, setSelectedMarkets } = useOnboarding(); + const [selectedCollaterals, setSelectedCollaterals] = useState([]); + const [selectedOracles, setSelectedOracles] = useState([]); + const [selectedMarkets, setSelectedMarketsLocal] = useState>(new Set()); + + const collateralTokens = useMemo(() => { + if (!selectedToken?.markets) return []; + const tokens = selectedToken.markets.map((market) => ({ + address: market.collateralAsset.address, + chainId: market.morphoBlue.chain.id, + })); + return getUniqueTokens(tokens); + }, [selectedToken]); + + // Filter markets based on selected collaterals and oracles + const filteredMarkets = useMemo(() => { + if (!selectedToken?.markets) return []; + + return selectedToken.markets + .filter((market) => { + // Skip markets without known collateral + const collateralToken = findToken( + market.collateralAsset.address, + market.morphoBlue.chain.id, + ); + if (!collateralToken) return false; + + // Check if collateral is selected (if any are selected) + if (selectedCollaterals.length > 0) { + const tokenKey = `${market.collateralAsset.address.toLowerCase()}-${ + market.morphoBlue.chain.id + }`; + if (!selectedCollaterals.some((key) => key.split('|').includes(tokenKey))) return false; + } + + // Check if oracle is selected (if any are selected) + if (selectedOracles.length > 0) { + const { vendors } = parseOracleVendors(market.oracle.data); + // Check if all vendors are selected + if (!vendors.every((vendor) => selectedOracles.includes(vendor))) return false; + } + + return true; + }) + .sort((a, b) => { + const aAssets = Number(a.state.supplyAssets) || 0; + const bAssets = Number(b.state.supplyAssets) || 0; + return bAssets - aAssets; + }); + }, [selectedToken, selectedCollaterals, selectedOracles]); + + const handleNext = () => { + if (selectedMarkets.size > 0) { + const selectedMarketsArray = Array.from(selectedMarkets) + .map((key) => filteredMarkets.find((m) => m.uniqueKey === key)) + .filter((m): m is Market => m !== undefined); + + setSelectedMarkets(selectedMarketsArray); + router.push('/positions/onboarding?step=setup'); + } + }; + + const handleMarketDetails = (market: Market, e: React.MouseEvent) => { + e.stopPropagation(); + const currentParams = searchParams.toString(); + const marketPath = `/market/${market.morphoBlue.chain.id}/${market.uniqueKey}`; + const targetPath = currentParams ? `${marketPath}?${currentParams}` : marketPath; + router.push(targetPath); + }; + + const toggleMarketSelection = (market: Market) => { + const newSelection = new Set(selectedMarkets); + if (selectedMarkets.has(market.uniqueKey)) { + newSelection.delete(market.uniqueKey); + } else { + newSelection.add(market.uniqueKey); + } + setSelectedMarketsLocal(newSelection); + }; + + return ( +
+
+

Select Your Risk Preference

+

Choose which assets and oracles you want to trust

+
+ + {/* Input Section */} +
+
+ +
+
+ +
+
+ +
+

Choose markets you want to trust

+
+ + {/* Markets Table */} +
+
+ + + + + + + + + + + + + + + {filteredMarkets.map((market) => { + const collateralToken = findToken( + market.collateralAsset.address, + market.morphoBlue.chain.id, + ); + if (!collateralToken) return null; + + const isSelected = selectedMarkets.has(market.uniqueKey); + const { vendors } = parseOracleVendors(market.oracle.data); + + return ( + toggleMarketSelection(market)} + className={`cursor-pointer transition-all duration-200 ease-in-out hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800 ${ + isSelected ? 'bg-primary-50 dark:bg-primary-900/20' : '' + }`} + > + + + + + + + + + + ); + })} + +
MarketMarket ParamsOracleLLTVSupply APYTotal SupplyUtilizationActions
+
+ {collateralToken?.img && ( +
+ {market.collateralAsset.symbol} +
+ )} +
+
+ e.stopPropagation()} + className="flex items-center gap-1 no-underline hover:underline" + > + {market.collateralAsset.symbol} + + +
+ as collateral +
+
+
+
+ + + +
+
+
+ {vendors.map((vendor) => ( + + ))} +
+
+ {formatUnits(BigInt(market.lltv), 16)}% + + {formatReadable(market.state.supplyApy * 100)}% + + {formatReadable( + Number( + formatUnits(BigInt(market.state.supplyAssets), market.loanAsset.decimals), + ), + )}{' '} + {market.loanAsset.symbol} + + {formatReadable(market.state.utilization * 100)}% + + +
+
+
+ + {/* Navigation */} +
+ + +
+
+ ); +} diff --git a/app/positions/components/onboarding/SetupPositions.tsx b/app/positions/components/onboarding/SetupPositions.tsx new file mode 100644 index 00000000..e800f165 --- /dev/null +++ b/app/positions/components/onboarding/SetupPositions.tsx @@ -0,0 +1,469 @@ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { Button, Slider } from '@nextui-org/react'; +import { LockClosedIcon, LockOpen1Icon } from '@radix-ui/react-icons'; +import Image from 'next/image'; +import { useRouter } from 'next/navigation'; +import { formatUnits, parseUnits } from 'viem'; +import OracleVendorBadge from '@/components/OracleVendorBadge'; +import { SupplyProcessModal } from '@/components/SupplyProcessModal'; +import { useLocalStorage } from '@/hooks/useLocalStorage'; +import { useMultiMarketSupply } from '@/hooks/useMultiMarketSupply'; +import { useUserBalances } from '@/hooks/useUserBalances'; +import { formatBalance, formatReadable } from '@/utils/balance'; +import { parseOracleVendors } from '@/utils/oracle'; +import { findToken } from '@/utils/tokens'; +import { useOnboarding } from './OnboardingContext'; + +export function SetupPositions() { + const router = useRouter(); + const { selectedToken, selectedMarkets } = useOnboarding(); + const { balances } = useUserBalances(); + const [useEth] = useLocalStorage('useEth', false); + const [usePermit2Setting] = useLocalStorage('usePermit2', true); + const [totalAmount, setTotalAmount] = useState(''); + const [amounts, setAmounts] = useState>({}); + const [percentages, setPercentages] = useState>({}); + const [lockedAmounts, setLockedAmounts] = useState>(new Set()); + const [error, setError] = useState(null); + const [isSupplying, setIsSupplying] = useState(false); + + // Redirect if no token selected + useEffect(() => { + if (!selectedToken) { + router.push('/positions/onboarding?step=asset-selection'); + return; + } + if (!selectedMarkets || selectedMarkets.length === 0) { + router.push('/positions/onboarding?step=risk-selection'); + } + }, [router, selectedToken, selectedMarkets]); + + // Compute token balance and decimals + const tokenBalance = useMemo(() => { + if (!selectedToken) return 0n; + return BigInt( + balances.find((b) => b.address.toLowerCase() === selectedToken.address.toLowerCase()) + ?.balance ?? '0', + ); + }, [balances, selectedToken]); + + const tokenDecimals = useMemo(() => selectedToken?.decimals ?? 0, [selectedToken]); + + // Initialize percentages evenly + useEffect(() => { + if (selectedMarkets.length > 0) { + const evenPercentage = 100 / selectedMarkets.length; + const initialPercentages = selectedMarkets.reduce( + (acc, market) => { + acc[market.uniqueKey] = evenPercentage; + return acc; + }, + {} as Record, + ); + setPercentages(initialPercentages); + } + }, [selectedMarkets]); + + // Update amounts when total amount or percentages change + useEffect(() => { + if (totalAmount && Object.keys(percentages).length > 0) { + const newAmounts = Object.entries(percentages).reduce( + (acc, [key, percentage]) => { + acc[key] = ((percentage / 100) * Number(totalAmount)).toFixed(tokenDecimals); + return acc; + }, + {} as Record, + ); + setAmounts(newAmounts); + } + }, [totalAmount, percentages, tokenDecimals]); + + const handleTotalAmountChange = (value: string) => { + // Remove any non-numeric characters except decimal point + const cleanValue = value.replace(/[^0-9.]/g, ''); + + // Ensure only one decimal point + const parts = cleanValue.split('.'); + if (parts.length > 2) return; + + // Limit decimal places to token decimals + if (parts[1] && parts[1].length > tokenDecimals) return; + + try { + // Validate the new amount can be converted to BigInt + parseUnits(cleanValue || '0', tokenDecimals); + setTotalAmount(cleanValue); + } catch (e) { + setError('Invalid amount'); + } + }; + + const toggleLockAmount = useCallback( + (marketKey: string) => { + const newLockedAmounts = new Set(lockedAmounts); + if (lockedAmounts.has(marketKey)) { + newLockedAmounts.delete(marketKey); + } else { + newLockedAmounts.add(marketKey); + } + setLockedAmounts(newLockedAmounts); + }, + [lockedAmounts], + ); + + const handlePercentageChange = useCallback( + (marketKey: string, newPercentage: number) => { + // If the input is invalid (NaN), set it to 0 + if (Number.isNaN(newPercentage)) { + newPercentage = 0; + } + + const market = selectedMarkets.find((m) => m.uniqueKey === marketKey); + if (!market) return; + + const lockedMarkets = selectedMarkets.filter( + (m) => m.uniqueKey !== marketKey && lockedAmounts.has(m.uniqueKey), + ); + const unlockedMarkets = selectedMarkets.filter( + (m) => m.uniqueKey !== marketKey && !lockedAmounts.has(m.uniqueKey), + ); + + // Calculate total locked percentage + const totalLockedPercentage = lockedMarkets.reduce( + (sum, m) => sum + (percentages[m.uniqueKey] || 0), + 0, + ); + + // Ensure we don't exceed 100% - totalLockedPercentage + const maxAllowedPercentage = 100 - totalLockedPercentage; + newPercentage = Math.min(newPercentage, maxAllowedPercentage); + + // Calculate remaining percentage for unlocked markets + const remainingPercentage = 100 - totalLockedPercentage - newPercentage; + + // Distribute remaining percentage among unlocked markets proportionally + const newPercentages = { ...percentages }; + newPercentages[marketKey] = newPercentage; + + if (unlockedMarkets.length > 0 && remainingPercentage > 0) { + const currentUnlockedTotal = unlockedMarkets.reduce( + (sum, m) => sum + (percentages[m.uniqueKey] || 0), + 0, + ); + + unlockedMarkets.forEach((m) => { + const currentPct = percentages[m.uniqueKey] || 0; + const proportion = + currentUnlockedTotal === 0 + ? 1 / unlockedMarkets.length + : currentPct / currentUnlockedTotal; + newPercentages[m.uniqueKey] = remainingPercentage * proportion; + }); + } + + setPercentages(newPercentages); + }, + [percentages, selectedMarkets, lockedAmounts], + ); + + const handleAmountChange = useCallback( + (marketKey: string, value: string) => { + if (!totalAmount) return; + + // Remove any non-numeric characters except decimal point + const cleanValue = value.replace(/[^0-9.]/g, ''); + + // Ensure only one decimal point + const parts = cleanValue.split('.'); + if (parts.length > 2) return; + + // Limit decimal places to token decimals + if (parts[1] && parts[1].length > tokenDecimals) return; + + try { + // Validate the new amount can be converted to BigInt + parseUnits(cleanValue || '0', tokenDecimals); + + const newAmount = Number(cleanValue); + const percentage = (newAmount / Number(totalAmount)) * 100; + + // Update this market's percentage + handlePercentageChange(marketKey, percentage); + } catch (e) { + // If conversion fails, don't update the state + console.warn(`Invalid amount format: ${cleanValue}`); + return; + } + }, + [totalAmount, tokenDecimals, handlePercentageChange], + ); + + const supplies = useMemo(() => { + if (!selectedMarkets || !amounts || !tokenDecimals) return []; + + return selectedMarkets + .map((market) => { + const amount = parseUnits(amounts[market.uniqueKey] || '0', tokenDecimals); + return { + market, + amount, + }; + }) + .filter((supply) => supply.amount > 0n); + }, [selectedMarkets, amounts, tokenDecimals]); + + const { + currentStep, + showProcessModal, + setShowProcessModal, + isLoadingPermit2, + approveAndSupply, + supplyPending, + } = useMultiMarketSupply(selectedToken!, supplies, useEth, usePermit2Setting); + + const handleSupply = async () => { + if (isSupplying) return; + setIsSupplying(true); + + try { + const success = await approveAndSupply(); + if (success) { + router.push('/positions/onboarding?step=success'); + } + } catch (supplyError) { + console.error('Supply failed:', supplyError); + // Error toast is already shown in useMultiMarketSupply + } finally { + setIsSupplying(false); + } + }; + + if (!selectedToken || !selectedMarkets || selectedMarkets.length === 0) { + return null; + } + + return ( +
+
+

Setup Your Positions

+

+ Choose how much {selectedToken.symbol} you want to supply in total and distribute it + across markets +

+
+ + {/* Total Amount Section */} +
+
+
+
+ handleTotalAmountChange(e.target.value)} + placeholder="0.0" + className="w-full rounded border border-gray-200 bg-white px-3 py-2 pr-20 font-mono dark:border-gray-700 dark:bg-gray-800" + /> + +
+
+
+
+ {selectedToken.symbol} + Wallet Balance +
+
+ + {formatBalance(tokenBalance, tokenDecimals)} {selectedToken.symbol} + +
+
+
+
+ + {/* Markets Distribution */} +
+
+ + + + + + + + + + + + {selectedMarkets.map((market) => { + const collateralToken = findToken( + market.collateralAsset.address, + market.morphoBlue.chain.id, + ); + if (!collateralToken) return null; + + const { vendors } = parseOracleVendors(market.oracle.data); + const currentPercentage = percentages[market.uniqueKey] ?? 0; + const isLocked = lockedAmounts.has(market.uniqueKey); + + return ( + + + + + + + + ); + })} + +
Market IDCollateralMarket ParamsSupply APYDistribution
+ + {market.uniqueKey.slice(2, 8)} + + +
+ {collateralToken?.img && ( +
+ {market.collateralAsset.symbol} +
+ )} +
+ {market.collateralAsset.symbol} + as collateral +
+
+
+
+
+ {vendors.map((vendor) => ( + + ))} +
+ + {formatUnits(BigInt(market.lltv), 16)}% LTV + +
+
+ {formatReadable(market.state.supplyApy * 100)}% + +
+
+
+ + handlePercentageChange(market.uniqueKey, Number(value)) + } + className="max-w-md" + classNames={{ + base: 'max-w-md gap-3', + track: 'bg-default-500/30', + thumb: 'bg-primary', + }} + isDisabled={isLocked} + /> +
+
+
+ + handleAmountChange(market.uniqueKey, e.target.value) + } + placeholder="0.0" + className="bg-hovered focus:border-monarch-orange h-8 w-full rounded p-2 text-right font-mono focus:outline-none" + disabled={isLocked} + /> +
+ + {Math.round(currentPercentage)}% + + +
+
+
+
+
+
+ + {error &&
{error}
} + + {/* Process Modal */} + {showProcessModal && ( + setShowProcessModal(false)} + tokenSymbol={selectedToken.symbol} + useEth={useEth} + usePermit2={usePermit2Setting} + /> + )} + + {/* Navigation */} +
+ + +
+
+ ); +} diff --git a/app/positions/components/onboarding/SmartOnboarding.tsx b/app/positions/components/onboarding/SmartOnboarding.tsx new file mode 100644 index 00000000..3085b4de --- /dev/null +++ b/app/positions/components/onboarding/SmartOnboarding.tsx @@ -0,0 +1,22 @@ +import { AssetSelection } from './AssetSelection'; +import { OnboardingProvider, useOnboarding } from './OnboardingContext'; +import { RiskSelection } from './RiskSelection'; + +function OnboardingContent() { + const { step } = useOnboarding(); + + return ( +
+ {step === 'asset-selection' && } + {step === 'risk-selection' && } +
+ ); +} + +export function SmartOnboarding() { + return ( + + + + ); +} diff --git a/app/positions/components/onboarding/SuccessPage.tsx b/app/positions/components/onboarding/SuccessPage.tsx new file mode 100644 index 00000000..8b87ba90 --- /dev/null +++ b/app/positions/components/onboarding/SuccessPage.tsx @@ -0,0 +1,37 @@ +import { Button } from '@nextui-org/react'; +import Link from 'next/link'; +import { FaCheckCircle } from 'react-icons/fa'; +import { useAccount } from 'wagmi'; +import { useOnboarding } from './OnboardingContext'; + +export function SuccessPage() { + const { selectedToken } = useOnboarding(); + const { address } = useAccount(); + + return ( +
+
+
+ +

Success!

+
+

+ Your {selectedToken?.symbol} has been successfully supplied to Morpho Blue. +

+
+ +
+ + + + + + +
+
+ ); +} diff --git a/app/positions/components/onboarding/types.ts b/app/positions/components/onboarding/types.ts new file mode 100644 index 00000000..8ef31e45 --- /dev/null +++ b/app/positions/components/onboarding/types.ts @@ -0,0 +1,21 @@ +import { NetworkToken } from '@/types/token'; +import { Market } from '@/utils/types'; + +export type TokenWithMarkets = NetworkToken & { + markets: Market[]; + minApy: number; + maxApy: number; + logoURI?: string; + balance: string; +}; + +export type OnboardingStep = 'asset-selection' | 'risk-selection' | 'setup'; + +export type OnboardingContextType = { + step: OnboardingStep; + selectedToken?: TokenWithMarkets; + selectedMarkets: Market[]; + setStep: (step: OnboardingStep) => void; + setSelectedToken: (token: TokenWithMarkets) => void; + setSelectedMarkets: (markets: Market[]) => void; +}; diff --git a/app/positions/onboarding/page.tsx b/app/positions/onboarding/page.tsx new file mode 100644 index 00000000..70b6437d --- /dev/null +++ b/app/positions/onboarding/page.tsx @@ -0,0 +1,11 @@ +import { Metadata } from 'next'; +import { OnboardingContent } from '../components/onboarding/OnboardingContent'; + +export const metadata: Metadata = { + title: 'New Position | Monarch', + description: 'Create a new position on Morpho Blue', +}; + +export default function OnboardingPage() { + return ; +} diff --git a/app/rewards/components/RewardContent.tsx b/app/rewards/components/RewardContent.tsx index ab965612..f75e8200 100644 --- a/app/rewards/components/RewardContent.tsx +++ b/app/rewards/components/RewardContent.tsx @@ -21,7 +21,7 @@ export default function Rewards() { const { account } = useParams<{ account: string }>(); const [activeProgram, setActiveProgram] = useState<'market' | 'uniform'>('market'); - const { loading, markets } = useMarkets(); + const { loading, markets } = useMarkets(); const { rewards, distributions, loading: loadingRewards } = useUserRewards(account); console.log('distributions', distributions); diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 508de02b..a85e6db0 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -20,21 +20,23 @@ export default function SettingsPage() {

Settings

- +
{/* Transaction Settings Section */}

Transaction Settings

- -
+ +

Use Gasless Approvals

-

- Enable signature-based token approvals using Permit2. This bundles approvals and actions into a single transaction, saving gas. +

+ Enable signature-based token approvals using Permit2. This bundles approvals and + actions into a single transaction, saving gas.

- Note: If you're using a smart contract wallet (like Safe or other multisig), you may want to disable this and use standard approvals instead. + Note: If you're using a smart contract wallet (like Safe or other multisig), you + may want to disable this and use standard approvals instead.

-
+
); diff --git a/src/components/ButtonGroup.tsx b/src/components/ButtonGroup.tsx index 90e2badb..7ca36287 100644 --- a/src/components/ButtonGroup.tsx +++ b/src/components/ButtonGroup.tsx @@ -57,7 +57,7 @@ export default function ButtonGroup({ variant = 'default', }: ButtonGroupProps) { return ( -
+
{options.map((option, index) => { const isFirst = index === 0; const isLast = index === options.length - 1; @@ -74,8 +74,8 @@ export default function ButtonGroup({ sizeClasses[size], // Position-based styles - isFirst ? 'rounded-l-lg' : '-ml-px', - isLast ? 'rounded-r-lg' : '', + isFirst ? 'rounded-l' : '-ml-px rounded-none', + isLast ? 'rounded-r' : 'rounded-none', // Variant & State styles variant === 'default' diff --git a/src/components/Input/Input.tsx b/src/components/Input/Input.tsx index 21b688fc..5f1ddf7c 100644 --- a/src/components/Input/Input.tsx +++ b/src/components/Input/Input.tsx @@ -65,7 +65,7 @@ export default function Input({ type="number" value={inputAmount} onChange={onInputChange} - className="bg-hovered focus:border-monarch-orange h-10 w-full rounded p-2 focus:outline-none" + className="bg-hovered h-10 w-full rounded p-2 focus:border-primary focus:outline-none" /> + -
- Supplying {supplyAmount} {tokenSymbol} -
+
+

Supply {tokenSymbol}

+

+ {isMultiMarket ? `Supplying to ${supplies.length} markets` : 'Supplying to market'} +

+ + {/* Market details */} +
+ {supplies.map((supply) => { + const collateralToken = findToken( + supply.market.collateralAsset.address, + supply.market.morphoBlue.chain.id, + ); + return ( +
+
+ {collateralToken?.img && ( +
+ {supply.market.collateralAsset.symbol} +
+ )} +
+
+ + {supply.market.collateralAsset.symbol} + + + {formatUnits(BigInt(supply.market.lltv), 16)}% LTV + +
+ + {formatBalance(supply.amount, supply.market.loanAsset.decimals)}{' '} + {tokenSymbol} + +
+
+
+
+ {(supply.market.state.supplyApy * 100).toFixed(2)}% +
+
Supply APY
+
+
+ ); + })} +
-
- {steps.map((step, index) => ( -
-
- {getStepStatus(step.key) === 'done' && } - {getStepStatus(step.key) === 'current' &&
} - {getStepStatus(step.key) === 'undone' && } -
-
-
{step.label}
- {currentStep === step.key && step.detail && ( -
- {step.detail} + {/* Steps */} +
+ {steps.map((step) => { + const status = getStepStatus(step.key); + return ( +
+
+ {status === 'done' ? ( + + ) : status === 'current' ? ( + + ) : ( + + )} +
+
+
{step.label}
+
{step.detail}
+
- )} -
- {index < steps.length - 1 &&
} + ); + })}
- ))} -
-
-
+
+ + + ); } diff --git a/src/components/layout/banner/banner.tsx b/src/components/layout/banner/banner.tsx index 8f66dd6a..258cc0d2 100644 --- a/src/components/layout/banner/banner.tsx +++ b/src/components/layout/banner/banner.tsx @@ -13,7 +13,7 @@ export default function Banner({ pageName, pageUrl, wip }: BannerProps) {
Connect
diff --git a/src/components/layout/header/AccountDropdown.tsx b/src/components/layout/header/AccountDropdown.tsx index 007d9a58..8d955693 100644 --- a/src/components/layout/header/AccountDropdown.tsx +++ b/src/components/layout/header/AccountDropdown.tsx @@ -28,7 +28,7 @@ export function AccountDropdown() { sideOffset={40} className={clsx( 'h-42 inline-flex w-60 flex-col items-start justify-start', - 'bg-surface rounded-lg bg-opacity-90 px-6 pb-2 pt-6 shadow backdrop-blur-2xl', + 'bg-surface rounded bg-opacity-90 px-6 pb-2 pt-6 shadow backdrop-blur-2xl', )} style={DropdownMenuContentStyle} > diff --git a/src/components/layout/header/AccountInfoPanel.tsx b/src/components/layout/header/AccountInfoPanel.tsx index 17fa511a..f25f94d3 100644 --- a/src/components/layout/header/AccountInfoPanel.tsx +++ b/src/components/layout/header/AccountInfoPanel.tsx @@ -4,7 +4,7 @@ import { ExitIcon, ExternalLinkIcon } from '@radix-ui/react-icons'; import { clsx } from 'clsx'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { FiSettings } from "react-icons/fi"; +import { FiSettings } from 'react-icons/fi'; import { useAccount, useDisconnect } from 'wagmi'; import { Avatar } from '@/components/Avatar/Avatar'; import { getSlicedAddress } from '@/utils/address'; @@ -14,7 +14,7 @@ export function AccountInfoPanel() { const { address, chainId } = useAccount(); const { disconnect } = useDisconnect(); const pathname = usePathname(); - + const handleDisconnectWallet = useCallback(() => { disconnect(); }, [disconnect]); @@ -42,7 +42,7 @@ export function AccountInfoPanel() { href="/settings" className={clsx( 'my-4 inline-flex items-center justify-between self-stretch no-underline', - pathname === '/settings' && 'text-primary' + pathname === '/settings' && 'text-primary', )} > Settings @@ -51,7 +51,7 @@ export function AccountInfoPanel() { - -
- Supply {loanToken ? loanToken.symbol : market.loanAsset.symbol} - {loanToken?.img && {loanToken.symbol}} -
+ return ( +
+ {showProcessModal && ( + setShowProcessModal(false)} + tokenSymbol={market.loanAsset.symbol} + useEth={useEth} + usePermit2={usePermit2Setting} + /> + )} + {!showProcessModal && ( +
+
+ -

- {' '} - You are supplying {market.loanAsset.symbol} to the following market:{' '} -

- -
-
-

Market ID:

- -

{market.uniqueKey.slice(2, 8)}

- -
-
-

Collateral Token:

-
-

{market.collateralAsset.symbol}

-
- {collateralToken?.img && ( - {collateralToken.symbol} - )}{' '} -
+
+ Supply {loanToken ? loanToken.symbol : market.loanAsset.symbol} + {loanToken?.img && {loanToken.symbol}}
-
-
-

LLTV:

-

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

-
-
-

Oracle:

- - - -
- -
- {isConnected ? ( -
-
-

My Balance:

-
-

- {useEth - ? formatBalance(ethBalance?.value ? ethBalance.value : '0', 18) - : formatBalance( - tokenBalance?.value ? tokenBalance.value : '0', - market.loanAsset.decimals, - )} -

-

{useEth ? 'ETH' : market.loanAsset.symbol}

-
- {loanToken?.img && ( - {loanToken.symbol} - )}{' '} +

+ {' '} + You are supplying {market.loanAsset.symbol} to the following market:{' '} +

+ +
+
+

Market ID:

+ +

+ {market.uniqueKey.slice(2, 8)} +

+ +
+
+

Collateral Token:

+
+

{market.collateralAsset.symbol}

+
+ {collateralToken?.img && ( + {collateralToken.symbol} + )}{' '} +
+
+

LLTV:

+

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

+
+
+

Oracle:

+ + + +
+
- {loanToken?.symbol === 'WETH' && ( -
-
-
-
Use ETH instead
- + + {isConnected ? ( +
+
+

My Balance:

+
+

+ {useEth + ? formatBalance(ethBalance?.value ? ethBalance.value : '0', 18) + : formatBalance( + tokenBalance?.value ? tokenBalance.value : '0', + market.loanAsset.decimals, + )} +

+

+ {useEth ? 'ETH' : market.loanAsset.symbol}{' '} +

+
+ {loanToken?.img && ( + {loanToken.symbol} + )}{' '} +
+
+
+ {loanToken?.symbol === 'WETH' && ( +
+
+
+
Use ETH instead
+ +
+
+ )} +
+ ) : ( +
+
+
)} -
- ) : ( -
-
- + +
Supply amount
+ +
+
+ + {inputError &&

{inputError}

} +
+ + {needSwitchChain ? ( + + ) : (!permit2Authorized && !useEth) || (!usePermit2Setting && !isApproved) ? ( + + ) : ( + + )}
- )} - -
Supply amount
- -
-
- - {inputError &&

{inputError}

} -
- - {needSwitchChain ? ( - - ) : (!permit2Authorized && !useEth) || (!usePermit2Setting && !isApproved) ? ( - - ) : ( - - )}
-
+ )}
); } diff --git a/src/contexts/MarketsContext.tsx b/src/contexts/MarketsContext.tsx index 6717b524..9483c4dd 100644 --- a/src/contexts/MarketsContext.tsx +++ b/src/contexts/MarketsContext.tsx @@ -1,6 +1,14 @@ 'use client'; -import { createContext, useContext, ReactNode, useCallback, useEffect, useState, useMemo } from 'react'; +import { + createContext, + useContext, + ReactNode, + useCallback, + useEffect, + useState, + useMemo, +} from 'react'; import { marketsQuery } from '@/graphql/queries'; import useLiquidations from '@/hooks/useLiquidations'; import { getRewardPer1000USD } from '@/utils/morpho'; @@ -62,7 +70,7 @@ export function MarketsProvider({ children }: MarketsProviderProps) { variables: { first: 1000, where: { whitelisted: true } }, }), }); - + const marketsResult = (await marketsResponse.json()) as MarketResponse; const rawMarkets = marketsResult.data.markets.items; @@ -161,4 +169,4 @@ export function useMarkets() { throw new Error('useMarkets must be used within a MarketsProvider'); } return context; -} \ No newline at end of file +} diff --git a/src/hooks/useAllowance.ts b/src/hooks/useAllowance.ts index e631bafc..35eaf6a0 100644 --- a/src/hooks/useAllowance.ts +++ b/src/hooks/useAllowance.ts @@ -41,6 +41,7 @@ export function useAllowance({ enabled: !!user && !!spender && !!token, refetchInterval, }, + chainId, }); const { sendTransactionAsync, isConfirming: approvePending } = useTransactionWithToast({ @@ -72,6 +73,9 @@ export function useAllowance({ }, [user, spender, token, sendTransactionAsync, chainIdFromArgumentOrConnectedWallet]); const allowance = data ? data : BigInt(0); + + console.log('data', data); + const isLoadingAllowance = data === undefined; return { allowance, isLoadingAllowance, approveInfinite, approvePending }; diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts index d39fc9fd..f4bbe96f 100644 --- a/src/hooks/useLocalStorage.ts +++ b/src/hooks/useLocalStorage.ts @@ -1,7 +1,10 @@ import { useCallback, useEffect, useState } from 'react'; import storage from 'local-storage-fallback'; -export function useLocalStorage(key: string, initialValue: T): readonly [T, (value: T | ((val: T) => T)) => void] { +export function useLocalStorage( + key: string, + initialValue: T, +): readonly [T, (value: T | ((val: T) => T)) => void] { // State to store our value // Pass initial state function to useState so logic is only executed once const [storedValue, setStoredValue] = useState(() => { diff --git a/src/hooks/useMultiMarketSupply.ts b/src/hooks/useMultiMarketSupply.ts new file mode 100644 index 00000000..fcd06d93 --- /dev/null +++ b/src/hooks/useMultiMarketSupply.ts @@ -0,0 +1,261 @@ +import { useCallback, useState } from 'react'; +import { toast } from 'react-toastify'; +import { Address, encodeFunctionData } from 'viem'; +import { useAccount } from 'wagmi'; +import morphoBundlerAbi from '@/abis/bundlerV2'; +import { usePermit2 } from '@/hooks/usePermit2'; +import { useTransactionWithToast } from '@/hooks/useTransactionWithToast'; +import { NetworkToken } from '@/types/token'; +import { formatBalance } from '@/utils/balance'; +import { getBundlerV2 } from '@/utils/morpho'; +import { SupportedNetworks } from '@/utils/networks'; +import { Market } from '@/utils/types'; +import { useERC20Approval } from './useERC20Approval'; + +export type MarketSupply = { + market: Market; + amount: bigint; +}; + +export function useMultiMarketSupply( + loanAsset: NetworkToken | undefined, + supplies: MarketSupply[], + useEth: boolean, + usePermit2Setting: boolean, +) { + const [currentStep, setCurrentStep] = useState<'approve' | 'signing' | 'supplying'>('approve'); + const [showProcessModal, setShowProcessModal] = useState(false); + + const { address: account } = useAccount(); + const chainId = loanAsset?.network; + const tokenSymbol = loanAsset?.symbol; + const totalAmount = supplies.reduce((sum, supply) => sum + supply.amount, 0n); + + const { + authorizePermit2, + permit2Authorized, + isLoading: isLoadingPermit2, + signForBundlers, + } = usePermit2({ + user: account as `0x${string}`, + spender: getBundlerV2(chainId ?? SupportedNetworks.Mainnet), + token: loanAsset?.address as `0x${string}`, + refetchInterval: 10000, + chainId, + tokenSymbol, + amount: totalAmount, + }); + + const { isApproved, approve } = useERC20Approval({ + token: loanAsset?.address as Address, + spender: getBundlerV2(chainId ?? SupportedNetworks.Mainnet), + amount: totalAmount, + tokenSymbol: loanAsset?.symbol ?? '', + }); + + const { isConfirming: supplyPending, sendTransactionAsync } = useTransactionWithToast({ + toastId: 'multi-supply', + pendingText: `Supplying ${formatBalance( + totalAmount, + loanAsset?.decimals ?? 18, + )} ${tokenSymbol}`, + successText: `${tokenSymbol} Supplied`, + errorText: 'Failed to supply', + chainId, + pendingDescription: `Supplying to ${supplies.length} market${supplies.length > 1 ? 's' : ''}`, + successDescription: `Successfully supplied to ${supplies.length} market${ + supplies.length > 1 ? 's' : '' + }`, + }); + + const executeSupplyTransaction = useCallback(async () => { + if (!account) throw new Error('No account connected'); + if (!loanAsset || !chainId) throw new Error('Invalid loan asset or chain'); + + const txs: `0x${string}`[] = []; + + try { + // Handle ETH wrapping if needed + if (useEth) { + txs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'wrapNative', + args: [totalAmount], + }), + ); + } + // Handle token approvals + else if (usePermit2Setting) { + setCurrentStep('signing'); + const { sigs, permitSingle } = await signForBundlers(); + + txs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'approve2', + args: [permitSingle, sigs, false], + }), + ); + + // transferFrom with permit2 + txs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'transferFrom2', + args: [loanAsset.address as Address, totalAmount], + }), + ); + } else { + // For standard ERC20 flow + txs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'erc20TransferFrom', + args: [loanAsset.address as Address, totalAmount], + }), + ); + } + + setCurrentStep('supplying'); + + // Add supply transactions for each market + for (const supply of supplies) { + const morphoSupplyTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoSupply', + args: [ + { + loanToken: supply.market.loanAsset.address as Address, + collateralToken: supply.market.collateralAsset.address as Address, + oracle: supply.market.oracleAddress as Address, + irm: supply.market.irmAddress as Address, + lltv: BigInt(supply.market.lltv), + }, + supply.amount, + BigInt(0), + BigInt(1), // minShares + account as `0x${string}`, + '0x', // callback + ], + }); + + txs.push(morphoSupplyTx); + } + + // Add timeout 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], + }), + value: useEth ? totalAmount : 0n, + }); + + return true; + } catch (error: unknown) { + console.error('Error in executeSupplyTransaction:', error); + setShowProcessModal(false); + if (error instanceof Error) { + toast.error('Transaction failed or cancelled'); + } else { + toast.error('Transaction failed'); + } + throw error; // Re-throw to be caught by approveAndSupply + } + }, [ + account, + supplies, + totalAmount, + sendTransactionAsync, + useEth, + signForBundlers, + usePermit2Setting, + chainId, + loanAsset, + ]); + + const approveAndSupply = useCallback(async () => { + if (!account) { + toast.error('Please connect your wallet'); + return false; + } + + try { + setShowProcessModal(true); + setCurrentStep('approve'); + + if (useEth) { + setCurrentStep('supplying'); + const success = await executeSupplyTransaction(); + return success; + } + + if (usePermit2Setting && !permit2Authorized) { + try { + await authorizePermit2(); + setCurrentStep('signing'); + + // Small delay to prevent UI glitches + await new Promise((resolve) => setTimeout(resolve, 500)); + } catch (error) { + console.error('Error in Permit2 authorization:', error); + setShowProcessModal(false); + return false; + } + } else if (!usePermit2Setting && !isApproved) { + // Standard ERC20 flow + try { + await approve(); + setCurrentStep('supplying'); + + // Small delay to prevent UI glitches + await new Promise((resolve) => setTimeout(resolve, 1000)); + } catch (error) { + console.error('Error in ERC20 approval:', error); + setShowProcessModal(false); + if (error instanceof Error) { + if (error.message.includes('User rejected')) { + toast.error('Approval rejected by user'); + } else { + toast.error('Failed to approve token'); + } + } else { + toast.error('An unexpected error occurred during approval'); + } + return false; + } + } + + const success = await executeSupplyTransaction(); + return success; + } catch (error) { + console.error('Error in approveAndSupply:', error); + setShowProcessModal(false); + return false; + } + }, [ + account, + usePermit2Setting, + permit2Authorized, + authorizePermit2, + isApproved, + approve, + useEth, + executeSupplyTransaction, + ]); + + return { + approveAndSupply, + currentStep, + showProcessModal, + setShowProcessModal, + supplyPending, + isLoadingPermit2, + }; +} diff --git a/src/hooks/useUserBalances.ts b/src/hooks/useUserBalances.ts new file mode 100644 index 00000000..c6729bd6 --- /dev/null +++ b/src/hooks/useUserBalances.ts @@ -0,0 +1,99 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useAccount } from 'wagmi'; +import { SupportedNetworks } from '@/utils/networks'; +import { findToken } from '@/utils/tokens'; + +type TokenBalance = { + address: string; + balance: string; + chainId: number; + decimals: number; + logoURI?: string; + symbol: string; +}; + +type TokenResponse = { + tokens: { + address: string; + balance: string; + }[]; +}; + +export function useUserBalances() { + const { address } = useAccount(); + const [balances, setBalances] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchBalances = useCallback( + async (chainId: number): Promise => { + try { + const response = await fetch(`/api/balances?address=${address}&chainId=${chainId}`); + if (!response.ok) { + throw new Error('Failed to fetch balances'); + } + const data = (await response.json()) as TokenResponse; + return data.tokens; + } catch (err) { + console.error('Error fetching balances:', err); + throw err instanceof Error ? err : new Error('Unknown error occurred'); + } + }, + [address], + ); + + const fetchAllBalances = useCallback(async () => { + if (!address) return; + + setLoading(true); + setError(null); + + try { + // Fetch balances from both chains + const [mainnetBalances, baseBalances] = await Promise.all([ + fetchBalances(SupportedNetworks.Mainnet), + fetchBalances(SupportedNetworks.Base), + ]); + + // Process and filter tokens + const processedBalances: TokenBalance[] = []; + + const processTokens = (tokens: TokenResponse['tokens'], chainId: number) => { + tokens.forEach((token) => { + const tokenInfo = findToken(token.address, chainId); + if (tokenInfo) { + processedBalances.push({ + address: token.address, + balance: token.balance, + chainId, + decimals: tokenInfo.decimals, + logoURI: tokenInfo.img, + symbol: tokenInfo.symbol, + }); + } + }); + }; + + processTokens(mainnetBalances, 1); + processTokens(baseBalances, 8453); + + setBalances(processedBalances); + } catch (err) { + setError(err instanceof Error ? err : new Error('Unknown error occurred')); + console.error('Error fetching all balances:', err); + } finally { + setLoading(false); + } + }, [address, fetchBalances]); + + useEffect(() => { + void fetchAllBalances(); + }, [fetchAllBalances]); + + return { + balances, + loading, + error, + refetch: fetchAllBalances, + }; +} diff --git a/src/hooks/useUserPositions.ts b/src/hooks/useUserPositions.ts index b380a311..a03eea0f 100644 --- a/src/hooks/useUserPositions.ts +++ b/src/hooks/useUserPositions.ts @@ -33,7 +33,7 @@ const useUserPositions = (user: string | undefined) => { body: JSON.stringify({ query: userPositionsQuery, variables: { - address: user, + address: user.toLowerCase(), chainId: SupportedNetworks.Mainnet, }, }), @@ -46,7 +46,7 @@ const useUserPositions = (user: string | undefined) => { body: JSON.stringify({ query: userPositionsQuery, variables: { - address: user, + address: user.toLowerCase(), chainId: SupportedNetworks.Base, }, }), diff --git a/src/store/createWagmiConfig.ts b/src/store/createWagmiConfig.ts index c3566601..3e4a9b62 100644 --- a/src/store/createWagmiConfig.ts +++ b/src/store/createWagmiConfig.ts @@ -26,7 +26,14 @@ export function createWagmiConfig(projectId: string) { }, { groupName: 'Other Wallets', - wallets: [rainbowWallet, coinbaseWallet, metaMaskWallet, safeWallet, argentWallet, injectedWallet], + wallets: [ + rainbowWallet, + coinbaseWallet, + metaMaskWallet, + safeWallet, + argentWallet, + injectedWallet, + ], }, ], { diff --git a/src/types/token.ts b/src/types/token.ts new file mode 100644 index 00000000..8c17bb24 --- /dev/null +++ b/src/types/token.ts @@ -0,0 +1,16 @@ +import { SupportedNetworks } from '@/utils/networks'; + +/** + * Represents a token with fixed network and address information + * Used for consistent token identification across the application + */ +export type NetworkToken = { + /** Token symbol (e.g., "WETH", "USDC") */ + symbol: string; + /** Token decimals for amount formatting */ + decimals: number; + /** Network address where this token exists */ + network: SupportedNetworks; + /** Token contract address on the network */ + address: string; +}; diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts index 54a1f28f..38d27c42 100644 --- a/src/utils/tokens.ts +++ b/src/utils/tokens.ts @@ -147,7 +147,7 @@ const supportedTokens = [ { symbol: 'EURC', img: require('../imgs/tokens/eurc.png') as string, - decimals: 18, + decimals: 6, networks: [ { chain: mainnet, address: '0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c' }, { chain: base, address: '0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42' }, diff --git a/tailwind.config.ts b/tailwind.config.ts index 1837ea02..1f31c599 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -2,6 +2,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import type { Config } from 'tailwindcss'; +import plugin from 'tailwindcss/plugin'; const { nextui } = require('@nextui-org/theme'); const config: Config = { @@ -52,7 +53,37 @@ const config: Config = { }, }, darkMode: 'class', - plugins: [nextui()], + plugins: [ + nextui({ + themes: { + light: { + layout: { + radius: { + small: '0.375rem', // rounded + medium: '0.375rem', // rounded + large: '0.375rem', // rounded + }, + }, + }, + dark: { + layout: { + radius: { + small: '0.375rem', // rounded + medium: '0.375rem', // rounded + large: '0.375rem', // rounded + }, + }, + }, + }, + }), + plugin(function ({ addBase }) { + addBase({ + 'button, .nextui-button': { + '@apply rounded': {}, // This makes all buttons rounded by default + }, + }); + }), + ], }; export default config;