From 9c8ce8733f77c4647efefeadf914b00292021da6 Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Thu, 21 Nov 2024 12:10:29 +0800 Subject: [PATCH 01/16] feat: onboarding basics --- .env.test | 5 +- app/api/balances/route.ts | 69 +++++ app/positions/components/PositionsContent.tsx | 32 ++- app/positions/components/SmartOnboarding.tsx | 14 + .../components/onboarding/AssetSelection.tsx | 120 +++++++++ .../onboarding/OnboardingContext.tsx | 47 ++++ .../components/onboarding/RiskSelection.tsx | 248 ++++++++++++++++++ .../components/onboarding/SetupPositions.tsx | 175 ++++++++++++ .../components/onboarding/SmartOnboarding.tsx | 22 ++ app/positions/components/onboarding/types.ts | 22 ++ app/positions/onboarding/page.tsx | 39 +++ src/hooks/useUserBalances.ts | 88 +++++++ src/utils/tokens.ts | 2 +- 13 files changed, 872 insertions(+), 11 deletions(-) create mode 100644 app/api/balances/route.ts create mode 100644 app/positions/components/SmartOnboarding.tsx create mode 100644 app/positions/components/onboarding/AssetSelection.tsx create mode 100644 app/positions/components/onboarding/OnboardingContext.tsx create mode 100644 app/positions/components/onboarding/RiskSelection.tsx create mode 100644 app/positions/components/onboarding/SetupPositions.tsx create mode 100644 app/positions/components/onboarding/SmartOnboarding.tsx create mode 100644 app/positions/components/onboarding/types.ts create mode 100644 app/positions/onboarding/page.tsx create mode 100644 src/hooks/useUserBalances.ts 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..741eeb07 --- /dev/null +++ b/app/api/balances/route.ts @@ -0,0 +1,69 @@ +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(); + 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) + })); + + console.log('user tokens', tokens); + + 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/positions/components/PositionsContent.tsx b/app/positions/components/PositionsContent.tsx index aecd1539..5c45a422 100644 --- a/app/positions/components/PositionsContent.tsx +++ b/app/positions/components/PositionsContent.tsx @@ -1,8 +1,8 @@ 'use client'; import { useState } from 'react'; -import Link from 'next/link'; import { useParams } from 'next/navigation'; +import Link from 'next/link'; import PrimaryButton from '@/components/common/PrimaryButton'; import Header from '@/components/layout/header/Header'; import EmptyScreen from '@/components/Status/EmptyScreen'; @@ -10,7 +10,6 @@ import LoadingScreen from '@/components/Status/LoadingScreen'; import { SupplyModal } from '@/components/supplyModal'; import { WithdrawModal } from '@/components/withdrawModal'; import useUserPositions from '@/hooks/useUserPositions'; - import { MarketPosition } from '@/utils/types'; import { PositionsSummaryTable } from './PositionsSummaryTable'; @@ -23,7 +22,7 @@ export default function Positions() { const { loading, isRefetching, data: marketPositions, refetch } = useUserPositions(account); - const hasSuppliedMarkets = marketPositions.length > 0; + const hasSuppliedMarkets = marketPositions && marketPositions.length > 0; return (
@@ -48,6 +47,14 @@ export default function Positions() { View Rewards + + +
@@ -75,7 +82,17 @@ export default function Positions() { {loading ? ( ) : !hasSuppliedMarkets ? ( - +
+ + + + +
) : (
- View All Markets -
-
- - Search Address + + View All Markets
diff --git a/app/positions/components/SmartOnboarding.tsx b/app/positions/components/SmartOnboarding.tsx new file mode 100644 index 00000000..019fb7a1 --- /dev/null +++ b/app/positions/components/SmartOnboarding.tsx @@ -0,0 +1,14 @@ +import { AssetSelection } from './onboarding/AssetSelection'; +import { RiskSelection } from './onboarding/RiskSelection'; +import { OnboardingProvider } from './onboarding/OnboardingContext'; + +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..1943b737 --- /dev/null +++ b/app/positions/components/onboarding/AssetSelection.tsx @@ -0,0 +1,120 @@ +import { useMemo } from 'react'; +import Image from 'next/image'; +import { useMarkets } from '@/hooks/useMarkets'; +import { useUserBalances } from '@/hooks/useUserBalances'; +import { formatBalance } from '@/utils/balance'; +import { TokenWithMarkets } from './types'; +import { useOnboarding } from './OnboardingContext'; +import { useRouter } from 'next/navigation'; +import { Button } from '@nextui-org/react'; +import Link from 'next/link'; + +export function AssetSelection() { + const { balances, loading: balancesLoading } = useUserBalances(); + const { markets, loading: marketsLoading } = useMarkets(); + const { setSelectedToken } = 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 === 1 ? 'Mainnet' : 'Base'; + + result.push({ + symbol: balance.symbol, + balance: balance.balance, + chainId: balance.chainId, + markets: relevantMarkets, + minApy, + maxApy, + logoURI: balance.logoURI, + decimals: balance.decimals, + network + }); + }); + + return result.sort((a, b) => b.markets.length - a.markets.length); + }, [balances, markets]); + + const handleTokenSelect = (token: TokenWithMarkets) => { + setSelectedToken(token); + router.push('/positions/onboarding?step=risk-selection'); + }; + + if (balancesLoading || marketsLoading) { + return ( +
+
+
+ ); + } + + return ( +
+
+

Select Asset to Lend

+

Choose which token you want to lend

+
+
+ {tokensWithMarkets.map((token) => ( + + ))} +
+
+ ); +} diff --git a/app/positions/components/onboarding/OnboardingContext.tsx b/app/positions/components/onboarding/OnboardingContext.tsx new file mode 100644 index 00000000..ce4b6820 --- /dev/null +++ b/app/positions/components/onboarding/OnboardingContext.tsx @@ -0,0 +1,47 @@ +import { createContext, useContext, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { TokenWithMarkets } from './types'; + +type OnboardingContextType = { + selectedToken: TokenWithMarkets | null; + setSelectedToken: (token: TokenWithMarkets | null) => void; + step: 'asset-selection' | 'risk-selection'; + setStep: (step: 'asset-selection' | 'risk-selection') => void; +}; + +const OnboardingContext = createContext(null); + +export function OnboardingProvider({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const searchParams = useSearchParams(); + const currentStep = searchParams.get('step') as 'asset-selection' | 'risk-selection' || 'asset-selection'; + + const [selectedToken, setSelectedToken] = useState(null); + + const setStep = (newStep: 'asset-selection' | 'risk-selection') => { + const params = new URLSearchParams(searchParams.toString()); + params.set('step', newStep); + router.push(`/positions/onboarding?${params.toString()}`); + }; + + 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..894fd544 --- /dev/null +++ b/app/positions/components/onboarding/RiskSelection.tsx @@ -0,0 +1,248 @@ +import { useMemo, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useOnboarding } from './OnboardingContext'; +import { findToken, getUniqueTokens } from '@/utils/tokens'; +import { OracleVendors, parseOracleVendors } from '@/utils/oracle'; +import AssetFilter from 'app/markets/components/AssetFilter'; +import OracleFilter from 'app/markets/components/OracleFilter'; +import Image from 'next/image'; +import { MarketDebtIndicator, MarketAssetIndicator, MarketOracleIndicator } from 'app/markets/components/RiskIndicator'; +import { formatBalance, formatReadable } from '@/utils/balance'; +import { formatUnits } from 'viem'; +import { getAssetURL } from '@/utils/external'; +import { Button } from '@nextui-org/react'; +import { Market } from '@/utils/types'; +import OracleVendorBadge from '@/components/OracleVendorBadge'; + +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()); + + if (!selectedToken) { + router.push('/positions/onboarding?step=asset-selection'); + return null; + } + + // Get unique collateral tokens from markets + 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 */} +
+
+ +
+
+ +
+
+ + {/* 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 border-b border-gray-200 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' : '' + }`} + > + + + + + + + + + + ); + })} + +
MarketRisk IndicatorsOracleLLTVSupply APYTotal SupplyUtilizationActions
+ + +
+ + + +
+
+
+ {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..e92f7d9e --- /dev/null +++ b/app/positions/components/onboarding/SetupPositions.tsx @@ -0,0 +1,175 @@ +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useOnboarding } from './OnboardingContext'; +import { formatBalance, formatReadable } from '@/utils/balance'; +import { formatUnits, parseUnits } from 'viem'; +import { Button } from '@nextui-org/react'; +import Image from 'next/image'; +import { useUserBalances } from '@/hooks/useUserBalances'; +import { findToken } from '@/utils/tokens'; + +export function SetupPositions() { + const router = useRouter(); + const { selectedToken, selectedMarkets } = useOnboarding(); + const { balances } = useUserBalances(); + const [amounts, setAmounts] = useState>({}); + const [error, setError] = useState(null); + + if (!selectedToken || !selectedMarkets || selectedMarkets.length === 0) { + router.push('/positions/onboarding?step=risk-selection'); + return null; + } + + const tokenBalance = balances?.[selectedToken.address.toLowerCase()]?.balance || 0n; + const tokenDecimals = selectedToken.decimals; + + const handleAmountChange = (marketKey: string, 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; + + setAmounts(prev => ({ ...prev, [marketKey]: cleanValue })); + + // Validate total amount + try { + const totalBigInt = Object.values(amounts).reduce((acc, val) => { + if (!val) return acc; + return acc + parseUnits(val, tokenDecimals); + }, 0n); + + if (totalBigInt > tokenBalance) { + setError('Total amount exceeds balance'); + } else { + setError(null); + } + } catch (e) { + // Handle parsing errors + setError('Invalid amount'); + } + }; + + const handleNext = () => { + if (error) return; + + // Convert amounts to BigInt and validate + const positions = selectedMarkets.map(market => ({ + market, + amount: amounts[market.uniqueKey] + ? parseUnits(amounts[market.uniqueKey], tokenDecimals) + : 0n + })); + + // TODO: Handle position creation + console.log('Creating positions:', positions); + }; + + return ( +
+
+

Setup Your Positions

+

+ Choose how much {selectedToken.symbol} you want to supply to each market +

+
+ +
+
+ Available Balance: + + {formatBalance(tokenBalance, tokenDecimals)} {selectedToken.symbol} + +
+
+ +
+
+ + + + + + + + + + {selectedMarkets.map((market) => { + const collateralToken = findToken(market.collateralAsset.address, market.morphoBlue.chain.id); + if (!collateralToken) return null; + + return ( + + + + + + ); + })} + +
MarketSupply APYAmount
+
+ {collateralToken?.img && ( + {market.collateralAsset.symbol} + )} +
+ {market.collateralAsset.symbol} + as collateral +
+
+
+ {formatReadable(market.state.supplyApy * 100)}% + +
+ handleAmountChange(market.uniqueKey, e.target.value)} + placeholder="0.0" + className="w-36 rounded-md border border-gray-300 bg-transparent px-3 py-1 text-right font-mono dark:border-gray-700" + /> + {selectedToken.symbol} +
+
+
+
+ + {error && ( +
+ {error} +
+ )} + + {/* Navigation */} +
+ + +
+
+ ); +} diff --git a/app/positions/components/onboarding/SmartOnboarding.tsx b/app/positions/components/onboarding/SmartOnboarding.tsx new file mode 100644 index 00000000..b1fbd33b --- /dev/null +++ b/app/positions/components/onboarding/SmartOnboarding.tsx @@ -0,0 +1,22 @@ +import { OnboardingProvider, useOnboarding } from './OnboardingContext'; +import { AssetSelection } from './AssetSelection'; +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/types.ts b/app/positions/components/onboarding/types.ts new file mode 100644 index 00000000..f80e210c --- /dev/null +++ b/app/positions/components/onboarding/types.ts @@ -0,0 +1,22 @@ +import { Market } from '@/utils/types'; + +export type TokenWithMarkets = { + symbol: string; + balance: string; + chainId: number; + markets: Market[]; + minApy: number; + maxApy: number; + logoURI?: string; + decimals: number; + network: string; +}; + +export type OnboardingStep = 'asset-selection' | 'risk-selection'; + +export type OnboardingContextType = { + step: OnboardingStep; + selectedToken?: TokenWithMarkets; + setStep: (step: OnboardingStep) => void; + setSelectedToken: (token: TokenWithMarkets) => void; +}; diff --git a/app/positions/onboarding/page.tsx b/app/positions/onboarding/page.tsx new file mode 100644 index 00000000..5e02975c --- /dev/null +++ b/app/positions/onboarding/page.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { OnboardingProvider } from '../components/onboarding/OnboardingContext'; +import { AssetSelection } from '../components/onboarding/AssetSelection'; +import { RiskSelection } from '../components/onboarding/RiskSelection'; +import { SetupPositions } from '../components/onboarding/SetupPositions'; +import Header from '@/components/layout/header/Header'; + +export default function OnboardingPage() { + 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 ; + default: + return ; + } + }; + + return ( +
+
+
+ +
+ {renderStep()} +
+
+
+
+ ); +} diff --git a/src/hooks/useUserBalances.ts b/src/hooks/useUserBalances.ts new file mode 100644 index 00000000..584e7d0f --- /dev/null +++ b/src/hooks/useUserBalances.ts @@ -0,0 +1,88 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useAccount } from 'wagmi'; +import { findToken } from '@/utils/tokens'; + +type TokenBalance = { + address: string; + balance: string; + chainId: number; + decimals: number; + logoURI?: string; + symbol: 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) => { + 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(); + return data.tokens; + } catch (err) { + console.error('Error fetching balances:', err); + throw err; + } + }, [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(1), + fetchBalances(8453) + ]); + + // Process and filter tokens + const processedBalances: TokenBalance[] = []; + + const processTokens = (tokens: any[], 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 as Error); + console.error('Error fetching all balances:', err); + } finally { + setLoading(false); + } + }, [address, fetchBalances]); + + useEffect(() => { + fetchAllBalances(); + }, [fetchAllBalances]); + + return { + balances, + loading, + error, + refetch: fetchAllBalances + }; +} 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' }, From 523f44dbba64124799276efbcc2d6669c5fb814f Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Thu, 21 Nov 2024 13:01:38 +0800 Subject: [PATCH 02/16] chore: onboarding input done --- .../components/onboarding/AssetSelection.tsx | 100 +++++----- .../onboarding/OnboardingContext.tsx | 16 +- .../components/onboarding/RiskSelection.tsx | 28 ++- .../components/onboarding/SetupPositions.tsx | 176 ++++++++++++++---- app/positions/components/onboarding/types.ts | 5 +- 5 files changed, 224 insertions(+), 101 deletions(-) diff --git a/app/positions/components/onboarding/AssetSelection.tsx b/app/positions/components/onboarding/AssetSelection.tsx index 1943b737..40879f63 100644 --- a/app/positions/components/onboarding/AssetSelection.tsx +++ b/app/positions/components/onboarding/AssetSelection.tsx @@ -12,7 +12,7 @@ import Link from 'next/link'; export function AssetSelection() { const { balances, loading: balancesLoading } = useUserBalances(); const { markets, loading: marketsLoading } = useMarkets(); - const { setSelectedToken } = useOnboarding(); + const { setSelectedToken, setSelectedMarkets } = useOnboarding(); const router = useRouter(); const tokensWithMarkets = useMemo(() => { @@ -46,75 +46,85 @@ export function AssetSelection() { maxApy, logoURI: balance.logoURI, decimals: balance.decimals, - network + network, + address: balance.address }); }); - return result.sort((a, b) => b.markets.length - a.markets.length); + 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 Asset to Lend

-

Choose which token you want to lend

+

Select an Asset

+

Choose which asset you want to supply

-
- {tokensWithMarkets.map((token) => ( - + +
+ ) : ( +
+ {tokensWithMarkets.map((token) => ( +
-
-

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

-

- APY Range: {token.minApy.toFixed(2)}% - {token.maxApy.toFixed(2)}% -

-
- - ))} -
+ + ))} +
+ )}
); } diff --git a/app/positions/components/onboarding/OnboardingContext.tsx b/app/positions/components/onboarding/OnboardingContext.tsx index ce4b6820..a9a00943 100644 --- a/app/positions/components/onboarding/OnboardingContext.tsx +++ b/app/positions/components/onboarding/OnboardingContext.tsx @@ -1,12 +1,17 @@ import { createContext, useContext, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { TokenWithMarkets } from './types'; +import { Market } from '@/utils/types'; + +type OnboardingStep = 'asset-selection' | 'risk-selection' | 'setup'; type OnboardingContextType = { selectedToken: TokenWithMarkets | null; setSelectedToken: (token: TokenWithMarkets | null) => void; - step: 'asset-selection' | 'risk-selection'; - setStep: (step: 'asset-selection' | 'risk-selection') => void; + selectedMarkets: Market[]; + setSelectedMarkets: (markets: Market[]) => void; + step: OnboardingStep; + setStep: (step: OnboardingStep) => void; }; const OnboardingContext = createContext(null); @@ -14,11 +19,12 @@ const OnboardingContext = createContext(null); export function OnboardingProvider({ children }: { children: React.ReactNode }) { const router = useRouter(); const searchParams = useSearchParams(); - const currentStep = searchParams.get('step') as 'asset-selection' | 'risk-selection' || 'asset-selection'; + const currentStep = (searchParams.get('step') as OnboardingStep) || 'asset-selection'; const [selectedToken, setSelectedToken] = useState(null); + const [selectedMarkets, setSelectedMarkets] = useState([]); - const setStep = (newStep: 'asset-selection' | 'risk-selection') => { + const setStep = (newStep: OnboardingStep) => { const params = new URLSearchParams(searchParams.toString()); params.set('step', newStep); router.push(`/positions/onboarding?${params.toString()}`); @@ -29,6 +35,8 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) value={{ selectedToken, setSelectedToken, + selectedMarkets, + setSelectedMarkets, step: currentStep, setStep, }} diff --git a/app/positions/components/onboarding/RiskSelection.tsx b/app/positions/components/onboarding/RiskSelection.tsx index 894fd544..5750be07 100644 --- a/app/positions/components/onboarding/RiskSelection.tsx +++ b/app/positions/components/onboarding/RiskSelection.tsx @@ -130,7 +130,7 @@ export function RiskSelection() { Market - Risk Indicators + Market Params Oracle LLTV Supply APY @@ -169,15 +169,23 @@ export function RiskSelection() { )} diff --git a/app/positions/components/onboarding/SetupPositions.tsx b/app/positions/components/onboarding/SetupPositions.tsx index e92f7d9e..656f5255 100644 --- a/app/positions/components/onboarding/SetupPositions.tsx +++ b/app/positions/components/onboarding/SetupPositions.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useOnboarding } from './OnboardingContext'; import { formatBalance, formatReadable } from '@/utils/balance'; @@ -7,11 +7,15 @@ import { Button } from '@nextui-org/react'; import Image from 'next/image'; import { useUserBalances } from '@/hooks/useUserBalances'; import { findToken } from '@/utils/tokens'; +import { parseOracleVendors } from '@/utils/oracle'; +import OracleVendorBadge from '@/components/OracleVendorBadge'; +import { Market } from '@/utils/types'; export function SetupPositions() { const router = useRouter(); const { selectedToken, selectedMarkets } = useOnboarding(); const { balances } = useUserBalances(); + const [totalAmount, setTotalAmount] = useState(''); const [amounts, setAmounts] = useState>({}); const [error, setError] = useState(null); @@ -20,9 +24,46 @@ export function SetupPositions() { return null; } - const tokenBalance = balances?.[selectedToken.address.toLowerCase()]?.balance || 0n; + const tokenBalance = BigInt(balances.find(b => b.address.toLowerCase() === selectedToken.address.toLowerCase())?.balance || '0') || 0n; const tokenDecimals = selectedToken.decimals; + // Initialize amounts evenly + useEffect(() => { + if (selectedMarkets.length > 0 && totalAmount) { + const evenAmount = Number(totalAmount) / selectedMarkets.length; + const initialAmounts = selectedMarkets.reduce((acc, market) => { + acc[market.uniqueKey] = evenAmount.toFixed(tokenDecimals); + return acc; + }, {} as Record); + setAmounts(initialAmounts); + } + }, [selectedMarkets.length, totalAmount, 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; + + setTotalAmount(cleanValue); + + try { + const amountBigInt = parseUnits(cleanValue || '0', tokenDecimals); + if (amountBigInt > tokenBalance) { + setError('Amount exceeds balance'); + } else { + setError(null); + } + } catch (e) { + setError('Invalid amount'); + } + }; + const handleAmountChange = (marketKey: string, value: string) => { // Remove any non-numeric characters except decimal point const cleanValue = value.replace(/[^0-9.]/g, ''); @@ -34,33 +75,45 @@ export function SetupPositions() { // Limit decimal places to token decimals if (parts[1] && parts[1].length > tokenDecimals) return; - setAmounts(prev => ({ ...prev, [marketKey]: cleanValue })); + const newAmounts = { ...amounts, [marketKey]: cleanValue }; + setAmounts(newAmounts); - // Validate total amount + // Calculate and update total try { - const totalBigInt = Object.values(amounts).reduce((acc, val) => { - if (!val) return acc; - return acc + parseUnits(val, tokenDecimals); - }, 0n); + const total = Object.values(newAmounts).reduce((sum, val) => sum + (Number(val) || 0), 0); + setTotalAmount(total.toFixed(tokenDecimals)); + const totalBigInt = parseUnits(total.toFixed(tokenDecimals), tokenDecimals); if (totalBigInt > tokenBalance) { setError('Total amount exceeds balance'); } else { setError(null); } } catch (e) { - // Handle parsing errors setError('Invalid amount'); } }; + const handleSetMax = () => { + const maxAmount = formatUnits(tokenBalance, tokenDecimals); + setTotalAmount(maxAmount); + + // Distribute evenly + const evenAmount = Number(maxAmount) / selectedMarkets.length; + const newAmounts = selectedMarkets.reduce((acc, market) => { + acc[market.uniqueKey] = evenAmount.toFixed(tokenDecimals); + return acc; + }, {} as Record); + setAmounts(newAmounts); + }; + const handleNext = () => { if (error) return; // Convert amounts to BigInt and validate const positions = selectedMarkets.map(market => ({ market, - amount: amounts[market.uniqueKey] + amount: amounts[market.uniqueKey] ? parseUnits(amounts[market.uniqueKey], tokenDecimals) : 0n })); @@ -74,49 +127,78 @@ export function SetupPositions() {

Setup Your Positions

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

-
-
- Available Balance: - - {formatBalance(tokenBalance, tokenDecimals)} {selectedToken.symbol} - + {/* Total Amount Section */} +
+
+
+ +
+ handleTotalAmountChange(e.target.value)} + placeholder="0.0" + className="bg-hovered focus:border-monarch-orange h-10 w-full rounded p-2 font-mono focus:outline-none" + /> + +
+
+
+ Available 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); + return ( - - + + - + -
MarketSupply APYAmountMarket IDCollateralRisk IndicatorsSupply APYAmount
+
+ {market.uniqueKey.slice(2, 8)} +
{collateralToken?.img && ( - {market.collateralAsset.symbol} +
+ {market.collateralAsset.symbol} +
)}
{market.collateralAsset.symbol} @@ -124,17 +206,29 @@ export function SetupPositions() {
+ +
+
+ {vendors.map((vendor) => ( + + ))} +
+ + {formatUnits(BigInt(market.lltv), 16)}% LTV + +
+
{formatReadable(market.state.supplyApy * 100)}% +
handleAmountChange(market.uniqueKey, e.target.value)} placeholder="0.0" - className="w-36 rounded-md border border-gray-300 bg-transparent px-3 py-1 text-right font-mono dark:border-gray-700" + className="bg-hovered focus:border-monarch-orange h-8 w-36 rounded p-2 text-right font-mono focus:outline-none" /> {selectedToken.symbol}
@@ -154,7 +248,7 @@ export function SetupPositions() { )} {/* Navigation */} -
+
diff --git a/app/positions/components/onboarding/types.ts b/app/positions/components/onboarding/types.ts index f80e210c..9507abdb 100644 --- a/app/positions/components/onboarding/types.ts +++ b/app/positions/components/onboarding/types.ts @@ -10,13 +10,16 @@ export type TokenWithMarkets = { logoURI?: string; decimals: number; network: string; + address: string; }; -export type OnboardingStep = 'asset-selection' | 'risk-selection'; +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; }; From c47284f0e960ea3113dc010026159642b15e8f5e Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Thu, 21 Nov 2024 15:58:41 +0800 Subject: [PATCH 03/16] chore: onboarding flow done --- app/api/balances/route.ts | 26 +- app/history/components/HistoryTable.tsx | 1 + app/info/components/info.tsx | 6 +- app/info/components/sectionData.tsx | 2 +- app/markets/components/markets.tsx | 4 +- app/positions/components/PositionsContent.tsx | 2 +- .../components/RebalanceActionInput.tsx | 2 +- app/positions/components/RebalanceModal.tsx | 8 +- app/positions/components/SmartOnboarding.tsx | 2 +- .../components/onboarding/AssetSelection.tsx | 107 +++-- .../onboarding/OnboardingContext.tsx | 4 +- .../components/onboarding/RiskSelection.tsx | 131 +++--- .../components/onboarding/SetupPositions.tsx | 389 +++++++++++++----- .../components/onboarding/SmartOnboarding.tsx | 2 +- .../components/onboarding/SuccessPage.tsx | 63 +++ app/positions/components/onboarding/types.ts | 2 +- app/positions/onboarding/page.tsx | 13 +- app/rewards/components/RewardContent.tsx | 2 +- app/settings/page.tsx | 16 +- src/components/ButtonGroup.tsx | 2 +- src/components/RiskNotificationModal.tsx | 2 +- src/components/SupplyProcessModal.tsx | 159 +++++-- src/components/layout/banner/banner.tsx | 2 +- .../layout/header/AccountConnect.tsx | 2 +- .../layout/header/AccountDropdown.tsx | 2 +- .../layout/header/AccountInfoPanel.tsx | 8 +- src/components/layout/header/Navbar.tsx | 8 +- src/components/providers/ClientProviders.tsx | 2 +- src/components/supplyModal.tsx | 360 ++++++++-------- src/contexts/MarketsContext.tsx | 14 +- src/hooks/useLocalStorage.ts | 5 +- src/hooks/useMultiMarketSupply.ts | 207 ++++++++++ src/hooks/useUserBalances.ts | 37 +- src/store/createWagmiConfig.ts | 9 +- 34 files changed, 1132 insertions(+), 469 deletions(-) create mode 100644 app/positions/components/onboarding/SuccessPage.tsx create mode 100644 src/hooks/useMultiMarketSupply.ts diff --git a/app/api/balances/route.ts b/app/api/balances/route.ts index 741eeb07..dd523c04 100644 --- a/app/api/balances/route.ts +++ b/app/api/balances/route.ts @@ -29,16 +29,16 @@ export async function GET(req: NextRequest) { // Get token balances const balancesResponse = await fetch(alchemyUrl, { method: 'POST', - headers: { - 'accept': 'application/json', - 'content-type': 'application/json' + headers: { + accept: 'application/json', + 'content-type': 'application/json', }, body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'alchemy_getTokenBalances', - params: [address] - }) + params: [address], + }), }); if (!balancesResponse.ok) { @@ -47,21 +47,23 @@ export async function GET(req: NextRequest) { const balancesData = await balancesResponse.json(); const nonZeroBalances: TokenBalance[] = balancesData.result.tokenBalances.filter( - (token: TokenBalance) => token.tokenBalance !== '0x0000000000000000000000000000000000000000000000000000000000000000' + (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) - })); + const tokens = nonZeroBalances + .filter((token) => token !== null) + .map((token) => ({ + address: token.contractAddress.toLowerCase(), + balance: BigInt(token.tokenBalance).toString(10), + })); console.log('user tokens', tokens); return NextResponse.json({ - tokens + 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/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/markets.tsx b/app/markets/components/markets.tsx index 14b4c3f7..3009de8e 100644 --- a/app/markets/components/markets.tsx +++ b/app/markets/components/markets.tsx @@ -339,13 +339,13 @@ export default function Markets() { />
-
+
-
+

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 index 019fb7a1..64226b9c 100644 --- a/app/positions/components/SmartOnboarding.tsx +++ b/app/positions/components/SmartOnboarding.tsx @@ -1,6 +1,6 @@ import { AssetSelection } from './onboarding/AssetSelection'; -import { RiskSelection } from './onboarding/RiskSelection'; 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 index 40879f63..95d84e3a 100644 --- a/app/positions/components/onboarding/AssetSelection.tsx +++ b/app/positions/components/onboarding/AssetSelection.tsx @@ -1,13 +1,28 @@ 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 { TokenWithMarkets } from './types'; +import { getNetworkImg, getNetworkName } from '@/utils/networks'; import { useOnboarding } from './OnboardingContext'; -import { useRouter } from 'next/navigation'; -import { Button } from '@nextui-org/react'; -import Link from 'next/link'; +import { TokenWithMarkets } from './types'; + +function NetworkIcon({ networkId }: { networkId: number }) { + const url = getNetworkImg(networkId); + return ( + {`networkId-${networkId}`} + ); +} export function AssetSelection() { const { balances, loading: balancesLoading } = useUserBalances(); @@ -19,23 +34,24 @@ export function AssetSelection() { if (!balances || !markets) return []; const result: TokenWithMarkets[] = []; - - balances.forEach(balance => { + + 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() + 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 apys = relevantMarkets.map((market) => market.state.supplyApy); const minApy = Math.min(...apys); const maxApy = Math.max(...apys); // Get network name - const network = balance.chainId === 1 ? 'Mainnet' : 'Base'; + const network = balance.chainId; result.push({ symbol: balance.symbol, @@ -47,7 +63,7 @@ export function AssetSelection() { logoURI: balance.logoURI, decimals: balance.decimals, network, - address: balance.address + address: balance.address, }); }); @@ -80,9 +96,11 @@ export function AssetSelection() {

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

No assets available

-

You need to have some assets in your wallet to supply

+

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

@@ -90,38 +108,57 @@ export function AssetSelection() { ) : (
{tokensWithMarkets.map((token) => ( - + ))}
)} diff --git a/app/positions/components/onboarding/OnboardingContext.tsx b/app/positions/components/onboarding/OnboardingContext.tsx index a9a00943..c838ae5d 100644 --- a/app/positions/components/onboarding/OnboardingContext.tsx +++ b/app/positions/components/onboarding/OnboardingContext.tsx @@ -1,7 +1,7 @@ import { createContext, useContext, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; -import { TokenWithMarkets } from './types'; import { Market } from '@/utils/types'; +import { TokenWithMarkets } from './types'; type OnboardingStep = 'asset-selection' | 'risk-selection' | 'setup'; @@ -20,7 +20,7 @@ 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([]); diff --git a/app/positions/components/onboarding/RiskSelection.tsx b/app/positions/components/onboarding/RiskSelection.tsx index 5750be07..6b6f3e23 100644 --- a/app/positions/components/onboarding/RiskSelection.tsx +++ b/app/positions/components/onboarding/RiskSelection.tsx @@ -1,18 +1,22 @@ import { useMemo, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { useOnboarding } from './OnboardingContext'; -import { findToken, getUniqueTokens } from '@/utils/tokens'; -import { OracleVendors, parseOracleVendors } from '@/utils/oracle'; -import AssetFilter from 'app/markets/components/AssetFilter'; -import OracleFilter from 'app/markets/components/OracleFilter'; +import { Button } from '@nextui-org/react'; import Image from 'next/image'; -import { MarketDebtIndicator, MarketAssetIndicator, MarketOracleIndicator } from 'app/markets/components/RiskIndicator'; -import { formatBalance, formatReadable } from '@/utils/balance'; +import { useRouter, useSearchParams } from 'next/navigation'; import { formatUnits } from 'viem'; +import OracleVendorBadge from '@/components/OracleVendorBadge'; +import { formatBalance, formatReadable } from '@/utils/balance'; import { getAssetURL } from '@/utils/external'; -import { Button } from '@nextui-org/react'; +import { OracleVendors, parseOracleVendors } from '@/utils/oracle'; +import { findToken, getUniqueTokens } from '@/utils/tokens'; import { Market } from '@/utils/types'; -import OracleVendorBadge from '@/components/OracleVendorBadge'; +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(); @@ -30,9 +34,9 @@ export function RiskSelection() { // Get unique collateral tokens from markets const collateralTokens = useMemo(() => { if (!selectedToken?.markets) return []; - const tokens = selectedToken.markets.map(market => ({ + const tokens = selectedToken.markets.map((market) => ({ address: market.collateralAsset.address, - chainId: market.morphoBlue.chain.id + chainId: market.morphoBlue.chain.id, })); return getUniqueTokens(tokens); }, [selectedToken]); @@ -40,39 +44,46 @@ export function RiskSelection() { // 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; - } + 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; - } + // 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; - }); + 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); - + 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'); } @@ -104,7 +115,7 @@ export function RiskSelection() {
{/* Input Section */} -
+
- +
{/* Markets Table */} -
+
@@ -141,7 +149,10 @@ export function RiskSelection() { {filteredMarkets.map((market) => { - const collateralToken = findToken(market.collateralAsset.address, market.morphoBlue.chain.id); + const collateralToken = findToken( + market.collateralAsset.address, + market.morphoBlue.chain.id, + ); if (!collateralToken) return null; const isSelected = selectedMarkets.has(market.uniqueKey); @@ -151,14 +162,20 @@ export function RiskSelection() { toggleMarketSelection(market)} - className={`cursor-pointer border-b border-gray-200 transition-all duration-200 ease-in-out hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800 ${ + 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' : '' }`} > @@ -211,7 +235,12 @@ export function RiskSelection() { {formatReadable(market.state.supplyApy * 100)}%
{vendors.map((vendor) => ( - + ))}
- {formatReadable(Number(formatUnits(BigInt(market.state.supplyAssets), market.loanAsset.decimals)))} {market.loanAsset.symbol} + {formatReadable( + Number( + formatUnits(BigInt(market.state.supplyAssets), market.loanAsset.decimals), + ), + )}{' '} + {market.loanAsset.symbol} {formatReadable(market.state.utilization * 100)}% @@ -235,7 +264,7 @@ export function RiskSelection() { {/* Navigation */} -
+
-
- Available Balance - - {formatBalance(tokenBalance, tokenDecimals)} {selectedToken.symbol} - +
+
+ {selectedToken.symbol} + Wallet Balance +
+
+ + {formatBalance(tokenBalance, tokenDecimals)} {selectedToken.symbol} + + {/* Placeholder for future swap button */} + {/* */} +
{/* Markets Distribution */} -
-
+
+
- + - + {selectedMarkets.map((market) => { - const collateralToken = findToken(market.collateralAsset.address, market.morphoBlue.chain.id); + 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 ( - @@ -241,27 +430,37 @@ export function SetupPositions() { - {error && ( -
- {error} -
+ {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 index b1fbd33b..3085b4de 100644 --- a/app/positions/components/onboarding/SmartOnboarding.tsx +++ b/app/positions/components/onboarding/SmartOnboarding.tsx @@ -1,5 +1,5 @@ -import { OnboardingProvider, useOnboarding } from './OnboardingContext'; import { AssetSelection } from './AssetSelection'; +import { OnboardingProvider, useOnboarding } from './OnboardingContext'; import { RiskSelection } from './RiskSelection'; function OnboardingContent() { diff --git a/app/positions/components/onboarding/SuccessPage.tsx b/app/positions/components/onboarding/SuccessPage.tsx new file mode 100644 index 00000000..789863f9 --- /dev/null +++ b/app/positions/components/onboarding/SuccessPage.tsx @@ -0,0 +1,63 @@ +import { useEffect } from 'react'; +import { Button } from '@nextui-org/react'; +import { CheckCircledIcon } from '@radix-ui/react-icons'; +import { motion } from 'framer-motion'; +import { useRouter } from 'next/navigation'; + +export function SuccessPage() { + const router = useRouter(); + + // Automatically redirect to portfolio after 3 seconds + useEffect(() => { + const timer = setTimeout(() => { + router.push('/positions'); + }, 3000); + + return () => clearTimeout(timer); + }, [router]); + + return ( + + + + + + + Position Created Successfully! + + + + Your supply position has been created. Redirecting to your portfolio... + + + + + + + ); +} diff --git a/app/positions/components/onboarding/types.ts b/app/positions/components/onboarding/types.ts index 9507abdb..5576b968 100644 --- a/app/positions/components/onboarding/types.ts +++ b/app/positions/components/onboarding/types.ts @@ -9,7 +9,7 @@ export type TokenWithMarkets = { maxApy: number; logoURI?: string; decimals: number; - network: string; + network: number; address: string; }; diff --git a/app/positions/onboarding/page.tsx b/app/positions/onboarding/page.tsx index 5e02975c..89920ef6 100644 --- a/app/positions/onboarding/page.tsx +++ b/app/positions/onboarding/page.tsx @@ -1,11 +1,12 @@ 'use client'; import { useSearchParams } from 'next/navigation'; -import { OnboardingProvider } from '../components/onboarding/OnboardingContext'; +import Header from '@/components/layout/header/Header'; import { AssetSelection } from '../components/onboarding/AssetSelection'; +import { OnboardingProvider } from '../components/onboarding/OnboardingContext'; import { RiskSelection } from '../components/onboarding/RiskSelection'; import { SetupPositions } from '../components/onboarding/SetupPositions'; -import Header from '@/components/layout/header/Header'; +import { SuccessPage } from '../components/onboarding/SuccessPage'; export default function OnboardingPage() { const searchParams = useSearchParams(); @@ -19,6 +20,8 @@ export default function OnboardingPage() { return ; case 'setup': return ; + case 'success': + return ; default: return ; } @@ -27,11 +30,9 @@ export default function OnboardingPage() { return (
-
+
-
- {renderStep()} -
+
{renderStep()}
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..d9dd97c4 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; diff --git a/src/components/RiskNotificationModal.tsx b/src/components/RiskNotificationModal.tsx index 87132b36..a4a7c9b5 100644 --- a/src/components/RiskNotificationModal.tsx +++ b/src/components/RiskNotificationModal.tsx @@ -70,7 +70,7 @@ export default function RiskNotificationModal() { .

-
+
void; tokenSymbol: string; @@ -13,7 +23,7 @@ type SupplyProcessModalProps = { }; export function SupplyProcessModal({ - supplyAmount, + supplies, currentStep, onClose, useEth, @@ -36,7 +46,7 @@ export function SupplyProcessModal({ { key: 'approve', label: 'Authorize Permit2', - detail: `This one-time approval make sure you don't need to send approval tx again in the future.`, + detail: `This one-time approval makes sure you don't need to send approval tx again in the future.`, }, { key: 'signing', @@ -79,45 +89,120 @@ export function SupplyProcessModal({ return 'undone'; }; + const totalAmount = supplies.reduce((sum, supply) => sum + supply.amount, 0n); + const isMultiMarket = supplies.length > 1; + return ( -
-
+ - + {/* Close button */} + -
- 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} + + + {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 && } -
+ 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 && ( - - )}{' '} -
+
+ Supply {loanToken ? loanToken.symbol : market.loanAsset.symbol} + {loanToken?.img && }
-
-
-

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 && ( - - )}{' '} +

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

+ +
+
+

Market ID:

+ +

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

+ +
+
+

Collateral Token:

+
+

{market.collateralAsset.symbol}

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

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 === '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/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..bb147fbc --- /dev/null +++ b/src/hooks/useMultiMarketSupply.ts @@ -0,0 +1,207 @@ +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 { formatBalance } from '@/utils/balance'; +import { getBundlerV2 } from '@/utils/morpho'; +import { Market } from '@/utils/types'; + +export type MarketSupply = { + market: Market; + amount: bigint; +}; + +export function useMultiMarketSupply( + 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 = supplies[0]?.market.morphoBlue.chain.id; + const tokenSymbol = supplies[0]?.market.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), + token: supplies[0]?.market.loanAsset.address as `0x${string}`, + refetchInterval: 10000, + chainId, + tokenSymbol, + amount: totalAmount, + }); + + const { isConfirming: supplyPending, sendTransactionAsync } = useTransactionWithToast({ + toastId: 'multi-supply', + pendingText: `Supplying ${formatBalance( + totalAmount, + supplies[0]?.market.loanAsset.decimals, + )} ${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) return; + + try { + const txs: `0x${string}`[] = []; + + // Handle ETH wrapping if needed + if (useEth) { + txs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'wrapNative', + args: [totalAmount], + }), + ); + } + // Handle token approvals + else if (usePermit2Setting) { + const { sigs, permitSingle } = await signForBundlers(); + console.log('Signed for bundlers:', { sigs, permitSingle }); + + txs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'approve2', + args: [permitSingle, sigs, false], + }), + ); + + // transferFrom with permit2 + txs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'transferFrom2', + args: [supplies[0].market.loanAsset.address as Address, totalAmount], + }), + ); + } else { + // For standard ERC20 flow + txs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'erc20TransferFrom', + args: [supplies[0].market.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, + }); + + setShowProcessModal(false); + } catch (error: unknown) { + setShowProcessModal(false); + if (error instanceof Error) { + toast.error('An error occurred. Please try again.'); + } else { + toast.error('An unexpected error occurred'); + } + } + }, [ + account, + supplies, + totalAmount, + sendTransactionAsync, + useEth, + signForBundlers, + usePermit2Setting, + chainId, + ]); + + const approveAndSupply = useCallback(async () => { + if (!account) { + toast.error('Please connect your wallet'); + return; + } + + try { + setShowProcessModal(true); + setCurrentStep('approve'); + + if (!useEth) { + if (usePermit2Setting && !permit2Authorized) { + await authorizePermit2(); + } + setCurrentStep('signing'); + } + + await executeSupplyTransaction(); + } catch (error) { + setShowProcessModal(false); + console.error('Error in approveAndSupply:', error); + } + }, [ + account, + useEth, + usePermit2Setting, + permit2Authorized, + authorizePermit2, + executeSupplyTransaction, + ]); + + return { + approveAndSupply, + currentStep, + showProcessModal, + setShowProcessModal, + supplyPending, + isLoadingPermit2, + }; +} diff --git a/src/hooks/useUserBalances.ts b/src/hooks/useUserBalances.ts index 584e7d0f..9c090546 100644 --- a/src/hooks/useUserBalances.ts +++ b/src/hooks/useUserBalances.ts @@ -17,19 +17,22 @@ export function useUserBalances() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const fetchBalances = useCallback(async (chainId: number) => { - try { - const response = await fetch(`/api/balances?address=${address}&chainId=${chainId}`); - if (!response.ok) { - throw new Error('Failed to fetch balances'); + const fetchBalances = useCallback( + async (chainId: number) => { + 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(); + return data.tokens; + } catch (err) { + console.error('Error fetching balances:', err); + throw err; } - const data = await response.json(); - return data.tokens; - } catch (err) { - console.error('Error fetching balances:', err); - throw err; - } - }, [address]); + }, + [address], + ); const fetchAllBalances = useCallback(async () => { if (!address) return; @@ -41,14 +44,14 @@ export function useUserBalances() { // Fetch balances from both chains const [mainnetBalances, baseBalances] = await Promise.all([ fetchBalances(1), - fetchBalances(8453) + fetchBalances(8453), ]); // Process and filter tokens const processedBalances: TokenBalance[] = []; - + const processTokens = (tokens: any[], chainId: number) => { - tokens.forEach(token => { + tokens.forEach((token) => { const tokenInfo = findToken(token.address, chainId); if (tokenInfo) { processedBalances.push({ @@ -57,7 +60,7 @@ export function useUserBalances() { chainId, decimals: tokenInfo.decimals, logoURI: tokenInfo.img, - symbol: tokenInfo.symbol + symbol: tokenInfo.symbol, }); } }); @@ -83,6 +86,6 @@ export function useUserBalances() { balances, loading, error, - refetch: fetchAllBalances + refetch: fetchAllBalances, }; } 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, + ], }, ], { From b3d47fa262e2f920bc63087d3773dcf1abe4c136 Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Thu, 21 Nov 2024 16:27:50 +0800 Subject: [PATCH 04/16] chore: lint --- .../onboarding/OnboardingContext.tsx | 22 ++--- .../components/onboarding/RiskSelection.tsx | 11 +-- .../components/onboarding/SetupPositions.tsx | 91 ++++++++++--------- app/positions/onboarding/page.tsx | 2 +- src/components/SupplyProcessModal.tsx | 15 ++- src/hooks/useUserBalances.ts | 23 +++-- 6 files changed, 85 insertions(+), 79 deletions(-) diff --git a/app/positions/components/onboarding/OnboardingContext.tsx b/app/positions/components/onboarding/OnboardingContext.tsx index c838ae5d..2f87cdf6 100644 --- a/app/positions/components/onboarding/OnboardingContext.tsx +++ b/app/positions/components/onboarding/OnboardingContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState } from 'react'; +import { createContext, useContext, useState, useMemo } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { Market } from '@/utils/types'; import { TokenWithMarkets } from './types'; @@ -30,17 +30,17 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) router.push(`/positions/onboarding?${params.toString()}`); }; + const contextValue = useMemo(() => ({ + selectedToken, + setSelectedToken, + selectedMarkets, + setSelectedMarkets, + step: currentStep, + setStep, + }), [selectedToken, selectedMarkets, currentStep, setStep]); + return ( - + {children} ); diff --git a/app/positions/components/onboarding/RiskSelection.tsx b/app/positions/components/onboarding/RiskSelection.tsx index 6b6f3e23..5142c2b8 100644 --- a/app/positions/components/onboarding/RiskSelection.tsx +++ b/app/positions/components/onboarding/RiskSelection.tsx @@ -26,12 +26,6 @@ export function RiskSelection() { const [selectedOracles, setSelectedOracles] = useState([]); const [selectedMarkets, setSelectedMarketsLocal] = useState>(new Set()); - if (!selectedToken) { - router.push('/positions/onboarding?step=asset-selection'); - return null; - } - - // Get unique collateral tokens from markets const collateralTokens = useMemo(() => { if (!selectedToken?.markets) return []; const tokens = selectedToken.markets.map((market) => ({ @@ -41,6 +35,11 @@ export function RiskSelection() { return getUniqueTokens(tokens); }, [selectedToken]); + if (!selectedToken) { + router.push('/positions/onboarding'); + return null; + } + // Filter markets based on selected collaterals and oracles const filteredMarkets = useMemo(() => { if (!selectedToken?.markets) return []; diff --git a/app/positions/components/onboarding/SetupPositions.tsx b/app/positions/components/onboarding/SetupPositions.tsx index e3b7e014..585b9f0a 100644 --- a/app/positions/components/onboarding/SetupPositions.tsx +++ b/app/positions/components/onboarding/SetupPositions.tsx @@ -17,26 +17,25 @@ import { useOnboarding } from './OnboardingContext'; export function SetupPositions() { const router = useRouter(); const { selectedToken, selectedMarkets } = useOnboarding(); - const { balances } = useUserBalances(); - const [totalAmount, setTotalAmount] = useState(''); - const [amounts, setAmounts] = useState>({}); - const [percentages, setPercentages] = useState>({}); - const [lockedAmounts, setLockedAmounts] = useState>(new Set()); - const [error, setError] = useState(null); + const [useEth] = useLocalStorage('useEth', false); const [usePermit2Setting] = useLocalStorage('usePermit2', true); - const [useEth, setUseEth] = useState(false); + const { balances } = useUserBalances(); + const tokenBalance = BigInt( + balances.find((b) => b.address.toLowerCase() === selectedToken?.address.toLowerCase()) + ?.balance ?? '0' + ); + const tokenDecimals = selectedToken?.decimals ?? 0; if (!selectedToken || !selectedMarkets || selectedMarkets.length === 0) { router.push('/positions/onboarding?step=risk-selection'); return null; } - const tokenBalance = - BigInt( - balances.find((b) => b.address.toLowerCase() === selectedToken.address.toLowerCase()) - ?.balance || '0', - ) || 0n; - const tokenDecimals = selectedToken.decimals; + const [totalAmount, setTotalAmount] = useState(''); + const [amounts, setAmounts] = useState>({}); + const [percentages, setPercentages] = useState>({}); + const [lockedAmounts, setLockedAmounts] = useState>(new Set()); + const [error, setError] = useState(null); // Initialize percentages evenly useEffect(() => { @@ -92,35 +91,6 @@ export function SetupPositions() { } }; - const handleAmountChange = (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; - } - }; - const handleSetMax = () => { const maxAmount = formatUnits(tokenBalance, tokenDecimals); setTotalAmount(maxAmount); @@ -136,7 +106,7 @@ export function SetupPositions() { setLockedAmounts(newLockedAmounts); }; - const handlePercentageChange = (marketKey: string, newPercentage: number) => { + const handlePercentageChange = useCallback((marketKey: string, newPercentage: number) => { // If the input is invalid (NaN), set it to 0 if (isNaN(newPercentage)) { newPercentage = 0; @@ -186,7 +156,36 @@ export function SetupPositions() { } 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: MarketSupply[] = useMemo(() => { return selectedMarkets @@ -262,7 +261,8 @@ export function SetupPositions() { 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" /> diff --git a/src/hooks/useUserBalances.ts b/src/hooks/useUserBalances.ts index 9c090546..a9136200 100644 --- a/src/hooks/useUserBalances.ts +++ b/src/hooks/useUserBalances.ts @@ -2,14 +2,21 @@ import { useCallback, useEffect, useState } from 'react'; import { useAccount } from 'wagmi'; import { findToken } from '@/utils/tokens'; -type TokenBalance = { +interface TokenBalance { address: string; balance: string; chainId: number; decimals: number; logoURI?: string; symbol: string; -}; +} + +interface TokenResponse { + tokens: Array<{ + address: string; + balance: string; + }>; +} export function useUserBalances() { const { address } = useAccount(); @@ -18,17 +25,17 @@ export function useUserBalances() { const [error, setError] = useState(null); const fetchBalances = useCallback( - async (chainId: number) => { + 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(); + const data = (await response.json()) as TokenResponse; return data.tokens; } catch (err) { console.error('Error fetching balances:', err); - throw err; + throw err instanceof Error ? err : new Error('Unknown error occurred'); } }, [address], @@ -50,7 +57,7 @@ export function useUserBalances() { // Process and filter tokens const processedBalances: TokenBalance[] = []; - const processTokens = (tokens: any[], chainId: number) => { + const processTokens = (tokens: TokenResponse['tokens'], chainId: number) => { tokens.forEach((token) => { const tokenInfo = findToken(token.address, chainId); if (tokenInfo) { @@ -71,7 +78,7 @@ export function useUserBalances() { setBalances(processedBalances); } catch (err) { - setError(err as Error); + setError(err instanceof Error ? err : new Error('Unknown error occurred')); console.error('Error fetching all balances:', err); } finally { setLoading(false); @@ -79,7 +86,7 @@ export function useUserBalances() { }, [address, fetchBalances]); useEffect(() => { - fetchAllBalances(); + void fetchAllBalances(); }, [fetchAllBalances]); return { From 4f860a7e3cedb3e34e9eb7abe9031d1cb705ae0a Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Thu, 21 Nov 2024 16:59:46 +0800 Subject: [PATCH 05/16] chore: lint --- app/positions/components/FromAndToMarkets.tsx | 6 ++ .../components/RebalanceActionInput.tsx | 2 +- .../components/onboarding/RiskSelection.tsx | 11 +-- .../components/onboarding/SetupPositions.tsx | 76 ++++++++++--------- .../components/onboarding/SuccessPage.tsx | 7 +- src/hooks/useUserBalances.ts | 8 +- 6 files changed, 59 insertions(+), 51 deletions(-) diff --git a/app/positions/components/FromAndToMarkets.tsx b/app/positions/components/FromAndToMarkets.tsx index 81dbe564..d9a0162f 100644 --- a/app/positions/components/FromAndToMarkets.tsx +++ b/app/positions/components/FromAndToMarkets.tsx @@ -93,6 +93,9 @@ export function FromAndToMarkets({ value={fromFilter} onChange={(e) => onFromFilterChange(e.target.value)} className="mb-2" + classNames={{ + inputWrapper: 'rounded' + }} />
{fromMarkets.length === 0 ? ( @@ -216,6 +219,9 @@ export function FromAndToMarkets({ value={toFilter} onChange={(e) => onToFilterChange(e.target.value)} className="mb-2" + classNames={{ + inputWrapper: 'rounded' + }} />
{toMarkets.length === 0 ? ( diff --git a/app/positions/components/RebalanceActionInput.tsx b/app/positions/components/RebalanceActionInput.tsx index ca28aa29..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 { if (!selectedToken?.markets) return []; @@ -201,6 +196,7 @@ export function RiskSelection() { @@ -266,13 +262,14 @@ export function RiskSelection() {
diff --git a/app/positions/components/onboarding/SuccessPage.tsx b/app/positions/components/onboarding/SuccessPage.tsx index 789863f9..e63ed040 100644 --- a/app/positions/components/onboarding/SuccessPage.tsx +++ b/app/positions/components/onboarding/SuccessPage.tsx @@ -3,14 +3,17 @@ import { Button } from '@nextui-org/react'; import { CheckCircledIcon } from '@radix-ui/react-icons'; import { motion } from 'framer-motion'; import { useRouter } from 'next/navigation'; +import { useAccount } from 'wagmi'; export function SuccessPage() { const router = useRouter(); + const { address } = useAccount() + // Automatically redirect to portfolio after 3 seconds useEffect(() => { const timer = setTimeout(() => { - router.push('/positions'); + router.push(`/positions/${address}`) // Corrected }, 3000); return () => clearTimeout(timer); @@ -54,7 +57,7 @@ export function SuccessPage() { transition={{ delay: 0.8 }} className="mt-8" > - diff --git a/src/hooks/useUserBalances.ts b/src/hooks/useUserBalances.ts index a9136200..dc63baa2 100644 --- a/src/hooks/useUserBalances.ts +++ b/src/hooks/useUserBalances.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useAccount } from 'wagmi'; import { findToken } from '@/utils/tokens'; -interface TokenBalance { +type TokenBalance = { address: string; balance: string; chainId: number; @@ -11,11 +11,11 @@ interface TokenBalance { symbol: string; } -interface TokenResponse { - tokens: Array<{ +type TokenResponse = { + tokens: { address: string; balance: string; - }>; + }[]; } export function useUserBalances() { From 164f4e807b69f6faa8114398b5dd09f1ab8af21c Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Thu, 21 Nov 2024 17:17:09 +0800 Subject: [PATCH 06/16] chore: revert changes --- app/positions/components/PositionsContent.tsx | 16 +++++----------- src/components/Status/EmptyScreen.tsx | 2 +- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/app/positions/components/PositionsContent.tsx b/app/positions/components/PositionsContent.tsx index 76dfd03a..52bdc9dc 100644 --- a/app/positions/components/PositionsContent.tsx +++ b/app/positions/components/PositionsContent.tsx @@ -34,7 +34,7 @@ export default function Positions() { @@ -42,7 +42,7 @@ export default function Positions() { @@ -82,12 +82,12 @@ export default function Positions() { {loading ? ( ) : !hasSuppliedMarkets ? ( -
- +
+ @@ -105,12 +105,6 @@ export default function Positions() { />
)} - -
- - View All Markets - -
); diff --git a/src/components/Status/EmptyScreen.tsx b/src/components/Status/EmptyScreen.tsx index db795019..70c28feb 100644 --- a/src/components/Status/EmptyScreen.tsx +++ b/src/components/Status/EmptyScreen.tsx @@ -8,7 +8,7 @@ type EmptyScreenProps = { export default function EmptyScreen({ message = 'No data', hint }: EmptyScreenProps) { return ( -
+

{message}

{hint}

From 5911372a28c1d3f21bdf75add4bce30e49f797c9 Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Thu, 21 Nov 2024 17:31:25 +0800 Subject: [PATCH 07/16] chore: styling --- app/markets/components/OracleFilter.tsx | 86 +++++++++---------- app/markets/components/markets.tsx | 4 +- app/positions/components/PositionsContent.tsx | 1 - src/components/Status/EmptyScreen.tsx | 2 +- tailwind.config.ts | 33 ++++++- 5 files changed, 75 insertions(+), 51 deletions(-) diff --git a/app/markets/components/OracleFilter.tsx b/app/markets/components/OracleFilter.tsx index 34e5e185..978b24cf 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); }; }, []); @@ -51,8 +45,8 @@ export default function OracleFilter({ selectedOracles, setSelectedOracles }: Or 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} +
+ + ))} + +
); } diff --git a/app/markets/components/markets.tsx b/app/markets/components/markets.tsx index 3009de8e..ecf4059b 100644 --- a/app/markets/components/markets.tsx +++ b/app/markets/components/markets.tsx @@ -344,7 +344,7 @@ export default function Markets() { onClick={handleRefresh} type="button" disabled={loading || isRefetching} - className={`flex items-center gap-2 rounded-md bg-gray-200 p-2 px-3 text-sm text-secondary transition-colors hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 ${ + className={`flex items-center gap-2 rounded bg-gray-200 p-2 px-3 text-sm text-secondary transition-colors hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 ${ loading || isRefetching ? 'cursor-not-allowed opacity-50' : '' }`} > @@ -357,7 +357,7 @@ export default function Markets() { type="button" aria-expanded={showAdvancedSettings} aria-controls="advanced-settings-panel" - className="flex items-center gap-2 rounded-md bg-gray-200 p-2 px-3 text-sm text-secondary transition-colors hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600" + className="flex items-center gap-2 rounded bg-gray-200 p-2 px-3 text-sm text-secondary transition-colors hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600" > Advanced diff --git a/app/positions/components/PositionsContent.tsx b/app/positions/components/PositionsContent.tsx index 52bdc9dc..26c9ba78 100644 --- a/app/positions/components/PositionsContent.tsx +++ b/app/positions/components/PositionsContent.tsx @@ -3,7 +3,6 @@ import { useState } from 'react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; -import PrimaryButton from '@/components/common/PrimaryButton'; import Header from '@/components/layout/header/Header'; import EmptyScreen from '@/components/Status/EmptyScreen'; import LoadingScreen from '@/components/Status/LoadingScreen'; diff --git a/src/components/Status/EmptyScreen.tsx b/src/components/Status/EmptyScreen.tsx index 70c28feb..b4ee4458 100644 --- a/src/components/Status/EmptyScreen.tsx +++ b/src/components/Status/EmptyScreen.tsx @@ -8,7 +8,7 @@ type EmptyScreenProps = { export default function EmptyScreen({ message = 'No data', hint }: EmptyScreenProps) { return ( -
+

{message}

{hint}

diff --git a/tailwind.config.ts b/tailwind.config.ts index 1837ea02..3c829216 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; From c4e7dd8e22f10e942e12306fa4871d42dd3c2900 Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Thu, 21 Nov 2024 17:49:52 +0800 Subject: [PATCH 08/16] chore: lint --- app/markets/components/OracleFilter.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/markets/components/OracleFilter.tsx b/app/markets/components/OracleFilter.tsx index 978b24cf..06f240e7 100644 --- a/app/markets/components/OracleFilter.tsx +++ b/app/markets/components/OracleFilter.tsx @@ -37,11 +37,6 @@ export default function OracleFilter({ selectedOracles, setSelectedOracles }: Or } }; - const clearSelection = () => { - setSelectedOracles([]); - setIsOpen(false); - }; - return (
Date: Thu, 21 Nov 2024 18:10:27 +0800 Subject: [PATCH 09/16] chore: lint fix --- app/api/balances/route.ts | 9 +++++++- app/positions/components/PositionsContent.tsx | 22 +++++++++++-------- .../components/onboarding/AssetSelection.tsx | 2 +- src/components/ButtonGroup.tsx | 4 ++-- src/components/Status/EmptyScreen.tsx | 2 +- src/components/Status/LoadingScreen.tsx | 2 +- 6 files changed, 26 insertions(+), 15 deletions(-) diff --git a/app/api/balances/route.ts b/app/api/balances/route.ts index dd523c04..794fa4ac 100644 --- a/app/api/balances/route.ts +++ b/app/api/balances/route.ts @@ -45,7 +45,14 @@ export async function GET(req: NextRequest) { throw new Error(`HTTP error! status: ${balancesResponse.status}`); } - const balancesData = await balancesResponse.json(); + 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', diff --git a/app/positions/components/PositionsContent.tsx b/app/positions/components/PositionsContent.tsx index 26c9ba78..e137223b 100644 --- a/app/positions/components/PositionsContent.tsx +++ b/app/positions/components/PositionsContent.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; +import { FaHistory, FaGift, FaPlus } from 'react-icons/fa'; import Header from '@/components/layout/header/Header'; import EmptyScreen from '@/components/Status/EmptyScreen'; import LoadingScreen from '@/components/Status/LoadingScreen'; @@ -30,28 +31,31 @@ export default function Positions() {

Portfolio

- + - + - +
diff --git a/app/positions/components/onboarding/AssetSelection.tsx b/app/positions/components/onboarding/AssetSelection.tsx index 95d84e3a..7b32f30a 100644 --- a/app/positions/components/onboarding/AssetSelection.tsx +++ b/app/positions/components/onboarding/AssetSelection.tsx @@ -102,7 +102,7 @@ export function AssetSelection() { You need to have some assets in your wallet to supply

- +
) : ( diff --git a/src/components/ButtonGroup.tsx b/src/components/ButtonGroup.tsx index d9dd97c4..7ca36287 100644 --- a/src/components/ButtonGroup.tsx +++ b/src/components/ButtonGroup.tsx @@ -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/Status/EmptyScreen.tsx b/src/components/Status/EmptyScreen.tsx index b4ee4458..05be6a89 100644 --- a/src/components/Status/EmptyScreen.tsx +++ b/src/components/Status/EmptyScreen.tsx @@ -8,7 +8,7 @@ type EmptyScreenProps = { export default function EmptyScreen({ message = 'No data', hint }: EmptyScreenProps) { return ( -
+

{message}

{hint}

diff --git a/src/components/Status/LoadingScreen.tsx b/src/components/Status/LoadingScreen.tsx index 2aea444e..de6efd0b 100644 --- a/src/components/Status/LoadingScreen.tsx +++ b/src/components/Status/LoadingScreen.tsx @@ -8,7 +8,7 @@ type LoadingScreenProps = { export default function LoadingScreen({ message = 'Loading...' }: LoadingScreenProps) { return ( -
+

{message}

From 6e8a855f73fc61a0c672e9aba7a126448a3bb3ba Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Thu, 21 Nov 2024 18:12:46 +0800 Subject: [PATCH 10/16] fix: homepage fonts --- app/home/_components/HomeHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ( -
+
); From f2fab5416197f3b4d747a6c63032e9688606f6ae Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Thu, 21 Nov 2024 20:37:33 +0800 Subject: [PATCH 11/16] chore: better types --- .../components/onboarding/AssetSelection.tsx | 5 +-- .../components/onboarding/SetupPositions.tsx | 44 +++++++++---------- app/positions/components/onboarding/types.ts | 10 ++--- src/hooks/useMultiMarketSupply.ts | 34 ++++++-------- src/types/token.ts | 16 +++++++ 5 files changed, 56 insertions(+), 53 deletions(-) create mode 100644 src/types/token.ts diff --git a/app/positions/components/onboarding/AssetSelection.tsx b/app/positions/components/onboarding/AssetSelection.tsx index 7b32f30a..5236c821 100644 --- a/app/positions/components/onboarding/AssetSelection.tsx +++ b/app/positions/components/onboarding/AssetSelection.tsx @@ -55,8 +55,6 @@ export function AssetSelection() { result.push({ symbol: balance.symbol, - balance: balance.balance, - chainId: balance.chainId, markets: relevantMarkets, minApy, maxApy, @@ -64,6 +62,7 @@ export function AssetSelection() { decimals: balance.decimals, network, address: balance.address, + balance: balance.balance }); }); @@ -109,7 +108,7 @@ export function AssetSelection() {
{tokensWithMarkets.map((token) => ( handleTokenSelect(token)} className="hover:border-monarch-orange group relative flex items-start gap-4 rounded-lg border border-gray-200 bg-white p-4 text-left transition-all duration-300 hover:shadow-lg dark:border-gray-700 dark:bg-gray-800/50 dark:hover:bg-gray-800" whileHover={{ scale: 1.02 }} diff --git a/app/positions/components/onboarding/SetupPositions.tsx b/app/positions/components/onboarding/SetupPositions.tsx index be5164f4..5c796b0b 100644 --- a/app/positions/components/onboarding/SetupPositions.tsx +++ b/app/positions/components/onboarding/SetupPositions.tsx @@ -11,8 +11,8 @@ import { useMultiMarketSupply, MarketSupply } 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'; +import { findToken } from '@/utils/tokens'; export function SetupPositions() { const router = useRouter(); @@ -184,37 +184,35 @@ export function SetupPositions() { } }, [totalAmount, tokenDecimals, handlePercentageChange]); - const supplies: MarketSupply[] = useMemo(() => { + const supplies = useMemo(() => { + if (!selectedMarkets || !amounts || !tokenDecimals) return []; + return selectedMarkets .map((market) => { - const amountStr = amounts[market.uniqueKey] ?? '0'; - try { - const amountBigInt = parseUnits(amountStr, tokenDecimals); - return { - market, - amount: amountBigInt, - }; - } catch (e) { - console.warn( - `Failed to convert amount ${amountStr} to BigInt for market ${market.uniqueKey}`, - ); - return { - market, - amount: 0n, - }; - } + const amount = parseUnits(amounts[market.uniqueKey] || '0', tokenDecimals); + return { + market, + amount, + }; }) .filter((supply) => supply.amount > 0n); }, [selectedMarkets, amounts, tokenDecimals]); const { - approveAndSupply, currentStep, showProcessModal, setShowProcessModal, - supplyPending, isLoadingPermit2, - } = useMultiMarketSupply(supplies, useEth, usePermit2Setting); + permit2Authorized, + authorizePermit2, + supplyPending, + executeSupplyTransaction, + } = useMultiMarketSupply( + selectedToken!, + supplies, + useEth, + usePermit2Setting, + ); const handleNext = useCallback(async () => { if (error || !totalAmount) return; @@ -224,14 +222,14 @@ export function SetupPositions() { const totalAmountBigInt = parseUnits(totalAmount, tokenDecimals); if (totalAmountBigInt === 0n) return; - await approveAndSupply(); + await executeSupplyTransaction(); // After successful supply, navigate to success page router.push('/positions/onboarding?step=success'); } catch (e) { setError('Invalid amount format'); return; } - }, [error, totalAmount, tokenDecimals, approveAndSupply, router]); + }, [error, totalAmount, tokenDecimals, executeSupplyTransaction, router]); if (!selectedToken || !selectedMarkets || selectedMarkets.length === 0) { return null; diff --git a/app/positions/components/onboarding/types.ts b/app/positions/components/onboarding/types.ts index 5576b968..001d6fc3 100644 --- a/app/positions/components/onboarding/types.ts +++ b/app/positions/components/onboarding/types.ts @@ -1,16 +1,12 @@ import { Market } from '@/utils/types'; +import { NetworkToken } from '@/types/token'; -export type TokenWithMarkets = { - symbol: string; - balance: string; - chainId: number; +export type TokenWithMarkets = NetworkToken & { markets: Market[]; minApy: number; maxApy: number; logoURI?: string; - decimals: number; - network: number; - address: string; + balance: string; }; export type OnboardingStep = 'asset-selection' | 'risk-selection' | 'setup'; diff --git a/src/hooks/useMultiMarketSupply.ts b/src/hooks/useMultiMarketSupply.ts index bb147fbc..13ee81b4 100644 --- a/src/hooks/useMultiMarketSupply.ts +++ b/src/hooks/useMultiMarketSupply.ts @@ -8,6 +8,7 @@ import { useTransactionWithToast } from '@/hooks/useTransactionWithToast'; import { formatBalance } from '@/utils/balance'; import { getBundlerV2 } from '@/utils/morpho'; import { Market } from '@/utils/types'; +import { NetworkToken } from '@/types/token'; export type MarketSupply = { market: Market; @@ -15,6 +16,7 @@ export type MarketSupply = { }; export function useMultiMarketSupply( + loanAsset: NetworkToken, supplies: MarketSupply[], useEth: boolean, usePermit2Setting: boolean, @@ -23,8 +25,8 @@ export function useMultiMarketSupply( const [showProcessModal, setShowProcessModal] = useState(false); const { address: account } = useAccount(); - const chainId = supplies[0]?.market.morphoBlue.chain.id; - const tokenSymbol = supplies[0]?.market.loanAsset.symbol; + const chainId = loanAsset.network; + const tokenSymbol = loanAsset.symbol; const totalAmount = supplies.reduce((sum, supply) => sum + supply.amount, 0n); const { @@ -35,7 +37,7 @@ export function useMultiMarketSupply( } = usePermit2({ user: account as `0x${string}`, spender: getBundlerV2(chainId), - token: supplies[0]?.market.loanAsset.address as `0x${string}`, + token: loanAsset.address as `0x${string}`, refetchInterval: 10000, chainId, tokenSymbol, @@ -44,10 +46,7 @@ export function useMultiMarketSupply( const { isConfirming: supplyPending, sendTransactionAsync } = useTransactionWithToast({ toastId: 'multi-supply', - pendingText: `Supplying ${formatBalance( - totalAmount, - supplies[0]?.market.loanAsset.decimals, - )} ${tokenSymbol}`, + pendingText: `Supplying ${formatBalance(totalAmount, loanAsset.decimals)} ${tokenSymbol}`, successText: `${tokenSymbol} Supplied`, errorText: 'Failed to supply', chainId, @@ -91,7 +90,7 @@ export function useMultiMarketSupply( encodeFunctionData({ abi: morphoBundlerAbi, functionName: 'transferFrom2', - args: [supplies[0].market.loanAsset.address as Address, totalAmount], + args: [loanAsset.address as Address, totalAmount], }), ); } else { @@ -100,13 +99,11 @@ export function useMultiMarketSupply( encodeFunctionData({ abi: morphoBundlerAbi, functionName: 'erc20TransferFrom', - args: [supplies[0].market.loanAsset.address as Address, totalAmount], + args: [loanAsset.address as Address, totalAmount], }), ); } - setCurrentStep('supplying'); - // Add supply transactions for each market for (const supply of supplies) { const morphoSupplyTx = encodeFunctionData({ @@ -163,6 +160,7 @@ export function useMultiMarketSupply( signForBundlers, usePermit2Setting, chainId, + loanAsset, ]); const approveAndSupply = useCallback(async () => { @@ -173,23 +171,19 @@ export function useMultiMarketSupply( try { setShowProcessModal(true); - setCurrentStep('approve'); - - if (!useEth) { - if (usePermit2Setting && !permit2Authorized) { - await authorizePermit2(); - } + if (usePermit2Setting && !permit2Authorized) { setCurrentStep('signing'); + await authorizePermit2(); } - + setCurrentStep('supplying'); await executeSupplyTransaction(); } catch (error) { - setShowProcessModal(false); console.error('Error in approveAndSupply:', error); + setShowProcessModal(false); + setCurrentStep('approve'); } }, [ account, - useEth, usePermit2Setting, permit2Authorized, authorizePermit2, diff --git a/src/types/token.ts b/src/types/token.ts new file mode 100644 index 00000000..34f82599 --- /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; +}; From 313ee64f79c1ee71a5dd8e9cdbcb6ce37f533955 Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Thu, 21 Nov 2024 20:44:32 +0800 Subject: [PATCH 12/16] chore: execution error --- app/positions/components/onboarding/SetupPositions.tsx | 9 ++++----- src/hooks/useAllowance.ts | 6 +++++- src/hooks/useMultiMarketSupply.ts | 1 + 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/positions/components/onboarding/SetupPositions.tsx b/app/positions/components/onboarding/SetupPositions.tsx index 5c796b0b..5f4da3ee 100644 --- a/app/positions/components/onboarding/SetupPositions.tsx +++ b/app/positions/components/onboarding/SetupPositions.tsx @@ -203,10 +203,8 @@ export function SetupPositions() { showProcessModal, setShowProcessModal, isLoadingPermit2, - permit2Authorized, - authorizePermit2, + approveAndSupply, supplyPending, - executeSupplyTransaction, } = useMultiMarketSupply( selectedToken!, supplies, @@ -222,14 +220,15 @@ export function SetupPositions() { const totalAmountBigInt = parseUnits(totalAmount, tokenDecimals); if (totalAmountBigInt === 0n) return; - await executeSupplyTransaction(); + await approveAndSupply(); // After successful supply, navigate to success page router.push('/positions/onboarding?step=success'); } catch (e) { + console.error(e); setError('Invalid amount format'); return; } - }, [error, totalAmount, tokenDecimals, executeSupplyTransaction, router]); + }, [error, totalAmount, tokenDecimals, approveAndSupply, router]); if (!selectedToken || !selectedMarkets || selectedMarkets.length === 0) { return null; diff --git a/src/hooks/useAllowance.ts b/src/hooks/useAllowance.ts index e631bafc..143670ca 100644 --- a/src/hooks/useAllowance.ts +++ b/src/hooks/useAllowance.ts @@ -32,7 +32,7 @@ export function useAllowance({ const { chain } = useAccount(); const chainIdFromArgumentOrConnectedWallet = chainId ?? chain?.id; - const { data } = useReadContract({ + const { data, error } = useReadContract({ abi: erc20Abi, functionName: 'allowance', address: token, @@ -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/useMultiMarketSupply.ts b/src/hooks/useMultiMarketSupply.ts index 13ee81b4..e7a09d67 100644 --- a/src/hooks/useMultiMarketSupply.ts +++ b/src/hooks/useMultiMarketSupply.ts @@ -197,5 +197,6 @@ export function useMultiMarketSupply( setShowProcessModal, supplyPending, isLoadingPermit2, + executeSupplyTransaction }; } From cb9ea176f2b36860420d61d066f450d517669986 Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Thu, 21 Nov 2024 22:41:16 +0800 Subject: [PATCH 13/16] chore: fmt --- app/api/balances/route.ts | 2 +- app/markets/components/OracleFilter.tsx | 4 +- app/positions/components/FromAndToMarkets.tsx | 4 +- app/positions/components/PositionsContent.tsx | 4 +- .../components/onboarding/AssetSelection.tsx | 6 +- .../onboarding/OnboardingContext.tsx | 25 +- .../components/onboarding/RiskSelection.tsx | 2 +- .../components/onboarding/SetupPositions.tsx | 227 +++++++++--------- .../components/onboarding/SuccessPage.tsx | 86 ++----- app/positions/components/onboarding/types.ts | 2 +- src/components/Status/EmptyScreen.tsx | 2 +- src/hooks/useAllowance.ts | 4 +- src/hooks/useMultiMarketSupply.ts | 102 ++++++-- src/hooks/useUserBalances.ts | 4 +- src/types/token.ts | 2 +- tailwind.config.ts | 4 +- 16 files changed, 254 insertions(+), 226 deletions(-) diff --git a/app/api/balances/route.ts b/app/api/balances/route.ts index 794fa4ac..137fe9b1 100644 --- a/app/api/balances/route.ts +++ b/app/api/balances/route.ts @@ -45,7 +45,7 @@ export async function GET(req: NextRequest) { throw new Error(`HTTP error! status: ${balancesResponse.status}`); } - const balancesData = await balancesResponse.json() as { + const balancesData = (await balancesResponse.json()) as { id: number; jsonrpc: string; result: { diff --git a/app/markets/components/OracleFilter.tsx b/app/markets/components/OracleFilter.tsx index 06f240e7..39f27cd4 100644 --- a/app/markets/components/OracleFilter.tsx +++ b/app/markets/components/OracleFilter.tsx @@ -79,9 +79,7 @@ export default function OracleFilter({ selectedOracles, setSelectedOracles }: Or
    diff --git a/app/positions/components/FromAndToMarkets.tsx b/app/positions/components/FromAndToMarkets.tsx index d9a0162f..31aa5f21 100644 --- a/app/positions/components/FromAndToMarkets.tsx +++ b/app/positions/components/FromAndToMarkets.tsx @@ -94,7 +94,7 @@ export function FromAndToMarkets({ onChange={(e) => onFromFilterChange(e.target.value)} className="mb-2" classNames={{ - inputWrapper: 'rounded' + inputWrapper: 'rounded', }} />
    @@ -220,7 +220,7 @@ export function FromAndToMarkets({ onChange={(e) => onToFilterChange(e.target.value)} className="mb-2" classNames={{ - inputWrapper: 'rounded' + inputWrapper: 'rounded', }} />
    diff --git a/app/positions/components/PositionsContent.tsx b/app/positions/components/PositionsContent.tsx index e137223b..d945d416 100644 --- a/app/positions/components/PositionsContent.tsx +++ b/app/positions/components/PositionsContent.tsx @@ -52,7 +52,7 @@ export default function Positions() { +
    ) : ( diff --git a/app/positions/components/onboarding/OnboardingContext.tsx b/app/positions/components/onboarding/OnboardingContext.tsx index 2f87cdf6..3992beab 100644 --- a/app/positions/components/onboarding/OnboardingContext.tsx +++ b/app/positions/components/onboarding/OnboardingContext.tsx @@ -30,20 +30,19 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) router.push(`/positions/onboarding?${params.toString()}`); }; - const contextValue = useMemo(() => ({ - selectedToken, - setSelectedToken, - selectedMarkets, - setSelectedMarkets, - step: currentStep, - setStep, - }), [selectedToken, selectedMarkets, currentStep, setStep]); - - return ( - - {children} - + const contextValue = useMemo( + () => ({ + selectedToken, + setSelectedToken, + selectedMarkets, + setSelectedMarkets, + step: currentStep, + setStep, + }), + [selectedToken, selectedMarkets, currentStep, setStep], ); + + return {children}; } export function useOnboarding() { diff --git a/app/positions/components/onboarding/RiskSelection.tsx b/app/positions/components/onboarding/RiskSelection.tsx index ef5c7d89..10929a4f 100644 --- a/app/positions/components/onboarding/RiskSelection.tsx +++ b/app/positions/components/onboarding/RiskSelection.tsx @@ -262,7 +262,7 @@ export function RiskSelection() {
    */}
@@ -455,9 +460,7 @@ export function SetupPositions() { color="primary" isDisabled={error !== null || !totalAmount || supplies.length === 0} isLoading={supplyPending || isLoadingPermit2} - onPress={() => { - void handleNext(); - }} + onPress={handleSupply} className="min-w-[120px] rounded" > Execute diff --git a/app/positions/components/onboarding/SuccessPage.tsx b/app/positions/components/onboarding/SuccessPage.tsx index e63ed040..bb51554a 100644 --- a/app/positions/components/onboarding/SuccessPage.tsx +++ b/app/positions/components/onboarding/SuccessPage.tsx @@ -1,66 +1,32 @@ -import { useEffect } from 'react'; -import { Button } from '@nextui-org/react'; -import { CheckCircledIcon } from '@radix-ui/react-icons'; -import { motion } from 'framer-motion'; -import { useRouter } from 'next/navigation'; -import { useAccount } from 'wagmi'; +import Link from 'next/link'; +import { useOnboarding } from './OnboardingContext'; export function SuccessPage() { - const router = useRouter(); - - const { address } = useAccount() - - // Automatically redirect to portfolio after 3 seconds - useEffect(() => { - const timer = setTimeout(() => { - router.push(`/positions/${address}`) // Corrected - }, 3000); - - return () => clearTimeout(timer); - }, [router]); + const { selectedToken } = useOnboarding(); return ( - - - - - - - Position Created Successfully! - - - - Your supply position has been created. Redirecting to your portfolio... - - - - - - +
+
+

Success!

+

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

+
+ +
+ + View Position + + + Explore Markets + +
+
); } diff --git a/app/positions/components/onboarding/types.ts b/app/positions/components/onboarding/types.ts index 001d6fc3..8ef31e45 100644 --- a/app/positions/components/onboarding/types.ts +++ b/app/positions/components/onboarding/types.ts @@ -1,5 +1,5 @@ -import { Market } from '@/utils/types'; import { NetworkToken } from '@/types/token'; +import { Market } from '@/utils/types'; export type TokenWithMarkets = NetworkToken & { markets: Market[]; diff --git a/src/components/Status/EmptyScreen.tsx b/src/components/Status/EmptyScreen.tsx index 05be6a89..dce0865a 100644 --- a/src/components/Status/EmptyScreen.tsx +++ b/src/components/Status/EmptyScreen.tsx @@ -8,7 +8,7 @@ type EmptyScreenProps = { export default function EmptyScreen({ message = 'No data', hint }: EmptyScreenProps) { return ( -
+

{message}

{hint}

diff --git a/src/hooks/useAllowance.ts b/src/hooks/useAllowance.ts index 143670ca..35eaf6a0 100644 --- a/src/hooks/useAllowance.ts +++ b/src/hooks/useAllowance.ts @@ -32,7 +32,7 @@ export function useAllowance({ const { chain } = useAccount(); const chainIdFromArgumentOrConnectedWallet = chainId ?? chain?.id; - const { data, error } = useReadContract({ + const { data } = useReadContract({ abi: erc20Abi, functionName: 'allowance', address: token, @@ -74,7 +74,7 @@ export function useAllowance({ const allowance = data ? data : BigInt(0); - console.log('data', data) + console.log('data', data); const isLoadingAllowance = data === undefined; diff --git a/src/hooks/useMultiMarketSupply.ts b/src/hooks/useMultiMarketSupply.ts index e7a09d67..afccf1ca 100644 --- a/src/hooks/useMultiMarketSupply.ts +++ b/src/hooks/useMultiMarketSupply.ts @@ -5,10 +5,12 @@ 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 { NetworkToken } from '@/types/token'; +import { useERC20Approval } from './useERC20Approval'; export type MarketSupply = { market: Market; @@ -16,7 +18,7 @@ export type MarketSupply = { }; export function useMultiMarketSupply( - loanAsset: NetworkToken, + loanAsset: NetworkToken | undefined, supplies: MarketSupply[], useEth: boolean, usePermit2Setting: boolean, @@ -25,8 +27,8 @@ export function useMultiMarketSupply( const [showProcessModal, setShowProcessModal] = useState(false); const { address: account } = useAccount(); - const chainId = loanAsset.network; - const tokenSymbol = loanAsset.symbol; + const chainId = loanAsset?.network; + const tokenSymbol = loanAsset?.symbol; const totalAmount = supplies.reduce((sum, supply) => sum + supply.amount, 0n); const { @@ -36,17 +38,27 @@ export function useMultiMarketSupply( signForBundlers, } = usePermit2({ user: account as `0x${string}`, - spender: getBundlerV2(chainId), - token: loanAsset.address 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)} ${tokenSymbol}`, + pendingText: `Supplying ${formatBalance( + totalAmount, + loanAsset?.decimals ?? 18, + )} ${tokenSymbol}`, successText: `${tokenSymbol} Supplied`, errorText: 'Failed to supply', chainId, @@ -57,11 +69,12 @@ export function useMultiMarketSupply( }); const executeSupplyTransaction = useCallback(async () => { - if (!account) return; + if (!account) throw new Error('No account connected'); + if (!loanAsset || !chainId) throw new Error('Invalid loan asset or chain'); - try { - const txs: `0x${string}`[] = []; + const txs: `0x${string}`[] = []; + try { // Handle ETH wrapping if needed if (useEth) { txs.push( @@ -74,8 +87,8 @@ export function useMultiMarketSupply( } // Handle token approvals else if (usePermit2Setting) { + setCurrentStep('signing'); const { sigs, permitSingle } = await signForBundlers(); - console.log('Signed for bundlers:', { sigs, permitSingle }); txs.push( encodeFunctionData({ @@ -104,6 +117,8 @@ export function useMultiMarketSupply( ); } + setCurrentStep('supplying'); + // Add supply transactions for each market for (const supply of supplies) { const morphoSupplyTx = encodeFunctionData({ @@ -142,14 +157,16 @@ export function useMultiMarketSupply( value: useEth ? totalAmount : 0n, }); - setShowProcessModal(false); + return true; } catch (error: unknown) { + console.error('Error in executeSupplyTransaction:', error); setShowProcessModal(false); if (error instanceof Error) { - toast.error('An error occurred. Please try again.'); + toast.error('Transaction failed or cancelled'); } else { - toast.error('An unexpected error occurred'); + toast.error('Transaction failed'); } + throw error; // Re-throw to be caught by approveAndSupply } }, [ account, @@ -166,27 +183,70 @@ export function useMultiMarketSupply( const approveAndSupply = useCallback(async () => { if (!account) { toast.error('Please connect your wallet'); - return; + return false; } try { setShowProcessModal(true); + setCurrentStep('approve'); + + if (useEth) { + setCurrentStep('supplying'); + const success = await executeSupplyTransaction(); + return success; + } + if (usePermit2Setting && !permit2Authorized) { - setCurrentStep('signing'); - await authorizePermit2(); + 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; + } } - setCurrentStep('supplying'); - await executeSupplyTransaction(); + + const success = await executeSupplyTransaction(); + return success; } catch (error) { console.error('Error in approveAndSupply:', error); setShowProcessModal(false); - setCurrentStep('approve'); + return false; } }, [ account, usePermit2Setting, permit2Authorized, authorizePermit2, + isApproved, + approve, + useEth, executeSupplyTransaction, ]); @@ -197,6 +257,6 @@ export function useMultiMarketSupply( setShowProcessModal, supplyPending, isLoadingPermit2, - executeSupplyTransaction + executeSupplyTransaction, }; } diff --git a/src/hooks/useUserBalances.ts b/src/hooks/useUserBalances.ts index dc63baa2..49d6ef54 100644 --- a/src/hooks/useUserBalances.ts +++ b/src/hooks/useUserBalances.ts @@ -9,14 +9,14 @@ type TokenBalance = { decimals: number; logoURI?: string; symbol: string; -} +}; type TokenResponse = { tokens: { address: string; balance: string; }[]; -} +}; export function useUserBalances() { const { address } = useAccount(); diff --git a/src/types/token.ts b/src/types/token.ts index 34f82599..8c17bb24 100644 --- a/src/types/token.ts +++ b/src/types/token.ts @@ -1,4 +1,4 @@ -import { SupportedNetworks } from "@/utils/networks"; +import { SupportedNetworks } from '@/utils/networks'; /** * Represents a token with fixed network and address information diff --git a/tailwind.config.ts b/tailwind.config.ts index 3c829216..1f31c599 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -76,10 +76,10 @@ const config: Config = { }, }, }), - plugin(function({ addBase }) { + plugin(function ({ addBase }) { addBase({ 'button, .nextui-button': { - '@apply rounded': {}, // This makes all buttons rounded by default + '@apply rounded': {}, // This makes all buttons rounded by default }, }); }), From 0c236b167154ba8e7798efdd5d38c088c33c9b32 Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Thu, 21 Nov 2024 22:52:16 +0800 Subject: [PATCH 14/16] chore: review feedbacks --- app/api/balances/route.ts | 2 -- app/positions/components/PositionsContent.tsx | 3 +++ app/positions/components/onboarding/AssetSelection.tsx | 8 +++++--- app/positions/components/onboarding/SetupPositions.tsx | 4 +--- src/components/Input/Input.tsx | 2 +- src/components/SupplyProcessModal.tsx | 4 ++-- src/hooks/useUserBalances.ts | 5 +++-- 7 files changed, 15 insertions(+), 13 deletions(-) diff --git a/app/api/balances/route.ts b/app/api/balances/route.ts index 137fe9b1..f4376d62 100644 --- a/app/api/balances/route.ts +++ b/app/api/balances/route.ts @@ -66,8 +66,6 @@ export async function GET(req: NextRequest) { balance: BigInt(token.tokenBalance).toString(10), })); - console.log('user tokens', tokens); - return NextResponse.json({ tokens, }); diff --git a/app/positions/components/PositionsContent.tsx b/app/positions/components/PositionsContent.tsx index d945d416..b89e3fce 100644 --- a/app/positions/components/PositionsContent.tsx +++ b/app/positions/components/PositionsContent.tsx @@ -34,6 +34,7 @@ export default function Positions() {
+
+

Choose markets you want to trust

+
+ {/* Markets Table */}
diff --git a/app/positions/components/onboarding/SetupPositions.tsx b/app/positions/components/onboarding/SetupPositions.tsx index db3cb50f..70f0fb4e 100644 --- a/app/positions/components/onboarding/SetupPositions.tsx +++ b/app/positions/components/onboarding/SetupPositions.tsx @@ -228,7 +228,7 @@ export function SetupPositions() { try { const success = await approveAndSupply(); if (success) { - router.push('/positions/onboarding/success'); + router.push('/positions/onboarding?step=success'); } } catch (error) { console.error('Supply failed:', error); diff --git a/app/positions/components/onboarding/SuccessPage.tsx b/app/positions/components/onboarding/SuccessPage.tsx index bb51554a..d0c48a5a 100644 --- a/app/positions/components/onboarding/SuccessPage.tsx +++ b/app/positions/components/onboarding/SuccessPage.tsx @@ -1,30 +1,33 @@ import Link from 'next/link'; +import { Button } from '@nextui-org/react'; +import { FaCheckCircle } from 'react-icons/fa'; import { useOnboarding } from './OnboardingContext'; export function SuccessPage() { const { selectedToken } = useOnboarding(); return ( -
+
-

Success!

-

- Your {selectedToken?.symbol} has been successfully supplied. +

+ +

Success!

+
+

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

- - View Position + + - - Explore Markets + +
From 180e9f7785fb3298207aa4bcb43875888a510c24 Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Thu, 21 Nov 2024 23:18:28 +0800 Subject: [PATCH 16/16] chore: lint and review fixes --- .../components/onboarding/AssetSelection.tsx | 8 ++-- .../onboarding/OnboardingContent.tsx | 40 +++++++++++++++++ .../components/onboarding/SetupPositions.tsx | 8 ++-- .../components/onboarding/SuccessPage.tsx | 8 ++-- app/positions/onboarding/page.tsx | 43 +++---------------- src/components/Input/Input.tsx | 2 +- src/components/SupplyProcessModal.tsx | 2 +- src/hooks/useMultiMarketSupply.ts | 1 - src/hooks/useUserBalances.ts | 2 +- src/hooks/useUserPositions.ts | 4 +- 10 files changed, 65 insertions(+), 53 deletions(-) create mode 100644 app/positions/components/onboarding/OnboardingContent.tsx diff --git a/app/positions/components/onboarding/AssetSelection.tsx b/app/positions/components/onboarding/AssetSelection.tsx index 1e2c7c4f..486d84d8 100644 --- a/app/positions/components/onboarding/AssetSelection.tsx +++ b/app/positions/components/onboarding/AssetSelection.tsx @@ -111,10 +111,10 @@ export function AssetSelection() { {tokensWithMarkets.map((token) => ( handleTokenSelect(token)} - className="hover:border-primary group relative flex items-start gap-4 rounded-lg border border-gray-200 bg-white p-4 text-left transition-all duration-300 hover:shadow-lg dark:border-gray-700 dark:bg-gray-800/50 dark:hover:bg-gray-800" + 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 }} > @@ -132,7 +132,7 @@ export function AssetSelection() {
-

+

{token.symbol}

@@ -150,7 +150,7 @@ export function AssetSelection() {

-

+

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

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/SetupPositions.tsx b/app/positions/components/onboarding/SetupPositions.tsx index 70f0fb4e..e800f165 100644 --- a/app/positions/components/onboarding/SetupPositions.tsx +++ b/app/positions/components/onboarding/SetupPositions.tsx @@ -7,7 +7,7 @@ import { formatUnits, parseUnits } from 'viem'; import OracleVendorBadge from '@/components/OracleVendorBadge'; import { SupplyProcessModal } from '@/components/SupplyProcessModal'; import { useLocalStorage } from '@/hooks/useLocalStorage'; -import { useMultiMarketSupply, MarketSupply } from '@/hooks/useMultiMarketSupply'; +import { useMultiMarketSupply } from '@/hooks/useMultiMarketSupply'; import { useUserBalances } from '@/hooks/useUserBalances'; import { formatBalance, formatReadable } from '@/utils/balance'; import { parseOracleVendors } from '@/utils/oracle'; @@ -230,8 +230,8 @@ export function SetupPositions() { if (success) { router.push('/positions/onboarding?step=success'); } - } catch (error) { - console.error('Supply failed:', error); + } catch (supplyError) { + console.error('Supply failed:', supplyError); // Error toast is already shown in useMultiMarketSupply } finally { setIsSupplying(false); @@ -458,7 +458,7 @@ export function SetupPositions() { color="primary" isDisabled={error !== null || !totalAmount || supplies.length === 0} isLoading={supplyPending || isLoadingPermit2} - onPress={handleSupply} + onPress={ () => void handleSupply()} className="min-w-[120px] rounded" > Execute diff --git a/app/positions/components/onboarding/SuccessPage.tsx b/app/positions/components/onboarding/SuccessPage.tsx index d0c48a5a..8b87ba90 100644 --- a/app/positions/components/onboarding/SuccessPage.tsx +++ b/app/positions/components/onboarding/SuccessPage.tsx @@ -1,10 +1,12 @@ -import Link from 'next/link'; 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 (
@@ -19,12 +21,12 @@ export function SuccessPage() {
- + - + diff --git a/app/positions/onboarding/page.tsx b/app/positions/onboarding/page.tsx index 31727dd7..70b6437d 100644 --- a/app/positions/onboarding/page.tsx +++ b/app/positions/onboarding/page.tsx @@ -1,40 +1,11 @@ -'use client'; +import { Metadata } from 'next'; +import { OnboardingContent } from '../components/onboarding/OnboardingContent'; -import { useSearchParams } from 'next/navigation'; -import Header from '@/components/layout/header/Header'; -import { AssetSelection } from '../components/onboarding/AssetSelection'; -import { OnboardingProvider } from '../components/onboarding/OnboardingContext'; -import { RiskSelection } from '../components/onboarding/RiskSelection'; -import { SetupPositions } from '../components/onboarding/SetupPositions'; -import { SuccessPage } from '../components/onboarding/SuccessPage'; +export const metadata: Metadata = { + title: 'New Position | Monarch', + description: 'Create a new position on Morpho Blue', +}; export default function OnboardingPage() { - 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()}
-
-
-
- ); + return ; } diff --git a/src/components/Input/Input.tsx b/src/components/Input/Input.tsx index 380dc521..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-primary 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" />
Market ID CollateralRisk IndicatorsMarket Params Supply APYAmountDistribution
- {market.uniqueKey.slice(2, 8)} + + + {market.uniqueKey.slice(2, 8)} +
@@ -210,7 +353,11 @@ export function SetupPositions() {
{vendors.map((vendor) => ( - + ))}
@@ -222,15 +369,57 @@ export function SetupPositions() { {formatReadable(market.state.supplyApy * 100)}%
-
- handleAmountChange(market.uniqueKey, e.target.value)} - placeholder="0.0" - className="bg-hovered focus:border-monarch-orange h-8 w-36 rounded p-2 text-right font-mono focus:outline-none" - /> - {selectedToken.symbol} +
+
+
+ + 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)}% + + +
+