diff --git a/packages/frontend/src/api/services/exploits/exploitsEndpoints.ts b/packages/frontend/src/api/services/exploits/exploitsEndpoints.ts new file mode 100644 index 000000000..e8836409a --- /dev/null +++ b/packages/frontend/src/api/services/exploits/exploitsEndpoints.ts @@ -0,0 +1,101 @@ +import queryString from "query-string"; +import { Logger } from "src/api/utils/logging"; +import CONFIG from "../../../config/config"; +import { alphadayApi } from "../alphadayApi"; +import { + TGetExploitsRequest, + TGetExploitsRawResponse, + TGetExploitsResponse, +} from "./types"; + +const { EXPLOITS } = CONFIG.API.DEFAULT.ROUTES; + +const parseAmount = (amount: string | null): number | null => { + if (amount === null) return null; + const parsed = parseFloat(amount); + return Number.isNaN(parsed) ? null : parsed; +}; + +const exploitsApi = alphadayApi.injectEndpoints({ + endpoints: (builder) => ({ + getExploits: builder.query({ + query: (req) => { + const params: string = queryString.stringify({ + ...req, + limit: req.limit ?? 30, + }); + const path = `${String(EXPLOITS.BASE)}${String( + EXPLOITS.DEFAULT + )}?${params}`; + Logger.debug("getExploits: querying", path); + return path; + }, + transformResponse: ( + r: TGetExploitsRawResponse, + _meta, + _arg + ): TGetExploitsResponse => { + Logger.debug("[EXPLOITS] transformResponse: raw response =", r); + Logger.debug( + "[EXPLOITS] transformResponse: typeof r =", + typeof r, + "| keys =", + r && typeof r === "object" ? Object.keys(r) : "n/a", + "| results isArray =", + Array.isArray(r?.results), + "| results length =", + r?.results?.length + ); + try { + const facadeData = r.results + .filter( + (item, index, self) => + self.findIndex( + (innerItem) => innerItem.id === item.id + ) === index + ) + .map((data) => ({ + id: data.id, + hash: data.hash, + title: data.title, + url: data.url, + image: data.image, + shortDescription: data.short_description, + description: data.description, + protocol: data.protocol, + amountUsd: parseAmount(data.amount_usd), + chain: data.chain, + attackType: data.attack_type?.name ?? null, + date: data.date, + source: data.source, + tags: data.tags ?? [], + })); + Logger.debug( + "[EXPLOITS] transformResponse: mapped facade length =", + facadeData.length, + "| first item =", + facadeData[0] + ); + return { + ...r, + results: facadeData, + }; + } catch (error) { + Logger.error( + "[EXPLOITS] transformResponse: FAILED to parse response — returning empty list. error =", + error, + "| raw response was =", + r + ); + return { + ...r, + results: [], + }; + } + }, + }), + }), + overrideExisting: false, +}); + +export const { useGetExploitsQuery } = exploitsApi; diff --git a/packages/frontend/src/api/services/exploits/types.ts b/packages/frontend/src/api/services/exploits/types.ts new file mode 100644 index 000000000..98daf52cb --- /dev/null +++ b/packages/frontend/src/api/services/exploits/types.ts @@ -0,0 +1,69 @@ +import { TExploit } from "src/api/types"; +import { TPagination } from "../baseTypes"; + +/** + * Primitive types + */ + +export type TRemoteExploitSource = { + name: string; + slug: string; + icon: string | null; +}; + +export type TRemoteExploitTag = { + id: number; + name: string; + slug: string; +}; + +export type TRemoteExploitAttackType = { + id: number; + name: string; + slug: string; + description: string | null; +}; + +export type TRemoteExploit = { + id: number; + hash: string; + title: string; + url: string; + image: string | null; + short_description: string | null; + description: string | null; + protocol: string | null; + // amount lost, expressed in millions of USD + amount_usd: string | null; + chain: string | null; + attack_type: TRemoteExploitAttackType | null; + date: string; + source: TRemoteExploitSource | null; + tags: TRemoteExploitTag[]; +}; + +/** + * Queries + */ + +export type TGetExploitsRequest = { + page?: number | undefined; + limit?: number | undefined; + attack_type?: string | undefined; + chain?: string | undefined; + date?: string | undefined; + min_amount?: number | undefined; + period?: string | undefined; + protocol?: string | undefined; + sort_by?: string | undefined; + sources?: string | undefined; + tags?: string | undefined; +}; + +export type TGetExploitsRawResponse = TPagination & { + results: TRemoteExploit[]; +}; + +export type TGetExploitsResponse = TPagination & { + results: TExploit[]; +}; diff --git a/packages/frontend/src/api/services/index.ts b/packages/frontend/src/api/services/index.ts index bb58cc309..342c8577c 100644 --- a/packages/frontend/src/api/services/index.ts +++ b/packages/frontend/src/api/services/index.ts @@ -44,6 +44,9 @@ export * from "./yields/types"; export * from "./fees/feesEndpoints"; export * from "./fees/types"; +export * from "./exploits/exploitsEndpoints"; +export * from "./exploits/types"; + export * from "./developerActivity/developerActivityEndpoints"; export * from "./developerActivity/types"; diff --git a/packages/frontend/src/api/types/exploits.ts b/packages/frontend/src/api/types/exploits.ts new file mode 100644 index 000000000..a42c21c82 --- /dev/null +++ b/packages/frontend/src/api/types/exploits.ts @@ -0,0 +1,41 @@ +export type TExploitSource = { + name: string; + slug: string; + icon: string | null; +}; + +export type TExploitTag = { + id: number; + name: string; + slug: string; +}; + +export type TExploit = { + id: number; + hash: string; + title: string; + url: string; + image: string | null; + shortDescription: string | null; + description: string | null; + protocol: string | null; + // amount lost, expressed in millions of USD (null when unknown) + amountUsd: number | null; + chain: string | null; + attackType: string | null; + date: string; + source: TExploitSource | null; + tags: TExploitTag[]; +}; + +export enum EExploitPeriod { + Last24 = "LAST_24", + LastWeek = "LAST_WEEK", + LastMonth = "LAST_MONTH", + LastQuarter = "LAST_QUARTER", +} + +export enum EExploitSortBy { + Date = "-date", + Amount = "-amount_usd", +} diff --git a/packages/frontend/src/api/types/index.ts b/packages/frontend/src/api/types/index.ts index 36af3290a..6109d86ff 100644 --- a/packages/frontend/src/api/types/index.ts +++ b/packages/frontend/src/api/types/index.ts @@ -6,6 +6,7 @@ export * from "./dao"; export * from "./developerActivity"; export * from "./discord"; export * from "./event"; +export * from "./exploits"; export * from "./features"; export * from "./fees"; export * from "./forum"; diff --git a/packages/frontend/src/components/exploits/ExploitItem.tsx b/packages/frontend/src/components/exploits/ExploitItem.tsx new file mode 100644 index 000000000..c3961dfdb --- /dev/null +++ b/packages/frontend/src/components/exploits/ExploitItem.tsx @@ -0,0 +1,107 @@ +import { FC, useState, KeyboardEvent } from "react"; +import moment from "moment-with-locales-es6"; +import { TExploit } from "src/api/types"; +import { formatNumber, ENumberStyle } from "src/api/utils/format"; +import ChevronSVG from "src/assets/icons/chevron-down2.svg?react"; + +interface IExploitItemProps { + exploit: TExploit; +} + +const formatAmount = (amountUsd: number | null): string => { + if (amountUsd === null) return "—"; + // amount_usd is expressed in millions of USD + return formatNumber({ + value: amountUsd * 1_000_000, + style: ENumberStyle.Currency, + currency: "USD", + }).value; +}; + +export const ExploitItem: FC = ({ exploit }) => { + const [isExpanded, setIsExpanded] = useState(false); + const description = exploit.description ?? exploit.shortDescription; + + const toggle = () => setIsExpanded((prev) => !prev); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggle(); + } + }; + + return ( +
+
+
+ + {exploit.protocol ?? exploit.title} + + + {exploit.title} + +
+ {exploit.chain && ( + + {exploit.chain} + + )} + {exploit.attackType && ( + + {exploit.attackType} + + )} + + {moment(exploit.date).format("MMM D, YYYY")} + +
+
+
+
+ + {formatAmount(exploit.amountUsd)} + + + lost + +
+ +
+
+ + {isExpanded && description && ( +
+

+ {description} +

+ e.stopPropagation()} + className="inline-block mt-2 fontGroup-highlightSemi text-secondaryOrange hover:underline" + > + Read more → + +
+ )} +
+ ); +}; diff --git a/packages/frontend/src/components/exploits/ExploitsModule.tsx b/packages/frontend/src/components/exploits/ExploitsModule.tsx new file mode 100644 index 000000000..864d4215b --- /dev/null +++ b/packages/frontend/src/components/exploits/ExploitsModule.tsx @@ -0,0 +1,98 @@ +import { FC, memo, FormEvent } from "react"; +import { ModuleLoader, ScrollBar, TabsBar } from "@alphaday/ui-kit"; +import { TExploit, EExploitSortBy } from "src/api/types"; +import { shouldFetchMoreItems } from "src/api/utils/itemUtils"; +import { Logger } from "src/api/utils/logging"; +import globalMessages from "src/globalMessages"; +import { ExploitItem } from "./ExploitItem"; + +interface IExploits { + isLoading: boolean; + exploits: TExploit[] | undefined; + widgetHeight: number; + selectedSort: EExploitSortBy; + onChangeSort: (sort: EExploitSortBy) => void; + handlePaginate: (type: "next" | "previous") => void; +} + +const SORT_HEIGHT = 38; + +const sortNavItems = [ + { label: "Latest", value: EExploitSortBy.Date }, + { label: "Biggest", value: EExploitSortBy.Amount }, +]; + +const ExploitsModule: FC = memo(function ExploitsModule({ + isLoading, + exploits, + widgetHeight, + selectedSort, + onChangeSort, + handlePaginate, +}) { + const LIST_HEIGHT = widgetHeight - SORT_HEIGHT; + + const selectedSortOption = + sortNavItems.find((item) => item.value === selectedSort) || + sortNavItems[0]; + + const onTabOptionChange = (value: string) => { + const optionItem = sortNavItems.find((item) => item.value === value); + if (optionItem === undefined) { + Logger.debug("ExploitsModule::onTabOptionChange: option not found"); + return; + } + onChangeSort(optionItem.value); + }; + + const handleListScroll = ({ currentTarget }: FormEvent) => { + if (shouldFetchMoreItems(currentTarget)) { + handlePaginate("next"); + } + }; + + if (isLoading) { + return ; + } + + return ( +
+
+
+ +
+
    + {exploits !== undefined && exploits?.length !== 0 ? ( + + {exploits?.map((exploit) => ( +
  • + +
  • + ))} +
    + ) : ( +
    +

    + {globalMessages.queries.noMatchFound( + "security alerts" + )} +

    +
    + )} +
+
+
+ ); +}); + +export default ExploitsModule; diff --git a/packages/frontend/src/config/backend.ts b/packages/frontend/src/config/backend.ts index 94c750334..6ff2b27bd 100644 --- a/packages/frontend/src/config/backend.ts +++ b/packages/frontend/src/config/backend.ts @@ -148,6 +148,10 @@ const API_V0 = { BASE: "tvl", DEFAULT: "/yields/", }, + EXPLOITS: { + BASE: "security", + DEFAULT: "/exploits/", + }, VIEWS: { BASE: "ui", AVAILABLE_VIEWS: "/views/", diff --git a/packages/frontend/src/config/widgets.ts b/packages/frontend/src/config/widgets.ts index ecb4ae8c7..4fa4a56ab 100644 --- a/packages/frontend/src/config/widgets.ts +++ b/packages/frontend/src/config/widgets.ts @@ -253,6 +253,12 @@ export const WIDGETS_CONFIG = { MAX_PAGE_NUMBER: 10, ADJUSTABLE: true, }, + [ETemplateNameRegistry.Exploits]: { + POLLING_INTERVAL: 15 * 60, // 15 min + WIDGET_HEIGHT: 538, + MAX_PAGE_NUMBER: 10, + ADJUSTABLE: true, + }, [ETemplateNameRegistry.TvlFees]: { TAG_ITEM_TYPE: "project", POLLING_INTERVAL: 180, // 3 min diff --git a/packages/frontend/src/constants.ts b/packages/frontend/src/constants.ts index c94f0af41..0105a5199 100644 --- a/packages/frontend/src/constants.ts +++ b/packages/frontend/src/constants.ts @@ -65,6 +65,7 @@ export enum ETemplateNameRegistry { Discord = "DISCORD", Dune = "DUNE", DuneTable = "DUNE_TABLE", + Exploits = "EXPLOITS", FAQ = "FAQ", Forum = "FORUM", Gas = "GAS", diff --git a/packages/frontend/src/containers/exploits/ExploitsContainer.tsx b/packages/frontend/src/containers/exploits/ExploitsContainer.tsx new file mode 100644 index 000000000..1fdc2eb62 --- /dev/null +++ b/packages/frontend/src/containers/exploits/ExploitsContainer.tsx @@ -0,0 +1,111 @@ +import { FC, useState, useEffect } from "react"; +import { usePagination, useWidgetHeight } from "src/api/hooks"; +import { useGetExploitsQuery } from "src/api/services"; +import { TExploit, EExploitSortBy } from "src/api/types"; +import { buildUniqueItemList } from "src/api/utils/itemUtils"; +import { Logger } from "src/api/utils/logging"; +import ExploitsModule from "src/components/exploits/ExploitsModule"; +import CONFIG from "src/config"; +import { IModuleContainer } from "src/types"; + +const { MAX_PAGE_NUMBER } = CONFIG.WIDGETS.EXPLOITS; + +const ExploitsContainer: FC = ({ + moduleData, + mobileViewWidgetHeight, +}) => { + const widgetHeight = useWidgetHeight(moduleData); + + const [currentPage, setCurrentPage] = useState(); + const [selectedSort, setSelectedSort] = useState( + EExploitSortBy.Date + ); + + const pollingInterval = + (moduleData.widget.refresh_interval || + CONFIG.WIDGETS.EXPLOITS.POLLING_INTERVAL) * 1000; + + const { + currentData: exploitsDataResponse, + isLoading, + isSuccess, + } = useGetExploitsQuery( + { + page: currentPage, + sort_by: selectedSort, + }, + { + pollingInterval, + } + ); + + const { + nextPage, + handleNextPage, + reset: resetPagination, + } = usePagination(exploitsDataResponse?.links, MAX_PAGE_NUMBER, isSuccess); + + const [exploitsData, setExploitsData] = useState([]); + + const reset = () => { + setExploitsData([]); + setCurrentPage(undefined); + resetPagination(); + }; + + // Accumulate pages as they arrive. + // + // This runs in the commit phase, on purpose. The earlier approach appended + // during render while also mutating a ref to track "already seen" — but in + // React 18's concurrent renderer a prepared render can be discarded, which + // rolls back the queued setState while keeping the ref mutation. The ref + // then permanently reads "already appended", so the fetched page is lost + // and the list stays empty. Keying an effect on the results reference keeps + // the state update and the de-dup tracking in sync. + const exploitsResults = exploitsDataResponse?.results; + useEffect(() => { + if (exploitsResults === undefined) return; + setExploitsData((prevState) => [...prevState, ...exploitsResults]); + }, [exploitsResults]); + + // set current page 350ms after next page is set. + // RTK should cache requests, so we don't need to be too careful about rerenders. + useEffect(() => { + if (nextPage === undefined) return () => null; + const timeout = setTimeout(() => { + setCurrentPage(nextPage); + }, 350); + return () => { + clearTimeout(timeout); + }; + }, [nextPage]); + + const handleChangeSort = (sort: EExploitSortBy) => { + if (sort === selectedSort) return; + reset(); + setSelectedSort(sort); + }; + + const dataForModule = buildUniqueItemList(exploitsData); + Logger.debug( + "[EXPLOITS] container: passing to module. accumulated =", + exploitsData.length, + "| after dedup =", + dataForModule.length, + "| isLoading =", + isLoading + ); + + return ( + + ); +}; + +export default ExploitsContainer; diff --git a/packages/frontend/src/types.ts b/packages/frontend/src/types.ts index 14096ca7a..49e6d1170 100644 --- a/packages/frontend/src/types.ts +++ b/packages/frontend/src/types.ts @@ -156,6 +156,9 @@ export const TEMPLATES_DICT: Partial = { tvl_fees_template: lazyRetry( () => import("./containers/fees/FeesContainer") ), + exploits_template: lazyRetry( + () => import("./containers/exploits/ExploitsContainer") + ), two_col_image_template: lazyRetry( () => import("./containers/image/TwoColImageContainer") ),