From 8ad83de111bd14a386c7cc81a91bc1004a9a0839 Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Mon, 11 Nov 2024 14:14:50 +0700 Subject: [PATCH 1/6] feat: use context for markets --- app/layout.tsx | 20 ++- app/markets/components/markets.tsx | 4 +- src/components/providers/ClientProviders.tsx | 18 +++ src/contexts/MarketsContext.tsx | 144 +++++++++++++++++++ src/hooks/useMarkets.ts | 128 +---------------- 5 files changed, 174 insertions(+), 140 deletions(-) create mode 100644 src/components/providers/ClientProviders.tsx create mode 100644 src/contexts/MarketsContext.tsx diff --git a/app/layout.tsx b/app/layout.tsx index a84da744..ca04dcde 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,9 +1,9 @@ import './global.css'; -import { ToastContainer } from 'react-toastify'; import GoogleAnalytics from '@/components/GoogleAnalytics/GoogleAnalytics'; import RiskNotificationModal from '@/components/RiskNotificationModal'; import OnchainProviders from '@/OnchainProviders'; +import { ClientProviders } from '@/components/providers/ClientProviders'; import { initAnalytics } from '@/utils/analytics'; import { inter, zen, monospace } from './fonts'; @@ -21,20 +21,18 @@ export const metadata: Metadata = { // so we can track page views and early events initAnalytics(); -/** Root layout to define the structure of every page - * https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts - */ export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - - - {children} - - - - + + + + {children} + + + + diff --git a/app/markets/components/markets.tsx b/app/markets/components/markets.tsx index 82fcd5c5..ffff8c20 100644 --- a/app/markets/components/markets.tsx +++ b/app/markets/components/markets.tsx @@ -8,7 +8,7 @@ import Header from '@/components/layout/header/Header'; import EmptyScreen from '@/components/Status/EmptyScreen'; import LoadingScreen from '@/components/Status/LoadingScreen'; import { SupplyModal } from '@/components/supplyModal'; -import useMarkets from '@/hooks/useMarkets'; +import { useMarkets } from '@/hooks/useMarkets'; import { usePagination } from '@/hooks/usePagination'; import { SupportedNetworks } from '@/utils/networks'; import { OracleVendors, parseOracleVendors } from '@/utils/oracle'; @@ -39,7 +39,7 @@ export default function Markets() { const router = useRouter(); const searchParams = useSearchParams(); - const { loading, data: rawMarkets } = useMarkets(); + const { loading, markets: rawMarkets } = useMarkets(); const defaultNetwork = (() => { const networkParam = searchParams.get('network'); diff --git a/src/components/providers/ClientProviders.tsx b/src/components/providers/ClientProviders.tsx new file mode 100644 index 00000000..ba162506 --- /dev/null +++ b/src/components/providers/ClientProviders.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { ReactNode } from 'react'; +import { MarketsProvider } from '@/contexts/MarketsContext'; +import { ToastContainer } from 'react-toastify'; + +type ClientProvidersProps = { + children: ReactNode; +}; + +export function ClientProviders({ children }: ClientProvidersProps) { + return ( + + {children} + + + ); +} \ No newline at end of file diff --git a/src/contexts/MarketsContext.tsx b/src/contexts/MarketsContext.tsx new file mode 100644 index 00000000..5ce02090 --- /dev/null +++ b/src/contexts/MarketsContext.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { createContext, useContext, ReactNode, useCallback, useEffect, useState } from 'react'; +import { marketsQuery } from '@/graphql/queries'; +import { getRewardPer1000USD } from '@/utils/morpho'; +import { isSupportedChain } from '@/utils/networks'; +import { MORPHOTokenAddress } from '@/utils/tokens'; +import { Market } from '@/utils/types'; +import { getMarketWarningsWithDetail } from '@/utils/warnings'; +import useLiquidations from '@/hooks/useLiquidations'; + +type MarketsContextType = { + markets: Market[]; + loading: boolean; + isRefetching: boolean; + error: unknown | null; + refetch: (onSuccess?: () => void) => void; +}; + +const MarketsContext = createContext(undefined); + +type MarketsProviderProps = { + children: ReactNode; +}; + +export function MarketsProvider({ children }: MarketsProviderProps) { + const [loading, setLoading] = useState(true); + const [isRefetching, setIsRefetching] = useState(false); + const [markets, setMarkets] = useState([]); + const [error, setError] = useState(null); + + const { + loading: liquidationsLoading, + liquidatedMarketIds, + error: liquidationsError, + refetch: refetchLiquidations, + } = useLiquidations(); + + const fetchMarkets = useCallback( + async (isRefetch = false) => { + try { + if (isRefetch) { + setIsRefetching(true); + } else { + setLoading(true); + } + + const marketsResponse = await fetch('https://blue-api.morpho.org/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: marketsQuery, + variables: { first: 1000, where: { whitelisted: true } }, + }), + }); + + const marketsResult = await marketsResponse.json(); + const rawMarkets = marketsResult.data.markets.items as Market[]; + + const filtered = rawMarkets + .filter((market) => market.collateralAsset != undefined) + .filter( + (market) => market.warnings.find((w) => w.type === 'not_whitelisted') === undefined, + ) + .filter((market) => isSupportedChain(market.morphoBlue.chain.id)); + + const processedMarkets = filtered.map((market) => { + const entry = market.state.rewards.find( + (reward) => reward.asset.address.toLowerCase() === MORPHOTokenAddress.toLowerCase(), + ); + + const warningsWithDetail = getMarketWarningsWithDetail(market); + const isProtectedByLiquidationBots = liquidatedMarketIds.has(market.id); + + if (!entry) { + return { + ...market, + rewardPer1000USD: undefined, + warningsWithDetail, + isProtectedByLiquidationBots, + }; + } + + const supplyAssetUSD = Number(market.state.supplyAssetsUsd); + const rewardPer1000USD = getRewardPer1000USD(entry.yearlySupplyTokens, supplyAssetUSD); + + return { + ...market, + rewardPer1000USD, + warningsWithDetail, + isProtectedByLiquidationBots, + }; + }); + + setMarkets(processedMarkets); + } catch (_error) { + setError(_error); + } finally { + setLoading(false); + setIsRefetching(false); + } + }, + [liquidatedMarketIds], + ); + + useEffect(() => { + if (!liquidationsLoading) { + fetchMarkets().catch(console.error); + } + }, [liquidationsLoading, fetchMarkets]); + + const refetch = useCallback( + (onSuccess?: () => void) => { + refetchLiquidations(); + fetchMarkets(true).then(onSuccess).catch(console.error); + }, + [refetchLiquidations, fetchMarkets], + ); + + const isLoading = loading || liquidationsLoading; + const combinedError = error || liquidationsError; + + return ( + + {children} + + ); +} + +export function useMarkets() { + const context = useContext(MarketsContext); + if (context === undefined) { + throw new Error('useMarkets must be used within a MarketsProvider'); + } + return context; +} \ No newline at end of file diff --git a/src/hooks/useMarkets.ts b/src/hooks/useMarkets.ts index 96fc0323..95268f90 100644 --- a/src/hooks/useMarkets.ts +++ b/src/hooks/useMarkets.ts @@ -1,127 +1 @@ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -'use client'; - -import { useState, useEffect, useCallback } from 'react'; -import { marketsQuery } from '@/graphql/queries'; -import { getRewardPer1000USD } from '@/utils/morpho'; -import { isSupportedChain } from '@/utils/networks'; -import { MORPHOTokenAddress } from '@/utils/tokens'; -import { Market } from '@/utils/types'; -import { getMarketWarningsWithDetail } from '@/utils/warnings'; -import useLiquidations from './useLiquidations'; - -export type Reward = { - id: string; - net_reward_apr: null | string; - reward_token_rates: { - token: { - address: string; - symbol: string; - }; - supply_rate: { - token_amount_per1000_market_token: string; // with decimals - token_amount_per1000_usd: string; // with decimals - }; - }[]; -}; - -const useMarkets = () => { - const [loading, setLoading] = useState(true); - const [isRefetching, setIsRefetching] = useState(false); - const [data, setData] = useState([]); - const [error, setError] = useState(null); - const { - loading: liquidationsLoading, - liquidatedMarketIds, - error: liquidationsError, - refetch: refetchLiquidations, - } = useLiquidations(); - - const fetchData = useCallback( - async (isRefetch = false) => { - try { - if (isRefetch) { - setIsRefetching(true); - } else { - setLoading(true); - } - - // Fetch markets - const marketsResponse = await fetch('https://blue-api.morpho.org/graphql', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - query: marketsQuery, - variables: { first: 1000, where: { whitelisted: true } }, - }), - }); - const marketsResult = await marketsResponse.json(); - const markets = marketsResult.data.markets.items as Market[]; - - const filtered = markets - .filter((market) => market.collateralAsset != undefined) - .filter( - (market) => market.warnings.find((w) => w.type === 'not_whitelisted') === undefined, - ) - .filter((market) => isSupportedChain(market.morphoBlue.chain.id)); - - const final = filtered.map((market) => { - const entry = market.state.rewards.find( - (reward) => reward.asset.address.toLowerCase() === MORPHOTokenAddress.toLowerCase(), - ); - - const warningsWithDetail = getMarketWarningsWithDetail(market); - const isProtectedByLiquidationBots = liquidatedMarketIds.has(market.id); - - if (!entry) { - return { - ...market, - rewardPer1000USD: undefined, - warningsWithDetail, - isProtectedByLiquidationBots, - }; - } - - const supplyAssetUSD = Number(market.state.supplyAssetsUsd); - const rewardPer1000USD = getRewardPer1000USD(entry.yearlySupplyTokens, supplyAssetUSD); - - return { - ...market, - rewardPer1000USD, - warningsWithDetail, - isProtectedByLiquidationBots, - }; - }); - - setData(final); - } catch (_error) { - setError(_error); - } finally { - setLoading(false); - setIsRefetching(false); - } - }, - [liquidatedMarketIds], - ); - - useEffect(() => { - if (!liquidationsLoading) { - fetchData().catch(console.error); - } - }, [liquidationsLoading, fetchData]); - - const refetch = useCallback( - (onSuccess?: () => void) => { - refetchLiquidations(); - fetchData(true).then(onSuccess).catch(console.error); - }, - [refetchLiquidations, fetchData], - ); - - const isLoading = loading || liquidationsLoading; - const combinedError = error || liquidationsError; - - return { loading: isLoading, isRefetching, data, error: combinedError, refetch }; -}; - -export default useMarkets; +export { useMarkets } from '@/contexts/MarketsContext'; From bf68e4b5f1c94817d8dc1f4926c7c7e9c47a792c Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Mon, 11 Nov 2024 14:25:47 +0700 Subject: [PATCH 2/6] chore: lint and refresh --- app/layout.tsx | 2 +- app/markets/components/markets.tsx | 22 +++++++++++++++++--- src/components/providers/ClientProviders.tsx | 2 +- src/contexts/MarketsContext.tsx | 5 ++--- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index ca04dcde..8b0e247d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,9 +1,9 @@ import './global.css'; import GoogleAnalytics from '@/components/GoogleAnalytics/GoogleAnalytics'; +import { ClientProviders } from '@/components/providers/ClientProviders'; import RiskNotificationModal from '@/components/RiskNotificationModal'; import OnchainProviders from '@/OnchainProviders'; -import { ClientProviders } from '@/components/providers/ClientProviders'; import { initAnalytics } from '@/utils/analytics'; import { inter, zen, monospace } from './fonts'; diff --git a/app/markets/components/markets.tsx b/app/markets/components/markets.tsx index ffff8c20..329a060b 100644 --- a/app/markets/components/markets.tsx +++ b/app/markets/components/markets.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState, useRef } from 'react'; import storage from 'local-storage-fallback'; import { useRouter, useSearchParams } from 'next/navigation'; -import { FaEllipsisH } from 'react-icons/fa'; +import { FaEllipsisH, FaSync } from 'react-icons/fa'; import { toast } from 'react-toastify'; import Header from '@/components/layout/header/Header'; import EmptyScreen from '@/components/Status/EmptyScreen'; @@ -39,7 +39,7 @@ export default function Markets() { const router = useRouter(); const searchParams = useSearchParams(); - const { loading, markets: rawMarkets } = useMarkets(); + const { loading, markets: rawMarkets, refetch, isRefetching } = useMarkets(); const defaultNetwork = (() => { const networkParam = searchParams.get('network'); @@ -263,6 +263,10 @@ export default function Markets() { router.push(targetPath); }; + const handleRefresh = () => { + refetch(() => toast.success('Markets refreshed')); + }; + return (
@@ -333,7 +337,19 @@ export default function Markets() { />
-
+
+ + + )} +
chainId !== groupedPosition.chainId, + [chainId, groupedPosition.chainId] + ); + + console.log('needSwitchChain', needSwitchChain); + const handleExecuteRebalance = useCallback(async () => { + if (needSwitchChain) { + try { + await switchChain({ chainId: groupedPosition.chainId }); + // The actual execution will happen after network switch through useEffect + return; + } catch (error) { + console.error('Failed to switch network:', error); + toast.error('Failed to switch network'); + return; + } + } + + console.log('executeRebalance'); setShowProcessModal(true); try { await executeRebalance(); @@ -169,7 +193,7 @@ export function RebalanceModal({ } finally { setShowProcessModal(false); } - }, [executeRebalance]); + }, [executeRebalance, needSwitchChain, switchChain, groupedPosition.chainId]); const handleManualRefresh = () => { refetch(() => { @@ -273,7 +297,9 @@ 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" > - Execute Rebalance + {needSwitchChain + ? 'Switch Network & Execute' + : 'Execute Rebalance'} From 760bad4512d15591724d77994a247ce28ccb5814 Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Mon, 11 Nov 2024 14:47:29 +0700 Subject: [PATCH 6/6] chore: lint --- app/positions/components/RebalanceModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/positions/components/RebalanceModal.tsx b/app/positions/components/RebalanceModal.tsx index 3ab003e8..cdd36168 100644 --- a/app/positions/components/RebalanceModal.tsx +++ b/app/positions/components/RebalanceModal.tsx @@ -11,6 +11,7 @@ import { import { GrRefresh } from 'react-icons/gr'; import { toast } from 'react-toastify'; import { parseUnits } from 'viem'; +import { useAccount, useSwitchChain } from 'wagmi'; import { useMarkets } from '@/hooks/useMarkets'; import { usePagination } from '@/hooks/usePagination'; import { useRebalance } from '@/hooks/useRebalance'; @@ -21,7 +22,6 @@ import { FromAndToMarkets } from './FromAndToMarkets'; import { RebalanceActionInput } from './RebalanceActionInput'; import { RebalanceCart } from './RebalanceCart'; import { RebalanceProcessModal } from './RebalanceProcessModal'; -import { useAccount, useSwitchChain } from 'wagmi'; type RebalanceModalProps = { groupedPosition: GroupedPosition; @@ -174,7 +174,7 @@ export function RebalanceModal({ const handleExecuteRebalance = useCallback(async () => { if (needSwitchChain) { try { - await switchChain({ chainId: groupedPosition.chainId }); + switchChain({ chainId: groupedPosition.chainId }); // The actual execution will happen after network switch through useEffect return; } catch (error) {