diff --git a/components/Button.tsx b/components/Button.tsx new file mode 100644 index 0000000..1f3ab95 --- /dev/null +++ b/components/Button.tsx @@ -0,0 +1,150 @@ +import React, { useCallback } from "react"; +import classNames from "classnames"; + +interface Props extends React.HTMLProps { + children?: string | React.ReactNode; + prepend?: React.ReactNode; + append?: React.ReactNode; + buttonSize?: + | "base" + | "xxs" + | "xs" + | "sm" + | "md" + | "lg" + | "xl" + | "2xl" + | "3xl" + | "4xl" + | "5xl" + | "6xl"; + palette?: + | "default" + | "highlight" + | "orange" + | "dimmed" + | "white" + | "red" + | "primary" + | "error" + | "blue" + | "success" + | "balance" + | "outline" + | "deposit"; + className?: string; + type?: "button" | "submit" | "reset"; + backgroundColor?: string; +} + +type PaletteState = { + backgroundColor?: string; + borderColor?: string; + color?: string; +}; + +type Palette = { + blur: PaletteState; + hover: PaletteState; + disabled: PaletteState; +}; + +type Palettes = { + [key: string]: Palette; +}; + +const Palettes: Palettes = { + default: { + blur: { + backgroundColor: "bg-gradient2", + borderColor: "border-none", + color: "text-origin-white", + }, + hover: { + backgroundColor: "hover:bg-dark", + borderColor: "hover:border-dark", + color: "hover:text-[#FCFCFC]", + }, + disabled: { + backgroundColor: "disabled:!bg-dark", + borderColor: "disabled:!border-dark", + color: "disabled:!text-[#FCFCFC]", + }, + }, +}; + +const Button: React.ForwardRefRenderFunction = ( + { + children, + prepend, + append, + className, + buttonSize = "base", + palette = "default", + backgroundColor, + type = "button", + href, + ...restOfProps + }, + ref +) => { + const getButtonSize = useCallback((buttonSize) => { + const classList = []; + switch (buttonSize) { + case "sm": + classList.push("h-[40px] px-6 text-sm"); + break; + case "lg": + classList.push("h-[64px] px-12 text-lg"); + break; + default: + classList.push("h-[56px] px-10 text-base font-light"); + } + return classList.join(" "); + }, []); + + const getClassName = useCallback((palette = "default") => { + const classList = []; + // Handle palette + switch (palette) { + default: + classList.push( + Palettes[palette]?.blur?.backgroundColor, + Palettes[palette]?.blur?.borderColor, + Palettes[palette]?.blur?.color, + Palettes[palette]?.hover?.backgroundColor, + Palettes[palette]?.hover?.borderColor, + Palettes[palette]?.hover?.color, + Palettes[palette]?.disabled?.backgroundColor, + Palettes[palette]?.disabled?.borderColor, + Palettes[palette]?.disabled?.color + ); + } + return classList.join(" "); + }, []); + + return ( + + ); +}; + +export default React.forwardRef(Button); diff --git a/components/ErrorBoundary.tsx b/components/ErrorBoundary.tsx new file mode 100644 index 0000000..079ef30 --- /dev/null +++ b/components/ErrorBoundary.tsx @@ -0,0 +1,43 @@ +import { ErrorBoundary as ReactErrorBoundary } from "react-error-boundary"; +import { Typography } from "@originprotocol/origin-storybook"; +import Button from "./Button"; + +const fallbackRender = ({ resetErrorBoundary }) => { + return ( +
+ + Ooops... + + + Sorry, an error occurred while loading the view. + +
+ +
+
+ ); +}; + +const logError = (error: Error, info: { componentStack: string }) => { + // Do something with the error, e.g. log to an external API + console.error(error); +}; + +const ErrorBoundary = ({ children }) => ( + + {children} + +); + +export default ErrorBoundary; diff --git a/components/GradientButton.tsx b/components/GradientButton.tsx index d59e98f..29f9a3a 100644 --- a/components/GradientButton.tsx +++ b/components/GradientButton.tsx @@ -1,7 +1,7 @@ import { PropsWithChildren } from "react"; import { twMerge } from "tailwind-merge"; -interface Gradient2ButtonProps { +interface GradientButtonProps { onClick?: () => void; className?: string; elementId?: string; @@ -14,7 +14,7 @@ const GradientButton = ({ elementId, outerDivClassName, children, -}: PropsWithChildren) => { +}: PropsWithChildren) => { return (
( +
+ {isLoading && ( +
+ +
+ )} + {children} +
+); + +export default LayoutBox; diff --git a/components/ProgressBar.tsx b/components/ProgressBar.tsx new file mode 100644 index 0000000..731775e --- /dev/null +++ b/components/ProgressBar.tsx @@ -0,0 +1,36 @@ +import { useEffect, useState } from "react"; +import cx from "classnames"; + +const ProgressBar = ({ + numerator, + color, + denominator = 100, + loadDelay = 300, + className = "", +}) => { + const [value, setValue] = useState({ numerator: 0, denominator }); + useEffect(() => { + setTimeout(() => { + setValue({ + numerator, + denominator, + }); + }, loadDelay); + }, []); + return ( +
+
+
+ ); +}; + +export default ProgressBar; diff --git a/components/RealTimeStats.tsx b/components/RealTimeStats.tsx new file mode 100644 index 0000000..0b2c75a --- /dev/null +++ b/components/RealTimeStats.tsx @@ -0,0 +1,35 @@ +import { useFeeData } from "wagmi"; +import { ethers } from "ethers"; +import Image from "next/image"; +import { Typography } from "@originprotocol/origin-storybook"; + +const RealTimeStats = () => { + const { data, isError, isLoading } = useFeeData(); + + const gwei = + !isLoading && !isError + ? parseFloat( + ethers.utils.formatUnits(data?.formatted?.gasPrice || 0, "gwei") + )?.toFixed(2) + : 0; + + return ( +
+
+
+ Gas station icon + + {gwei} + +
+
+
+ ); +}; + +export default RealTimeStats; diff --git a/components/analytics/APYChart.tsx b/components/analytics/APYChart.tsx new file mode 100644 index 0000000..efcd645 --- /dev/null +++ b/components/analytics/APYChart.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { last } from "lodash"; +import { + CategoryScale, + Chart as ChartJS, + LinearScale, + LineElement, + PointElement, + TimeScale, + Title, + Tooltip, + Legend, +} from "chart.js"; +import { Line } from "react-chartjs-2"; +import { useAPYChart } from "../../hooks/analytics/useAPYChart"; +import { formatCurrency } from "../../utils"; +import { chartOptions } from "../../utils/analytics"; +import LayoutBox from "../LayoutBox"; +import DefaultChartHeader from "./DefaultChartHeader"; +import DurationFilter from "./DurationFilter"; +import MovingAverageFilter from "./MovingAverageFilter"; + +ChartJS.register( + CategoryScale, + TimeScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend +); + +const APYChart = () => { + const [{ data, filter, isFetching }, { onChangeFilter }] = useAPYChart(); + return data ? ( + +
+ +
+ { + onChangeFilter({ + duration: duration || "all", + }); + }} + /> +
+ { + onChangeFilter({ + typeOf: typeOf, + }); + }} + /> +
+
+
+
+ +
+
+ ) : null; +}; + +export default APYChart; diff --git a/components/analytics/CollateralPieChart.tsx b/components/analytics/CollateralPieChart.tsx new file mode 100644 index 0000000..324351d --- /dev/null +++ b/components/analytics/CollateralPieChart.tsx @@ -0,0 +1,102 @@ +import React, { useState } from "react"; +import { Typography } from "@originprotocol/origin-storybook"; +import { PieChart } from "react-minimal-pie-chart"; +import { Tooltip } from "react-tooltip"; +import { formatCurrency, formatPercentage } from "../../utils"; +import { useCollateralChart } from "../../hooks/analytics/useCollateralChart"; +import ErrorBoundary from "../ErrorBoundary"; +import LayoutBox from "../LayoutBox"; + +const makeTooltipContent = ({ tooltip, value }) => { + return ( +
+ {tooltip} + {formatCurrency(value, 2)} +
+ ); +}; + +const CollateralPieChart = () => { + const [hovered, setHovered] = useState(null); + const [{ data, totalSum, collateral, isFetching }] = useCollateralChart(); + return ( + + +
+
+ + Current Collateral + +
+
+
+ {/* @ts-ignore */} +
+ { + setHovered(index); + }} + onMouseOut={() => { + setHovered(null); + }} + /> + { + return typeof hovered === "number" + ? makeTooltipContent(data?.[hovered]) + : null; + }} + float + /> +
+
+ {collateral.map(({ label, color, total }) => ( +
+
+
+
+ {label} + + {formatPercentage(total / totalSum)} + + + {formatCurrency(total, 2)} + +
+
+
+ ))} +
+
+ + + ); +}; + +export default CollateralPieChart; diff --git a/components/analytics/DefaultChartHeader.tsx b/components/analytics/DefaultChartHeader.tsx new file mode 100644 index 0000000..7b67b9c --- /dev/null +++ b/components/analytics/DefaultChartHeader.tsx @@ -0,0 +1,17 @@ +import { Typography } from "@originprotocol/origin-storybook"; + +const DefaultChartHeader = ({ title, display, date }) => { + return ( +
+ + {title} + + {display} + + {date} + +
+ ); +}; + +export default DefaultChartHeader; diff --git a/components/analytics/DurationFilter.tsx b/components/analytics/DurationFilter.tsx new file mode 100644 index 0000000..a3d9e9e --- /dev/null +++ b/components/analytics/DurationFilter.tsx @@ -0,0 +1,33 @@ +import cx from "classnames"; +import { durationOptions } from "../../utils/analytics"; + +const DurationFilter = ({ value, onChange }) => ( +
+ {durationOptions.map((duration) => { + const isActiveDuration = value === duration.value; + return ( + + ); + })} +
+); + +export default DurationFilter; diff --git a/components/analytics/MovingAverageFilter.tsx b/components/analytics/MovingAverageFilter.tsx new file mode 100644 index 0000000..6ecf30a --- /dev/null +++ b/components/analytics/MovingAverageFilter.tsx @@ -0,0 +1,23 @@ +import { typeOptions } from "../../utils/analytics"; + +const MovingAverageFilter = ({ value, onChange }) => { + return ( +
+ +
+ ); +}; + +export default MovingAverageFilter; diff --git a/components/analytics/ProtocolChart.tsx b/components/analytics/ProtocolChart.tsx new file mode 100644 index 0000000..c41410c --- /dev/null +++ b/components/analytics/ProtocolChart.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import { last } from "lodash"; +import { + BarElement, + CategoryScale, + Chart as ChartJS, + LinearScale, + LineElement, + TimeScale, + Title, + Tooltip, + Filler, + Legend, +} from "chart.js"; +import { Bar } from "react-chartjs-2"; +import { Typography } from "@originprotocol/origin-storybook"; +import { formatCurrency } from "../../utils"; +import { useProtocolRevenueChart } from "../../hooks/analytics/useProtocolRevenueChart"; +import LayoutBox from "../LayoutBox"; +import DurationFilter from "./DurationFilter"; +import MovingAverageFilter from "./MovingAverageFilter"; + +ChartJS.register( + CategoryScale, + TimeScale, + LinearScale, + LineElement, + BarElement, + Title, + Tooltip, + Filler, + Legend +); + +const ProtocolChart = () => { + const [{ data, filter, chartOptions, isFetching }, { onChangeFilter }] = + useProtocolRevenueChart(); + return data ? ( + +
+
+ + Daily Protocol Revenue + +
+ {`Ξ ${formatCurrency( + Number(last(data?.datasets?.[1]?.data)) + + Number(last(data?.datasets?.[2]?.data)), + 4 + )}`} +
+ {data?.datasets?.map((dataset, index) => ( +
+
+
+ + {dataset.label} + + + {`Ξ ${formatCurrency(Number(last(dataset?.data)), 2)}`} + +
+
+ ))} +
+ + {last(data?.labels)} + +
+
+
+ { + onChangeFilter({ + duration: duration || "all", + }); + }} + /> +
+ { + onChangeFilter({ + typeOf: typeOf, + }); + }} + /> +
+
+
+
+ {/* @ts-ignore */} + +
+ + ) : null; +}; + +export default ProtocolChart; diff --git a/components/analytics/TotalSupplyChart.tsx b/components/analytics/TotalSupplyChart.tsx new file mode 100644 index 0000000..4c334c7 --- /dev/null +++ b/components/analytics/TotalSupplyChart.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import { + CategoryScale, + Chart as ChartJS, + LinearScale, + LineElement, + TimeScale, + Title, + Tooltip, + Filler, + Legend, +} from "chart.js"; +import { Line } from "react-chartjs-2"; +import { last } from "lodash"; +import { Typography } from "@originprotocol/origin-storybook"; +import { useTotalSupplyChart } from "../../hooks/analytics/useTotalSupplyChart"; +import { chartOptions } from "../../utils/analytics"; +import { formatCurrency } from "../../utils"; +import ErrorBoundary from "../ErrorBoundary"; +import LayoutBox from "../LayoutBox"; +import DurationFilter from "./DurationFilter"; + +ChartJS.register( + CategoryScale, + TimeScale, + LinearScale, + LineElement, + Title, + Tooltip, + Filler, + Legend +); + +const TotalSupplyChart = () => { + const [{ data, filter, isFetching }, { onChangeFilter }] = + useTotalSupplyChart(); + return ( + + +
+
+ + Total Supply + + {`${formatCurrency( + last(data?.datasets?.[0]?.data) || 0, + 2 + )}`} +
+
+
+ + OETH + +
+
+ + {last(data?.labels)} + +
+
+ { + onChangeFilter({ + duration: duration || "all", + }); + }} + /> +
+
+
+ +
+ + + ); +}; + +export default TotalSupplyChart; diff --git a/components/analytics/index.tsx b/components/analytics/index.tsx new file mode 100644 index 0000000..3cab9f0 --- /dev/null +++ b/components/analytics/index.tsx @@ -0,0 +1,7 @@ +export { default as DefaultChartHeader } from "./DefaultChartHeader"; +export { default as DurationFilter } from "./DurationFilter"; +export { default as MovingAverageFilter } from "./MovingAverageFilter"; +export { default as APYChart } from "./APYChart"; +export { default as ProtocolChart } from "./ProtocolChart"; +export { default as TotalSupplyChart } from "./TotalSupplyChart"; +export { default as CollateralPieChart } from "./CollateralPieChart"; diff --git a/components/index.ts b/components/index.ts index de54ba1..1fa3b29 100644 --- a/components/index.ts +++ b/components/index.ts @@ -1,3 +1,7 @@ +export { default as Image } from "next/image"; +export { default as Link } from "next/link"; +export { Tooltip as ReactTooltip } from "react-tooltip"; + export { default as Footer } from "./Footer"; export { default as Section } from "./Section"; export { default as GradientButton } from "./GradientButton"; @@ -5,6 +9,13 @@ export { default as Article } from "./Article"; export { default as News } from "./News"; export { default as Header } from "./Header"; +export { default as Button } from "./Button"; +export { default as LayoutBox } from "./LayoutBox"; +export { default as TwoColumnLayout } from "./layouts/TwoColumnLayout"; +export { default as ProgressBar } from "./ProgressBar"; +export { default as RealTimeStats } from "./RealTimeStats"; +export { default as ErrorBoundary } from "./ErrorBoundary"; + export * from "./homepage"; export * from "./strapi"; export * from "./proof-of-yield"; diff --git a/components/layouts/TwoColumnLayout.tsx b/components/layouts/TwoColumnLayout.tsx new file mode 100644 index 0000000..46ee60e --- /dev/null +++ b/components/layouts/TwoColumnLayout.tsx @@ -0,0 +1,350 @@ +import { useMemo, useEffect, useState, useContext } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import dynamic from "next/dynamic"; +import cx from "classnames"; +import { capitalize } from "lodash"; +import { Typography } from "@originprotocol/origin-storybook"; +import { AnimatePresence, motion, useCycle } from "framer-motion"; +import { useSwipeable } from "react-swipeable"; +import Button from "../Button"; +import transformLinks from "../../utils/transformLinks"; +import { useRouter } from "next/router"; +import { NavigationContext } from "../../pages/_app"; + +const RealTimeStats = dynamic(() => import("../RealTimeStats"), { + ssr: false, +}); + +const partsToHref = (parts, index) => `/${parts.slice(0, index).join("/")}`; + +const partToLabel = (part) => capitalize(part?.replace(/-/g, " ")); + +const noop = () => {}; + +const NavigationSidebar = ({ + pathname, + linkHeight = 52, + navLinks, + onClickLink, +}) => ( +
+ +
+); + +const Breadcrumbs = ({ pathname }) => { + const parts: string[] = useMemo(() => { + if (pathname === "/analytics") { + return ["analytics", "overview"]; + } + return pathname?.replace("/", "").split("/"); + }, [pathname]); + + return ( +
    + {parts.map((part, index) => { + const isLast = index === parts?.length - 1; + const href = partsToHref(parts, index - 1); + const label = partToLabel(part); + return isLast ? ( + + / + {label} + + ) : ( + + {label} + + ); + })} +
+ ); +}; + +const MainNavigation = ({ links, onClickLink = noop }) => { + return ( + + ); +}; + +const NavigationDivider = () => ( +
+); + +const AnalyticsNavigation = ({ + links, + currentPathname, + onClickLink = noop, +}) => ( + +); + +const MobileNavigation = ({ links, subLinks, currentPathname }) => { + const [isMenuOpen, cycleOpen] = useCycle(false, true); + + useEffect(() => { + if (isMenuOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = "auto"; + } + }, [isMenuOpen]); + + const handlers = useSwipeable({ + onSwipedLeft: () => cycleOpen(), + }); + + return ( + <> +
+ {isMenuOpen ? ( +
+ ) : ( + + Origin ether logo + + )} + +
+ + {isMenuOpen && ( + <> +
cycleOpen()} + /> + + +
+ + { + cycleOpen(); + }} + /> +
+ +
+ { + cycleOpen(); + }} + /> +
+
+
+ + )} + + + ); +}; + +const DesktopSidebarNavigation = ({ + links, + subLinks, + sidebarWidth, + currentPathname, +}) => { + return ( +
+
+ + Origin ether logo + + + + +
+
+ ); +}; + +const TwoColumnLayout = ({ sidebarWidth = 316, children }) => { + const { links } = useContext(NavigationContext); + const { pathname } = useRouter(); + + const analyticsLinks = [ + { + label: "Overview", + href: "/analytics", + enabled: true, + }, + { + label: "Collateral", + href: "/analytics/collateral", + enabled: true, + }, + { + label: "Supply", + href: "/analytics/supply", + enabled: false, + }, + { + label: "Holders", + href: "/analytics/holders", + enabled: false, + }, + { + label: "Protocol revenue", + href: "/analytics/protocol-revenue", + enabled: true, + }, + { + label: "Strategies", + href: "/analytics/strategies", + enabled: true, + }, + { + label: "Dripper", + href: "/analytics/dripper", + enabled: false, + }, + { + label: "Health monitoring", + href: "/analytics/health-monitoring", + enabled: false, + }, + ].filter((link) => link.enabled); + + return ( + <> +
+ + +
+
+ +
+ +
+
+ {children} +
+
+ + ); +}; + +export default TwoColumnLayout; diff --git a/constants/protocolMapping.ts b/constants/protocolMapping.ts index 22eb3cf..7b5d064 100644 --- a/constants/protocolMapping.ts +++ b/constants/protocolMapping.ts @@ -1,32 +1,38 @@ const protocolMapping = { Convex: { + color: "#ff5a5a", image: "/images/convex-strategy.svg", description: "Convex allows liquidity providers and stakers to earn greater rewards from Curve, a stablecoin-centric automated market maker (AMM). OETH earns trading fees and protocol token incentives (both CRV and CVX). This strategy serves as an algorithmic market operations controller (AMO), which enables OETH to safely leverage its own deposits to multiply returns and maintain the pool's balance.", }, Lido: { + color: "#66c8ff", image: "/images/lido-strategy.svg", description: "Lido is a liquid staking solution for Ethereum that staking ETH without locking tokens or maintaining infrastructure. OETH holds stETH to earn staking rewards for participating in the Ethereum network and adds a layer of auto-compounding.", }, RocketPool: { + color: "#e4ae89", image: "/images/rocketpool-strategy.svg", description: "Rocket Pool is a community-owned, decentralized protocol that mints rETH, an interest-earning ETH wrapper. OETH holds rETH to earn yield and normalizes the accounting by distributing additional tokens directly to users' wallets.", }, Frax: { + color: "#e85bff", image: "/images/frax-strategy.svg", description: "Frax uses a two-token model to maximize yields earned from staking validators. OETH deposits frxETH to the Frax Ether staking contract and amplifies this yield further.", }, Vault: { + color: "#8c66fc", description: "When OETH is minted, collateral is deposited into the Origin Vault and held until the allocate function is called. This happens automatically for larger transactions, which are less impacted by increased gas costs.", }, Morpho: { + color: "#9bc3e9", image: "/images/morpho-strategy.svg", description: - "Morpho adds a peer-to-peer layer on top of Compound and Aave allowing lenders and borrowers to be matched more efficiently with better interest rates. When no matching opportunity exists, funds flow directly through to the underlying protocol. OETH supplies WETH to Morpho’s Aave V2 market to earn interest. Additional value is generated from protocol MORPHO token emissions (currently locked).", + "Morpho adds a peer-to-peer layer on top of Compound and Aave allowing lenders and borrowers to be matched more efficiently with better interest rates. When no matching opportunity exists, funds flow directly through to the underlying protocol. OETH supplies WETH to Morpho’s Aave V2 market to earn interest. Additional value is generated from protocol MORPHO token emissions (currently locked).", }, }; diff --git a/hooks/analytics/useAPYChart.ts b/hooks/analytics/useAPYChart.ts new file mode 100644 index 0000000..393e2b9 --- /dev/null +++ b/hooks/analytics/useAPYChart.ts @@ -0,0 +1,63 @@ +import { useQuery } from "react-query"; +import { useMemo, useState } from "react"; +import { + borderFormatting, + filterByDuration, + formatDisplay, +} from "../../utils/analytics"; + +export const useAPYChart = () => { + const { data, isFetching } = useQuery(`/api/analytics/charts/apy`, { + initialData: { + labels: [], + datasets: [], + error: null, + }, + refetchOnWindowFocus: false, + keepPreviousData: true, + }); + + const [chartState, setChartState] = useState({ + duration: "all", + typeOf: "_30_day", + }); + + const chartData = useMemo(() => { + if (data?.error) { + return null; + } + return formatDisplay( + filterByDuration( + { + labels: data?.labels, + datasets: data?.datasets?.reduce((acc, dataset) => { + if (!chartState?.typeOf || dataset.id === chartState?.typeOf) { + acc.push({ + ...dataset, + ...borderFormatting, + }); + } + return acc; + }, []), + }, + chartState?.duration + ) + ); + }, [JSON.stringify(data), chartState?.duration, chartState?.typeOf]); + + const onChangeFilter = (value) => { + setChartState((prev) => ({ + ...prev, + ...value, + })); + }; + + return [ + { + data: chartData, + filter: chartState, + isFetching, + }, + { onChangeFilter }, + ]; +}; diff --git a/hooks/analytics/useCollateralChart.ts b/hooks/analytics/useCollateralChart.ts new file mode 100644 index 0000000..e6a3bee --- /dev/null +++ b/hooks/analytics/useCollateralChart.ts @@ -0,0 +1,63 @@ +import { useQuery } from "react-query"; +import { useMemo } from "react"; + +export const useCollateralChart = () => { + const { data, isFetching } = useQuery(`/api/analytics/charts/collateral`, { + initialData: { + collateral: [], + tvl: 0, + tvlUsd: 0, + error: null, + }, + refetchOnWindowFocus: false, + keepPreviousData: true, + }); + + const { collateral, tvl, tvlUsd } = data; + + const chartData = useMemo(() => { + return { + labels: collateral.map((item) => item.label), + datasets: [ + { + label: "Current Collateral", + data: collateral.map((item) => item.total), + backgroundColor: collateral.map((item) => item.color), + borderWidth: 0, + hoverOffset: 50, + }, + ], + }; + }, [JSON.stringify(collateral)]); + + const totalSum = useMemo(() => { + return collateral.reduce((acc, item) => { + acc += Number(item.total || 0); + return acc; + }, 0); + }, [JSON.stringify(collateral)]); + + const piechartData = useMemo(() => { + return chartData?.datasets?.[0]?.data.map((value, index) => { + const token = chartData?.labels?.[index]; + const color = chartData?.datasets?.[0]?.backgroundColor[index]; + return { + title: token, + value, + color, + tooltip: token, + }; + }); + }, [JSON.stringify(chartData)]); + + return [ + { + data: piechartData, + collateral, + tvl, + tvlUsd, + totalSum: totalSum, + isFetching, + }, + ]; +}; diff --git a/hooks/analytics/useProtocolRevenueChart.ts b/hooks/analytics/useProtocolRevenueChart.ts new file mode 100644 index 0000000..1ec16a9 --- /dev/null +++ b/hooks/analytics/useProtocolRevenueChart.ts @@ -0,0 +1,154 @@ +import { useQuery } from "react-query"; +import { useMemo, useState } from "react"; +import { isMobile } from "react-device-detect"; +import { + barFormatting, + filterByDuration, + formatDisplay, +} from "../../utils/analytics"; + +export const useProtocolRevenueChart = () => { + const { data, isFetching } = useQuery( + `/api/analytics/charts/protocolRevenue`, + { + initialData: { + labels: [], + datasets: [], + error: null, + }, + refetchOnWindowFocus: false, + keepPreviousData: true, + } + ); + + const [chartState, setChartState] = useState({ + duration: "all", + typeOf: "_7_day", + }); + + const barsToShow = ["revenue_daily", "yield_daily"]; + + const baseData = useMemo(() => { + if (data?.error) { + return null; + } + return { + labels: data?.labels, + datasets: data?.datasets?.reduce((acc, dataset) => { + if ( + barsToShow.includes(dataset.id) || + !chartState?.typeOf || + dataset.id === chartState?.typeOf + ) { + acc.push({ + ...barFormatting, + ...dataset, + ...(dataset.type === "line" + ? { + type: "line", + borderColor: "#ffffff", + borderWidth: 2, + tension: 0, + borderJoinStyle: "round", + pointRadius: 0, + pointHitRadius: 1, + } + : {}), + }); + } + return acc; + }, []), + }; + }, [JSON.stringify(data), JSON.stringify(chartState?.typeOf)]); + + const chartData = useMemo(() => { + return baseData + ? formatDisplay(filterByDuration(baseData, chartState?.duration)) + : null; + }, [JSON.stringify(baseData), chartState?.duration, chartState?.typeOf]); + + const onChangeFilter = (value) => { + setChartState((prev) => ({ + ...prev, + ...value, + })); + }; + + return [ + { + data: chartData, + // @ts-ignore + aggregations: data?.aggregations || {}, + filter: chartState, + isFetching, + chartOptions: { + responsive: true, + aspectRatio: isMobile ? 1 : 3, + plugins: { + title: { + display: false, + }, + legend: { + display: false, + position: "bottom", + }, + tooltip: { + enabled: true, + }, + }, + interaction: { + mode: "nearest", + intersect: false, + axis: "x", + }, + scales: { + x: { + stacked: true, + border: { + color: "#4d505e", + width: 0.5, + }, + grid: { + display: false, + }, + ticks: { + color: "#828699", + autoSkip: false, + maxRotation: 90, + minRotation: 0, + padding: 20, + callback: function (val, index) { + return ( + isMobile ? (index + 22) % 6 === 0 : (index + 8) % 3 === 0 + ) + ? this.getLabelForValue(val) + : null; + }, + }, + }, + y: { + stacked: true, + border: { + display: false, + dash: [2, 4], + dashOffset: 1, + }, + grid: { + color: "#4d505e", + lineWidth: 0.5, + }, + beginAtZero: true, + position: "right", + ticks: { + color: "#828699", + callback: function (val) { + return val === 0 ? null : this.getLabelForValue(val); + }, + }, + }, + }, + }, + }, + { onChangeFilter }, + ]; +}; diff --git a/hooks/analytics/useTotalSupplyChart.ts b/hooks/analytics/useTotalSupplyChart.ts new file mode 100644 index 0000000..89891ad --- /dev/null +++ b/hooks/analytics/useTotalSupplyChart.ts @@ -0,0 +1,63 @@ +import { useQuery } from "react-query"; +import { useMemo, useState } from "react"; +import { + borderFormatting, + createGradient, + filterByDuration, + formatDisplay, +} from "../../utils/analytics"; + +export const useTotalSupplyChart = () => { + const { data, isFetching } = useQuery("/api/analytics/charts/totalSupply", { + initialData: { + labels: [], + datasets: [], + }, + refetchOnWindowFocus: false, + keepPreviousData: true, + }); + + const [chartState, setChartState] = useState({ + duration: "all", + typeOf: "total", + }); + + const chartData = useMemo(() => { + return formatDisplay( + filterByDuration( + { + labels: data?.labels, + datasets: data?.datasets?.reduce((acc, dataset) => { + if (!chartState?.typeOf || dataset.id === chartState?.typeOf) { + acc.push({ + ...dataset, + ...borderFormatting, + borderWidth: 0, + backgroundColor: createGradient(["#8C66FC", "#0274F1"]), + fill: true, + }); + } + return acc; + }, []), + }, + chartState.duration + ) + ); + }, [JSON.stringify(data), chartState?.duration, chartState?.typeOf]); + + const onChangeFilter = (value) => { + setChartState((prev) => ({ + ...prev, + ...value, + })); + }; + + return [ + { + data: chartData, + filter: chartState, + isFetching, + }, + { onChangeFilter }, + ]; +}; diff --git a/lib/dune/error.ts b/lib/dune/error.ts new file mode 100644 index 0000000..948efb6 --- /dev/null +++ b/lib/dune/error.ts @@ -0,0 +1,6 @@ +export class DuneError extends Error { + constructor(msg: string) { + super(msg); + Object.setPrototypeOf(this, DuneError.prototype); + } +} diff --git a/lib/dune/index.ts b/lib/dune/index.ts new file mode 100644 index 0000000..751850b --- /dev/null +++ b/lib/dune/index.ts @@ -0,0 +1,240 @@ +import Redis from "ioredis"; +import { DuneError } from "./error"; +import { QueryParameter } from "./queryParameter"; +import { + ExecutionState, + ExecutionResponse, + GetStatusResponse, + ResultsResponse, +} from "./types"; +export { toChartData } from "./utils"; + +console.debug = function () {}; + +export const jobsLookup = { + apy: { + queryId: 2611997, + expiresAfter: 86400, + }, + totalSupplyOETH: { + queryId: 2520878, + expiresAfter: 86400, + }, + protocolRevenue: { + queryId: 2612143, + expiresAfter: 86400, + }, + totalSupplyBreakdown: { + queryId: 2520879, + expiresAfter: 86400, + }, +}; + +const BASE_URL = "https://api.dune.com/api/v1"; + +const TERMINAL_STATES = [ + ExecutionState.CANCELLED, + ExecutionState.COMPLETED, + ExecutionState.FAILED, +]; + +const logPrefix = "dune-client:"; + +const sleep = (seconds: number) => + new Promise((resolve) => setTimeout(resolve, seconds * 1000)); + +const CACHE_READY_STATE = "ready"; + +class DuneClient { + apiKey: string; + + cacheClient; + + constructor(apiKey: string) { + this.apiKey = apiKey; + if (process.env.REDIS_URL) { + this.cacheClient = new Redis(process.env.REDIS_URL, { + tls: { + rejectUnauthorized: false, + }, + connectTimeout: 10000, + lazyConnect: true, + retryStrategy(times) { + if (times > 5) return null; + return Math.min(times * 500, 2000); + }, + }); + + this.cacheClient.on("ready", function () { + console.log("Cache connected for DUNE API"); + }); + } + } + + private async _checkCache(key) { + try { + return JSON.parse(await this.cacheClient.get(key)); + } catch (error) { + console.error( + logPrefix, + `caught unhandled response error ${JSON.stringify(error)}` + ); + return null; + } + } + + private async _handleResponse( + responsePromise: Promise + ): Promise { + const apiResponse = await responsePromise + .then((response) => { + if (!response.ok) { + console.error( + logPrefix, + `response error ${response.status} - ${response.statusText}` + ); + } + return response.json(); + }) + .catch((error) => { + console.error( + logPrefix, + `caught unhandled response error ${JSON.stringify(error)}` + ); + throw error; + }); + + if (apiResponse.error) { + console.error( + logPrefix, + `error contained in response ${JSON.stringify(apiResponse)}` + ); + if (apiResponse.error instanceof Object) { + throw new DuneError(apiResponse.error.type); + } else { + throw new DuneError(apiResponse.error); + } + } + + return apiResponse; + } + + private async _get(url: string): Promise { + console.debug(logPrefix, `GET received input url=${url}`); + const response = fetch(url, { + method: "GET", + headers: { + "x-dune-api-key": this.apiKey, + }, + }); + return this._handleResponse(response); + } + + private async _post(url: string, params?: QueryParameter[]): Promise { + console.debug( + logPrefix, + `POST received input url=${url}, params=${JSON.stringify(params)}` + ); + // Transform Query Parameter list into "dict" + const reducedParams = params?.reduce>( + (acc, { name, value }) => ({ ...acc, [name]: value }), + {} + ); + + const response = fetch(url, { + method: "POST", + body: JSON.stringify({ query_parameters: reducedParams || {} }), + headers: { + "x-dune-api-key": this.apiKey, + }, + }); + return this._handleResponse(response); + } + + async execute( + queryID: number, + parameters?: QueryParameter[] + ): Promise { + const response = await this._post( + `${BASE_URL}/query/${queryID}/execute`, + parameters + ); + console.debug(logPrefix, `execute response ${JSON.stringify(response)}`); + return response as ExecutionResponse; + } + + async getStatus(jobID: string): Promise { + const response: GetStatusResponse = await this._get( + `${BASE_URL}/execution/${jobID}/status` + ); + console.debug(logPrefix, `get_status response ${JSON.stringify(response)}`); + return response as GetStatusResponse; + } + + async getResult(jobID: string): Promise { + const key = `${BASE_URL}/execution/${jobID}/results`; + const response: ResultsResponse = await this._get(key); + console.debug(logPrefix, `get_result response ${JSON.stringify(response)}`); + return response as ResultsResponse; + } + + async cancelExecution(jobID: string): Promise { + const { success }: { success: boolean } = await this._post( + `${BASE_URL}/execution/${jobID}/cancel` + ); + return success; + } + + async refresh( + queryID: number, + parameters?: QueryParameter[], + pingFrequency: number = 5, + cacheExpiration: number = 21600 + ): Promise { + console.info( + logPrefix, + `refreshing query https://dune.com/queries/${queryID} with parameters ${JSON.stringify( + parameters + )}` + ); + const data = await this._checkCache(String(queryID)); + if (data) { + return data as ResultsResponse; + } else { + const { execution_id: jobID } = await this.execute(queryID, parameters); + let { state } = await this.getStatus(jobID); + while (!TERMINAL_STATES.includes(state)) { + console.info( + logPrefix, + `waiting for query execution ${jobID} to complete: current state ${state}` + ); + await sleep(pingFrequency); + state = (await this.getStatus(jobID)).state; + } + if (state === ExecutionState.COMPLETED) { + const result = await this.getResult(jobID); + // Store in cache by jobID and `cacheExpiration` + if (this.cacheClient.status === CACHE_READY_STATE) { + const cachedResultSet = JSON.stringify(result); + await this.cacheClient.set( + String(queryID), + cachedResultSet, + "EX", + cacheExpiration + ); + console.log( + logPrefix, + `get_result cached response for ${queryID}: ${cacheExpiration} seconds` + ); + } + return result; + } else { + const message = `refresh (execution ${jobID}) yields incomplete terminal state ${state}`; + console.error(logPrefix, message); + throw new DuneError(message); + } + } + } +} + +export default DuneClient; diff --git a/lib/dune/queryParameter.ts b/lib/dune/queryParameter.ts new file mode 100644 index 0000000..129c262 --- /dev/null +++ b/lib/dune/queryParameter.ts @@ -0,0 +1,34 @@ +enum ParameterType { + TEXT = "text", + NUMBER = "number", + DATE = "date", + ENUM = "enum", +} + +export class QueryParameter { + type: ParameterType; + value: string; + name: string; + + constructor(type: ParameterType, name: string, value: any) { + this.type = type; + this.value = value.toString(); + this.name = name; + } + + static text(name: string, value: string): QueryParameter { + return new QueryParameter(ParameterType.TEXT, name, value); + } + + static number(name: string, value: string | number): QueryParameter { + return new QueryParameter(ParameterType.NUMBER, name, value.toString()); + } + + static date(name: string, value: string | Date): QueryParameter { + return new QueryParameter(ParameterType.DATE, name, value.toString()); + } + + static enum(name: string, value: string): QueryParameter { + return new QueryParameter(ParameterType.ENUM, name, value.toString()); + } +} diff --git a/lib/dune/types.ts b/lib/dune/types.ts new file mode 100644 index 0000000..bc4d6e5 --- /dev/null +++ b/lib/dune/types.ts @@ -0,0 +1,62 @@ +export enum ExecutionState { + COMPLETED = "QUERY_STATE_COMPLETED", + EXECUTING = "QUERY_STATE_EXECUTING", + PENDING = "QUERY_STATE_PENDING", + CANCELLED = "QUERY_STATE_CANCELLED", + FAILED = "QUERY_STATE_FAILED", +} + +export interface ExecutionResponse { + execution_id: string; + state: ExecutionState; +} + +export interface TimeData { + submitted_at: Date; + execution_started_at?: Date; + execution_ended_at?: Date; + expires_at?: Date; + cancelled_at?: Date; +} + +export interface ResultMetadata { + column_names: string[]; + result_set_bytes: number; + total_row_count: number; + datapoint_count: number; + pending_time_millis: number; + execution_time_millis: number; +} + +export interface BaseStatusResponse extends TimeData { + execution_id: string; + query_id: number; +} + +export interface IncompleteStatusResponse extends BaseStatusResponse { + state: Exclude; + queue_position?: number; +} + +export interface CompleteStatusResponse extends BaseStatusResponse { + state: ExecutionState.COMPLETED; + queue_position?: number; + result_metadata: ResultMetadata; +} + +export type GetStatusResponse = + | IncompleteStatusResponse + | CompleteStatusResponse; + +export interface ExecutionResult { + rows: Record[]; + metadata: ResultMetadata; +} + +export interface ResultsResponse extends TimeData { + execution_id: string; + query_id: number; + state: ExecutionState; + // only present when state is COMPLETE + result?: ExecutionResult; +} diff --git a/lib/dune/utils.ts b/lib/dune/utils.ts new file mode 100644 index 0000000..758001d --- /dev/null +++ b/lib/dune/utils.ts @@ -0,0 +1,24 @@ +import { reduce } from "lodash"; + +export const toChartData = (rows, keys) => { + const initialState = reduce( + keys, + (acc, toKey) => { + acc[toKey] = []; + return acc; + }, + {} + ); + return rows?.reduce((acc, row) => { + Object.keys(keys).forEach((fromKey) => { + const toKey = keys[fromKey]; + if (typeof row[fromKey] !== "undefined") { + acc[toKey]?.push(row[fromKey]); + } + }); + return acc; + }, initialState); +}; + +export const formatLabels = (labels) => + labels.map((d) => new Date(d).toString().slice(4, 10)); diff --git a/package.json b/package.json index 5934711..c9520f0 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,14 @@ "@originprotocol/origin-storybook": "^0.27.13", "@sentry/nextjs": "^7.51.2", "chart.js": "^4.2.1", + "classnames": "^2.3.2", + "date-fns": "^2.29.3", "dompurify": "^3.0.2", + "dotenv": "^16.0.3", "ethers": "^5.7.2", + "framer-motion": "^10.6.0", "he": "^1.2.0", + "ioredis": "^5.3.1", "lodash": "^4.17.21", "moment": "^2.29.4", "next": "latest", @@ -25,13 +30,18 @@ "qs": "^6.11.1", "react": "^18.2.0", "react-chartjs-2": "^5.2.0", + "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", + "react-error-boundary": "^4.0.3", + "react-loader-spinner": "^5.3.4", "react-minimal-pie-chart": "^8.4.0", "react-moment": "^1.1.3", "react-query": "^3.39.3", + "react-swipeable": "^7.0.0", + "react-tooltip": "^5.13.1", "sanitize-html": "^2.10.0", "tailwind-merge": "^1.12.0", - "tailwindcss": "^3.3.1" + "wagmi": "^0.12.7" }, "devDependencies": { "@typechain/ethers-v5": "^10.2.0", @@ -44,6 +54,7 @@ "@types/react-dom": "^17.0.1", "@types/sanitize-html": "^2.9.0", "postcss-preset-env": "^8.3.0", + "tailwindcss": "^3.3.1", "typechain": "^8.1.1", "typechain-target-ethers-v5": "^5.0.1", "typescript": "^4.8.3" diff --git a/pages/_app.tsx b/pages/_app.tsx index bd6a935..727dfc7 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,14 +1,16 @@ import "@originprotocol/origin-storybook/lib/styles.css"; -import "../styles/globals.css"; -import React, { createContext, useEffect } from "react"; +import React, { createContext, useEffect, useState } from "react"; import { useRouter } from "next/router"; import { AppProps } from "next/app"; import Script from "next/script"; import { QueryClient, QueryClientProvider } from "react-query"; import Head from "next/head"; +import { WagmiConfig, createClient, configureChains, mainnet } from "wagmi"; +import { publicProvider } from "wagmi/providers/public"; import { assetRootPath } from "../utils"; import { GTM_ID, pageview } from "../utils/gtm"; import { useContracts, usePreviousRoute } from "../hooks"; +import "../styles/globals.css"; const defaultQueryFn = async ({ queryKey }) => { return await fetch(queryKey).then((res) => res.json()); @@ -27,9 +29,59 @@ export const GlobalContext = createContext({ siteName: "", }); +export const NavigationContext = createContext({ + links: [], +}); + +const { provider, webSocketProvider } = configureChains( + [mainnet], + [publicProvider()] +); + +const wagmiClient = createClient({ + autoConnect: false, + provider, + webSocketProvider, +}); + +const useNavigationLinks = () => { + const [links, setLinks] = useState([]); + + useEffect(() => { + (async function () { + try { + const { data } = await fetch("/api/navigation", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + params: { + populate: { + links: { + populate: "*", + }, + }, + }, + }), + }).then((res) => res.json()); + setLinks(data); + } catch (e) { + console.error(e); + } + })(); + }, []); + return [{ links }]; +}; + function MyApp({ Component, pageProps }: AppProps) { const router = useRouter(); + const [{ links }] = useNavigationLinks(); + + // @ts-ignore + const getLayout = Component.getLayout || ((page) => page); + useContracts(); usePreviousRoute(); @@ -59,8 +111,15 @@ function MyApp({ Component, pageProps }: AppProps) { }} /> - {/* @ts-ignore */} - + + + {getLayout()} + + ); diff --git a/pages/analytics/collateral.tsx b/pages/analytics/collateral.tsx new file mode 100644 index 0000000..52be2f8 --- /dev/null +++ b/pages/analytics/collateral.tsx @@ -0,0 +1,165 @@ +import React from "react"; +import Head from "next/head"; +import Link from "next/link"; +import { Typography } from "@originprotocol/origin-storybook"; +import { GetServerSideProps } from "next"; +import { map } from "lodash"; +import { find, orderBy } from "lodash"; +import { + BarElement, + CategoryScale, + Chart as ChartJS, + LinearScale, +} from "chart.js"; +import { + ErrorBoundary, + LayoutBox, + Image, + TwoColumnLayout, +} from "../../components"; +import { aggregateCollateral, backingTokens } from "../../utils/analytics"; +import { fetchAllocation, fetchCollateral } from "../../utils/api"; +import { formatCurrency } from "../../utils/math"; +import { strategyMapping } from "../../constants"; + +ChartJS.register(CategoryScale, LinearScale, BarElement); + +const CollateralAggregate = ({ data = [] }) => { + return ( +
+ {data.map(({ label, logoSrc, percentage, total }, index) => ( + +
+ {label} +
+ + {label} + + {`${formatCurrency( + total, + 2 + )}`} + + {formatCurrency(percentage * 100, 2)}% + +
+
+
+ ))} +
+ ); +}; + +const CollateralPoolDistributions = ({ data = [] }) => { + return ( +
+ Collateral Distribution +
+ {data?.map(({ name, address, holdings }) => { + const strategyName = + find( + strategyMapping, + (item) => item.address?.toLowerCase() === address?.toLowerCase() + )?.short_name || name; + return ( + +
+ {strategyName} + + Collateral + +
+ {map(holdings, (holdingTotal, token) => + backingTokens[token] ? ( +
+ {token} +
+ {token} + + + {`${formatCurrency(holdingTotal, 2)}`} + + External link + +
+
+ ) : null + )} +
+
+
+ ); + })} +
+
+ ); +}; + +const AnalyticsCollateral = ({ strategies, collateral }) => { + return ( + + + Analytics | Collateral + + +
+
+ +
+
+ +
+
+
+ ); +}; + +export const getServerSideProps: GetServerSideProps = async (): Promise<{ + props; +}> => { + const [allocation, { collateral }] = await Promise.all([ + fetchAllocation(), + fetchCollateral(), + ]); + + return { + props: { + strategies: orderBy(allocation?.strategies, "total", "desc"), + collateral: orderBy( + aggregateCollateral({ collateral, allocation }), + "total", + "desc" + ), + }, + }; +}; + +export default AnalyticsCollateral; + +AnalyticsCollateral.getLayout = (page, props) => ( + {page} +); diff --git a/pages/analytics/health-monitoring.tsx b/pages/analytics/health-monitoring.tsx new file mode 100644 index 0000000..6b3c479 --- /dev/null +++ b/pages/analytics/health-monitoring.tsx @@ -0,0 +1,120 @@ +import { Typography } from "@originprotocol/origin-storybook"; +import Head from "next/head"; +import { ErrorBoundary, LayoutBox, TwoColumnLayout } from "../../components"; + +const monitoring = { + macro: [], + statistics: [], + strategies: [], +}; + +const AnalyticsHealthMonitoring = () => { + return ( + + + Analytics | Health Monitoring + +
+ + Monitoring the health of DEFI contracts that can affect OETH strategy + health. + +
+ Macro situation +
+ {monitoring?.macro.map(({ url, description }) => ( +
+ +
+