diff --git a/.eslintrc.js b/.eslintrc.js index b591ae69..b2920d3a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -100,7 +100,6 @@ module.exports = { '@typescript-eslint/consistent-type-assertions': 'error', '@typescript-eslint/no-confusing-non-null-assertion': 'error', '@typescript-eslint/no-extra-non-null-assertion': 'error', - '@typescript-eslint/no-non-null-assertion': 'error', '@typescript-eslint/no-non-null-asserted-optional-chain': 'error', '@typescript-eslint/prefer-as-const': 'error', diff --git a/app/markets/components/MarketRowDetail.tsx b/app/markets/components/MarketRowDetail.tsx index 5ca845a2..c64d49f8 100644 --- a/app/markets/components/MarketRowDetail.tsx +++ b/app/markets/components/MarketRowDetail.tsx @@ -1,79 +1,56 @@ -import { ExternalLinkIcon } from '@radix-ui/react-icons'; -import { zeroAddress } from 'viem'; +import { Tooltip } from '@nextui-org/tooltip'; +import { ExternalLinkIcon, QuestionMarkCircledIcon } from '@radix-ui/react-icons'; import { OracleFeedInfo } from '@/components/FeedInfo/OracleFeedInfo'; import { Info } from '@/components/Info/info'; +import OracleVendorBadge from '@/components/OracleVendorBadge'; import { formatReadable } from '@/utils/balance'; import { getExplorerURL } from '@/utils/external'; import { Market } from '@/utils/types'; export function ExpandedMarketDetail({ market }: { market: Market }) { - console.log('market.oracleFeed', market.oracleFeed); + const oracleData = market.oracle ? market.oracle.data : null; + + const hasFeeds = + oracleData && + (oracleData.baseFeedOne || + oracleData.baseFeedTwo || + oracleData.quoteFeedOne || + oracleData.quoteFeedTwo); return (
{/* Oracle info */}
- {/* warnings */}

Oracle Info

-
-

Oracle:

+
+

Vendors:

-

{market.oracleInfo.type}

+
- {market.oracleFeed && ( - <> -
-

Base feed

- - + {hasFeeds && ( +
+
+

Feed Routes:

+ + +
- {/* only shows base feed 2 if non-zero */} - {market.oracleFeed.baseFeedTwoAddress !== zeroAddress && ( -
-

Base feed 2

- -
- )} - - {market.oracleFeed.quoteFeedOneAddress !== zeroAddress && ( -
-

Quote feed

- -
- )} - - {/* only shows quote feed 2 if non-zero */} - {market.oracleFeed.quoteFeedTwoAddress !== zeroAddress && ( -
-

Quote feed 2

- -
- )} - + + + + +
)}
diff --git a/app/markets/components/MarketTableBody.tsx b/app/markets/components/MarketTableBody.tsx index 870e4975..5603a36e 100644 --- a/app/markets/components/MarketTableBody.tsx +++ b/app/markets/components/MarketTableBody.tsx @@ -4,6 +4,7 @@ import { ExternalLinkIcon } from '@radix-ui/react-icons'; import Image from 'next/image'; import { FaShieldAlt } from 'react-icons/fa'; import { GoStarFill, GoStar } from 'react-icons/go'; +import OracleVendorBadge from '@/components/OracleVendorBadge'; import { formatReadable } from '@/utils/balance'; import { getMarketURL } from '@/utils/external'; import { getNetworkImg } from '@/utils/networks'; @@ -116,6 +117,11 @@ export function MarketTableBody({ img={collatImg} symbol={collatToShow} /> + +
+ +
+ {Number(item.lltv) / 1e16}% @@ -174,7 +180,7 @@ export function MarketTableBody({ {expandedRowId === item.uniqueKey && ( - + diff --git a/app/markets/components/marketsTable.tsx b/app/markets/components/marketsTable.tsx index 7c622cd4..88a6e5e6 100644 --- a/app/markets/components/marketsTable.tsx +++ b/app/markets/components/marketsTable.tsx @@ -62,6 +62,7 @@ function MarketsTable({ sortDirection={sortDirection} targetColumn={SortColumn.CollateralAsset} /> + Oracle Market Collateral + Oracle LLTV APY Supplied @@ -111,7 +113,8 @@ export function SuppliedMarketsDetail({ const suppliedAmount = Number( formatBalance(position.supplyAssets, position.market.loanAsset.decimals), ); - const percentageOfPortfolio = totalSupply > 0 ? (suppliedAmount / totalSupply) * 100 : 0; + const percentageOfPortfolio = + totalSupply > 0 ? (suppliedAmount / totalSupply) * 100 : 0; const warningColor = getWarningColor(position.warningsWithDetail); return ( @@ -163,6 +166,11 @@ export function SuppliedMarketsDetail({ 'N/A' )} + +
+ +
+ {formatBalance(position.market.lltv, 16)}% diff --git a/src/components/FeedInfo/OracleFeedInfo.tsx b/src/components/FeedInfo/OracleFeedInfo.tsx index 77ad5763..96a85b1e 100644 --- a/src/components/FeedInfo/OracleFeedInfo.tsx +++ b/src/components/FeedInfo/OracleFeedInfo.tsx @@ -1,37 +1,59 @@ +import { Tooltip } from '@nextui-org/tooltip'; import { ExternalLinkIcon } from '@radix-ui/react-icons'; +import Image from 'next/image'; import Link from 'next/link'; -import { Address, zeroAddress } from 'viem'; +import { IoIosSwap } from 'react-icons/io'; +import { IoWarningOutline } from 'react-icons/io5'; +import { Address } from 'viem'; import { getSlicedAddress } from '@/utils/address'; import { getExplorerURL } from '@/utils/external'; +import { OracleVendors, OracleVendorIcons } from '@/utils/oracle'; +import { OracleFeed } from '@/utils/types'; export function OracleFeedInfo({ - address, - title, + feed, chainId, }: { - address: string; - title: string | null; + feed: OracleFeed | null; chainId: number; -}): JSX.Element { - const isLink = address !== zeroAddress; +}): JSX.Element | null { + if (!feed) return null; + + const fromAsset = feed.pair?.[0] ?? 'Unknown'; + const toAsset = feed.pair?.[1] ?? 'Unknown'; + + const vendorIcon = + OracleVendorIcons[feed.vendor as OracleVendors] ?? OracleVendorIcons[OracleVendors.Unknown]; + + const content = ( +
+
+ {fromAsset} + + {toAsset} +
+ {vendorIcon ? ( + {feed.vendor + ) : ( + + )} +
+ ); - if (isLink) return ( - - {title ? ( -

{title}

- ) : ( -

{getSlicedAddress(address as Address)}

- )} - - + + {content} + + + ); + } - return ( -

Hardcoded 1

- ); -} diff --git a/src/components/OracleVendorBadge.tsx b/src/components/OracleVendorBadge.tsx new file mode 100644 index 00000000..e9bace9f --- /dev/null +++ b/src/components/OracleVendorBadge.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { Tooltip } from '@nextui-org/tooltip'; +import Image from 'next/image'; +import { IoWarningOutline } from 'react-icons/io5'; +import { OracleVendorIcons, OracleVendors, parseOracleVendors } from '@/utils/oracle'; +import { MorphoChainlinkOracleData } from '@/utils/types'; + +type OracleVendorBadgeProps = { + oracleData: MorphoChainlinkOracleData | null; + useTooltip?: boolean; +}; + +const renderVendorIcon = (vendor: OracleVendors) => ( + OracleVendorIcons[vendor] + ? {vendor} + : +); + +function OracleVendorBadge({ oracleData, useTooltip = true }: OracleVendorBadgeProps) { + const { vendors } = parseOracleVendors(oracleData); + + const noFeeds = vendors.length === 0; + + const content = ( +
+ {!useTooltip && ( + + {noFeeds ? 'No Oracle' : vendors.join(', ')}: + + )} + {noFeeds ? ( + + ) : ( + vendors.map((vendor, index) => ( + + {renderVendorIcon(vendor)} + + )) + )} +
+ ); + + if (useTooltip) { + return ( + +

+ {noFeeds ? 'No Oracle Feed Used' : 'Oracle Vendors:'} +

+
    + {vendors.map((vendor, index) => ( +
  • + {vendor} +
  • + ))} +
+
+ } + className="rounded-sm" + > + {content} + + ); + } + + return content; +}; + +export default OracleVendorBadge; diff --git a/src/components/TokenIcon.tsx b/src/components/TokenIcon.tsx index 21511675..414c678c 100644 --- a/src/components/TokenIcon.tsx +++ b/src/components/TokenIcon.tsx @@ -12,7 +12,6 @@ type TokenIconProps = { export function TokenIcon({ address, chainId, width, height }: TokenIconProps) { const token = findToken(address, chainId); - if (!token?.img) { return
; } diff --git a/src/hooks/useMarkets.ts b/src/hooks/useMarkets.ts index df5b67c6..5fb5d147 100644 --- a/src/hooks/useMarkets.ts +++ b/src/hooks/useMarkets.ts @@ -25,6 +25,17 @@ export type Reward = { }; const marketsQuery = ` + fragment FeedFields on OracleFeed { + address + chain { + id + } + description + id + pair + vendor + } + query getMarkets($first: Int, $where: MarketFilters) { markets(first: $first, where: $where) { items { @@ -47,21 +58,37 @@ const marketsQuery = ` type __typename } - oracleFeed { - baseFeedOneAddress - baseFeedOneDescription - baseFeedTwoAddress - baseFeedTwoDescription - quoteFeedOneAddress - quoteFeedOneDescription - quoteFeedTwoAddress - quoteFeedTwoDescription - baseVault - baseVaultDescription - baseVaultVendor - quoteVault - quoteVaultDescription - quoteVaultVendor + oracle { + data { + ... on MorphoChainlinkOracleData { + baseFeedOne { + ...FeedFields + } + baseFeedTwo { + ...FeedFields + } + quoteFeedOne { + ...FeedFields + } + quoteFeedTwo { + ...FeedFields + } + } + ... on MorphoChainlinkOracleV2Data { + baseFeedOne { + ...FeedFields + } + baseFeedTwo { + ...FeedFields + } + quoteFeedOne { + ...FeedFields + } + quoteFeedTwo { + ...FeedFields + } + } + } } loanAsset { id diff --git a/src/hooks/useUserPositions.ts b/src/hooks/useUserPositions.ts index 1fa731c7..2971c821 100644 --- a/src/hooks/useUserPositions.ts +++ b/src/hooks/useUserPositions.ts @@ -5,127 +5,155 @@ import { SupportedNetworks } from '@/utils/networks'; import { MarketPosition, UserTransaction } from '@/utils/types'; import { getMarketWarningsWithDetail } from '@/utils/warnings'; -const query = `query getUserMarketPositions( - $address: String! - $chainId: Int -) { - userByAddress(address: $address, chainId: $chainId) { - marketPositions { - supplyShares - supplyAssets - supplyAssetsUsd - borrowShares - borrowAssets - borrowAssetsUsd - market { - id - uniqueKey - lltv - oracleAddress - irmAddress - morphoBlue { +const query = ` + fragment FeedFields on OracleFeed { + address + chain { + id + } + description + id + pair + vendor + } + + query getUserMarketPositions( + $address: String! + $chainId: Int + ) { + userByAddress(address: $address, chainId: $chainId) { + marketPositions { + supplyShares + supplyAssets + supplyAssetsUsd + borrowShares + borrowAssets + borrowAssetsUsd + market { id - address - chain { + uniqueKey + lltv + oracleAddress + irmAddress + morphoBlue { id + address + chain { + id + } } - } - dailyApys { - netSupplyApy - } - weeklyApys { - netSupplyApy - } - monthlyApys { - netSupplyApy - } - loanAsset { - address - symbol - decimals - } - collateralAsset { - address - symbol - decimals - } - state { - liquidityAssets - supplyAssetsUsd - supplyAssets - borrowAssets - borrowAssetsUsd - rewards { - yearlySupplyTokens - asset { - address - priceUsd - spotPriceEth + dailyApys { + netSupplyApy + } + weeklyApys { + netSupplyApy + } + monthlyApys { + netSupplyApy + } + loanAsset { + address + symbol + decimals + } + collateralAsset { + address + symbol + decimals + } + state { + liquidityAssets + supplyAssetsUsd + supplyAssets + borrowAssets + borrowAssetsUsd + rewards { + yearlySupplyTokens + asset { + address + priceUsd + spotPriceEth + } } + utilization + } + oracle { + data { + ... on MorphoChainlinkOracleData { + baseFeedOne { + ...FeedFields + } + baseFeedTwo { + ...FeedFields + } + quoteFeedOne { + ...FeedFields + } + quoteFeedTwo { + ...FeedFields + } + } + ... on MorphoChainlinkOracleV2Data { + baseFeedOne { + ...FeedFields + } + baseFeedTwo { + ...FeedFields + } + quoteFeedOne { + ...FeedFields + } + quoteFeedTwo { + ...FeedFields + } + } + } + } + oracleInfo { + type + } + warnings { + type + level + __typename } - utilization - } - oracleFeed { - baseFeedOneAddress - baseFeedOneDescription - baseFeedTwoAddress - baseFeedTwoDescription - quoteFeedOneAddress - quoteFeedOneDescription - quoteFeedTwoAddress - quoteFeedTwoDescription - baseVault - baseVaultDescription - baseVaultVendor - quoteVault - quoteVaultDescription - quoteVaultVendor - } - oracleInfo { - type - } - warnings { - type - level - __typename } } - } - transactions { - hash - timestamp - type - data { - __typename - ... on MarketTransferTransactionData { - assetsUsd - shares - assets - market { - id - uniqueKey - morphoBlue { - chain { + transactions { + hash + timestamp + type + data { + __typename + ... on MarketTransferTransactionData { + assetsUsd + shares + assets + market { + id + uniqueKey + morphoBlue { + chain { + id + } + } + collateralAsset { id + address + decimals } + loanAsset { + id + address + decimals + symbol + } } - collateralAsset { - id - address - decimals - } - loanAsset { - id - address - decimals - symbol - } } } } } - } -}`; + }`; const useUserPositions = (user: string | undefined) => { const [loading, setLoading] = useState(true); diff --git a/src/imgs/oracles/chainlink.png b/src/imgs/oracles/chainlink.png new file mode 100644 index 00000000..63a1f357 Binary files /dev/null and b/src/imgs/oracles/chainlink.png differ diff --git a/src/imgs/oracles/compound.webp b/src/imgs/oracles/compound.webp new file mode 100644 index 00000000..e0eed57e Binary files /dev/null and b/src/imgs/oracles/compound.webp differ diff --git a/src/imgs/oracles/lido.png b/src/imgs/oracles/lido.png new file mode 100644 index 00000000..b0800b61 Binary files /dev/null and b/src/imgs/oracles/lido.png differ diff --git a/src/imgs/oracles/pyth.png b/src/imgs/oracles/pyth.png new file mode 100644 index 00000000..65288213 Binary files /dev/null and b/src/imgs/oracles/pyth.png differ diff --git a/src/imgs/oracles/redstone.png b/src/imgs/oracles/redstone.png new file mode 100644 index 00000000..932d5056 Binary files /dev/null and b/src/imgs/oracles/redstone.png differ diff --git a/src/imgs/oracles/uma.png b/src/imgs/oracles/uma.png new file mode 100644 index 00000000..bb637190 Binary files /dev/null and b/src/imgs/oracles/uma.png differ diff --git a/src/utils/oracle.ts b/src/utils/oracle.ts new file mode 100644 index 00000000..61a871df --- /dev/null +++ b/src/utils/oracle.ts @@ -0,0 +1,57 @@ +import { MorphoChainlinkOracleData } from './types'; + +type VendorInfo = { + vendors: OracleVendors[]; + isUnknown: boolean; +}; + +export enum OracleVendors { + Chainlink = 'Chainlink', + PythNetwork = 'Pyth Network', + Redstone = 'Redstone', + Oval = 'Oval', + Compound = 'Compound', + Lido = 'Lido', + Unknown = 'Unknown', +} + +export const OracleVendorIcons: Record = { + [OracleVendors.Chainlink]: require('../imgs/oracles/chainlink.png') as string, + [OracleVendors.PythNetwork]: require('../imgs/oracles/pyth.png') as string, + [OracleVendors.Redstone]: require('../imgs/oracles/redstone.png') as string, + [OracleVendors.Oval]: require('../imgs/oracles/uma.png') as string, + [OracleVendors.Compound]: require('../imgs/oracles/compound.webp') as string, + [OracleVendors.Lido]: require('../imgs/oracles/lido.png') as string, + [OracleVendors.Unknown]: '', +}; + +export function parseOracleVendors(oracleData: MorphoChainlinkOracleData | null): VendorInfo { + if (!oracleData) return { vendors: [], isUnknown: false }; + if ( + !oracleData.baseFeedOne && + !oracleData.baseFeedTwo && + !oracleData.quoteFeedOne && + !oracleData.quoteFeedTwo + ) + return { vendors: [], isUnknown: true }; + + const feeds = [ + oracleData.baseFeedOne, + oracleData.baseFeedTwo, + oracleData.quoteFeedOne, + oracleData.quoteFeedTwo, + ]; + + const vendors = new Set( + feeds + .filter(feed => feed?.vendor) + .map(feed => Object.values(OracleVendors) + .find(v => v.toLowerCase() === feed!.vendor!.toLowerCase()) ?? OracleVendors.Unknown + ) + ); + + return { + vendors: Array.from(vendors), + isUnknown: vendors.has(OracleVendors.Unknown) || vendors.size === 0, + }; +} diff --git a/src/utils/types.ts b/src/utils/types.ts index a7ec64db..c40d12b3 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -58,6 +58,9 @@ export type MarketPosition = { utilization: number; }; warnings: MarketWarning[]; + oracle: { + data: MorphoChainlinkOracleData; + }; }; warningsWithDetail: WarningWithDetail[]; }; @@ -243,7 +246,26 @@ export type GroupedPosition = { allWarnings: WarningWithDetail[]; }; -// Add this type to the existing types in the file +// Add these new types +export type OracleFeed = { + address: string; + chain: { + id: number; + }; + description: string | null; + id: string; + pair: string[] | null; + vendor: string | null; +}; + +export type MorphoChainlinkOracleData = { + baseFeedOne: OracleFeed | null; + baseFeedTwo: OracleFeed | null; + quoteFeedOne: OracleFeed | null; + quoteFeedTwo: OracleFeed | null; +}; + +// Update the Market type export type Market = { id: string; lltv: string; @@ -261,7 +283,6 @@ export type Market = { oracleInfo: { type: string; }; - oracleFeed?: OracleFeedsInfo; loanAsset: TokenInfo; collateralAsset: TokenInfo; state: { @@ -306,4 +327,7 @@ export type Market = { rewardPer1000USD?: string; warningsWithDetail: WarningWithDetail[]; isProtectedByLiquidationBots: boolean; + oracle: { + data: MorphoChainlinkOracleData; + }; }; diff --git a/src/utils/warnings.ts b/src/utils/warnings.ts index 6aca8fb6..1e645d1b 100644 --- a/src/utils/warnings.ts +++ b/src/utils/warnings.ts @@ -5,13 +5,13 @@ const morphoOfficialWarnings: WarningWithDetail[] = [ { code: 'hardcoded_oracle', level: 'warning', - description: 'This market uses a hardcoded oracle value', + description: 'This market uses a hardcoded oracle value (or missing one or more feed routes)', category: WarningCategory.oracle, }, { code: 'hardcoded_oracle_feed', level: 'warning', - description: 'This market is using a hardcoded value in its oracle. ', + description: 'This market is using a hardcoded value in one or more of its feed routes', category: WarningCategory.oracle, }, { @@ -92,7 +92,7 @@ const morphoOfficialWarnings: WarningWithDetail[] = [ }, ]; -export const getMarketWarningsWithDetail = (market: {warnings: MarketWarning[]}) => { +export const getMarketWarningsWithDetail = (market: { warnings: MarketWarning[] }) => { const result = []; // process official warnings