diff --git a/src/app/review/[slug]/page.tsx b/src/app/review/[slug]/page.tsx new file mode 100644 index 000000000..e351dc7b0 --- /dev/null +++ b/src/app/review/[slug]/page.tsx @@ -0,0 +1,47 @@ +import { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { reviewDataService } from "@/services/review-data"; +import { ReviewPageLayout } from "@/components/review-page/review-page-layout"; +import type { ReviewConfig } from "@/lib/review-page/types"; + +interface ReviewPageProps { + params: { slug: string }; +} + +async function getConfig(slug: string): Promise { + try { + const config = await import( + `@/content/review-configs/${slug}.json` + ); + return config.default as ReviewConfig; + } catch { + return null; + } +} + +export async function generateMetadata({ + params, +}: ReviewPageProps): Promise { + const config = await getConfig(params.slug); + if (!config) return { title: "Review not found | DeFiScan" }; + + return { + title: `${config.protocolName} Review | DeFiScan`, + description: `Review of ${config.protocolName} (${config.tokenName}) on ${config.chain}`, + robots: { index: false, follow: false }, + }; +} + +export default async function ReviewPage({ params }: ReviewPageProps) { + const config = await getConfig(params.slug); + if (!config) notFound(); + + const apiData = await reviewDataService.getReviewData(params.slug); + if (!apiData) notFound(); + + return ( +
+ +
+ ); +} diff --git a/src/components/review-page/actors-section.tsx b/src/components/review-page/actors-section.tsx new file mode 100644 index 000000000..8f8533576 --- /dev/null +++ b/src/components/review-page/actors-section.tsx @@ -0,0 +1,23 @@ +import { SectionCard } from "./section-card"; +import { DynamicContentList } from "./dynamic-content-block"; +import type { ReviewSectionConfig } from "@/lib/review-page/types"; + +interface ActorsSectionProps { + config: ReviewSectionConfig; + data: Record; +} + +export function ActorsSection({ config, data }: ActorsSectionProps) { + return ( + +
+ {config.subsections.map((sub, i) => ( +
+

{sub.title}

+ +
+ ))} +
+
+ ); +} diff --git a/src/components/review-page/code-audits-section.tsx b/src/components/review-page/code-audits-section.tsx new file mode 100644 index 000000000..2c19ee691 --- /dev/null +++ b/src/components/review-page/code-audits-section.tsx @@ -0,0 +1,26 @@ +import { SectionCard } from "./section-card"; +import { DynamicContentList } from "./dynamic-content-block"; +import type { ReviewSectionConfig } from "@/lib/review-page/types"; + +interface CodeAuditsSectionProps { + config: ReviewSectionConfig; + data: Record; +} + +export function CodeAuditsSection({ + config, + data, +}: CodeAuditsSectionProps) { + return ( + +
+ {config.subsections.map((sub, i) => ( +
+

{sub.title}

+ +
+ ))} +
+
+ ); +} diff --git a/src/components/review-page/coin-chart.tsx b/src/components/review-page/coin-chart.tsx new file mode 100644 index 000000000..4ff67e28f --- /dev/null +++ b/src/components/review-page/coin-chart.tsx @@ -0,0 +1,305 @@ +"use client"; + +import { useState } from "react"; +import { PieChart, Pie, Cell, Sector } from "recharts"; +import { + ChartConfig, + ChartContainer, +} from "@/components/ui/chart"; +import type { CollateralData } from "@/lib/review-page/types"; + +interface CoinChartProps { + data: CollateralData; + className?: string; +} + +function formatUsd(value: number): string { + if (value >= 1_000_000_000) return `$${(value / 1_000_000_000).toFixed(1)}B`; + if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(0)}M`; + if (value >= 1_000) return `$${(value / 1_000).toFixed(0)}K`; + return `$${value.toFixed(0)}`; +} + +// Active shape renderer for hover effect +const renderActiveShape = (props: any) => { + const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill } = props; + + return ( + + + + ); +}; + +export function CoinChart({ data, className }: CoinChartProps) { + const [activeIndex, setActiveIndex] = useState(null); + const chartConfig = {} satisfies ChartConfig; + const size = 320; + const center = size / 2; + const outerRadius = size / 2 - 10; + const bezelWidth = 20; + const ridgeWidth = 8; + + const activeAsset = activeIndex !== null ? data.assets[activeIndex] : null; + + return ( +
setActiveIndex(null)}> + {/* 3D Coin Container */} +
+
+ {/* Main coin body */} +
+ {/* Outer metallic bezel SVG */} + + + {/* Chrome/Silver gradient for outer bezel */} + + + + + + + + + {/* Inner ring gradient */} + + + + + + + {/* Ridged edge pattern */} + + + + + + + {/* Outer bezel ring */} + + + {/* Ridged edge (coin milling) */} + + + {/* Engraved groove - darker on top, lighter on bottom */} + + + + + + + + + + {/* Inner border */} + + + + {/* Chart area - engraved into the coin */} +
+ {/* $ sign overlay with double vertical lines */} +
+ + $ + +
+ {/* Metallic gradient definitions */} + + + {/* ETH - Metallic blue-purple */} + + + + + + + + {/* WstETH - Metallic light blue */} + + + + + + + + {/* rETH - Metallic orange */} + + + + + + + + + + + + {/* Full pie chart filling the coin face */} + setActiveIndex(index)} + onMouseLeave={() => setActiveIndex(null)} + onClick={(_, index, e) => { + e.stopPropagation(); + setActiveIndex((prev) => (prev === index ? null : index)); + }} + > + {data.assets.map((entry, index) => ( + + ))} + + + +
+
+
+
+ + {/* Hover info / Total value */} +
+ {activeAsset ? ( + <> +
{formatUsd(activeAsset.value)}
+
+ {activeAsset.name} ({((activeAsset.value / data.totalValue) * 100).toFixed(1)}%) +
+ + ) : ( + <> +
{formatUsd(data.totalValue)}
+
Total Collateral
+ + )} +
+ + {/* Legend */} +
+ {data.categories.map((cat, i) => ( +
+
+ + {cat.name} ({formatUsd(cat.value)}) + +
+ ))} +
+
+ ); +} diff --git a/src/components/review-page/collaterals-section.tsx b/src/components/review-page/collaterals-section.tsx new file mode 100644 index 000000000..65deb371a --- /dev/null +++ b/src/components/review-page/collaterals-section.tsx @@ -0,0 +1,30 @@ +import { SectionCard } from "./section-card"; +import { CoinChart } from "./coin-chart"; +import { DynamicContentList } from "./dynamic-content-block"; +import type { ReviewSectionConfig, CollateralData } from "@/lib/review-page/types"; + +interface CollateralsSectionProps { + config: ReviewSectionConfig; + collaterals: CollateralData; + data: Record; +} + +export function CollateralsSection({ + config, + collaterals, + data, +}: CollateralsSectionProps) { + return ( + + +
+ {config.subsections.map((sub, i) => ( +
+

{sub.title}

+ +
+ ))} +
+
+ ); +} diff --git a/src/components/review-page/concentric-donut-chart.tsx b/src/components/review-page/concentric-donut-chart.tsx new file mode 100644 index 000000000..a48828628 --- /dev/null +++ b/src/components/review-page/concentric-donut-chart.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { PieChart, Pie, Cell, Label } from "recharts"; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import type { CollateralData } from "@/lib/review-page/types"; + +interface ConcentricDonutChartProps { + data: CollateralData; + className?: string; +} + +function formatUsd(value: number): string { + if (value >= 1_000_000_000) return `$${(value / 1_000_000_000).toFixed(1)}B`; + if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(0)}M`; + if (value >= 1_000) return `$${(value / 1_000).toFixed(0)}K`; + return `$${value.toFixed(0)}`; +} + +export function ConcentricDonutChart({ + data, + className, +}: ConcentricDonutChartProps) { + const chartConfig = {} satisfies ChartConfig; + + return ( +
+ + + } + /> + + {/* Outer ring: categories */} + + {data.categories.map((entry, index) => ( + + ))} + + + {/* Inner ring: individual assets */} + + {data.assets.map((entry, index) => ( + + ))} + + + + + {/* Legend */} +
+ {data.categories.map((cat, i) => ( +
+
+ + {cat.name} ({formatUsd(cat.value)}) + +
+ ))} +
+
+ ); +} diff --git a/src/components/review-page/dependencies-section.tsx b/src/components/review-page/dependencies-section.tsx new file mode 100644 index 000000000..d742c1cb8 --- /dev/null +++ b/src/components/review-page/dependencies-section.tsx @@ -0,0 +1,26 @@ +import { SectionCard } from "./section-card"; +import { DynamicContentList } from "./dynamic-content-block"; +import type { ReviewSectionConfig } from "@/lib/review-page/types"; + +interface DependenciesSectionProps { + config: ReviewSectionConfig; + data: Record; +} + +export function DependenciesSection({ + config, + data, +}: DependenciesSectionProps) { + return ( + +
+ {config.subsections.map((sub, i) => ( +
+

{sub.title}

+ +
+ ))} +
+
+ ); +} diff --git a/src/components/review-page/dynamic-content-block.tsx b/src/components/review-page/dynamic-content-block.tsx new file mode 100644 index 000000000..cdd9f63ef --- /dev/null +++ b/src/components/review-page/dynamic-content-block.tsx @@ -0,0 +1,408 @@ +"use client"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { ChevronDown, ExternalLink } from "lucide-react"; +import type { + ContentBlock, + TableColorScale, + TableBadgeColumn, + ExpandableTableRow, + PermissionedFunction, +} from "@/lib/review-page/types"; + +export function TagBadge({ + variant, + children, +}: { + variant: string; + children: React.ReactNode; +}) { + const baseClasses = "inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium"; + + switch (variant) { + case "immutable": + return ( + + {children} + + ); + case "upgradeable": + return ( + + {children} + + ); + case "centralized": + return ( + + {children} + + ); + case "staking": + return ( + + {children} + + ); + case "canonical": + return ( + + {children} + + ); + case "timelocked": + return ( + + {children} + + ); + case "direct": + return ( + + {children} + + ); + case "eoa": + return ( + + {children} + + ); + case "external": + return ( + + {children} + + ); + default: + return ( + + {children} + + ); + } +} + +export function interpolateData( + text: string, + data: Record +): string { + return text.replace( + /\{\{(\w+)\}\}/g, + (_, key) => String(data[key] ?? `{{${key}}}`) + ); +} + +function formatDataValue( + value: string | number, + format?: "usd" | "percent" | "number" | "string" +): string { + if (format === "usd" && typeof value === "number") { + if (value >= 1_000_000_000) return `$${(value / 1_000_000_000).toFixed(1)}B`; + if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `$${(value / 1_000).toFixed(1)}K`; + return `$${value.toFixed(2)}`; + } + if (format === "percent" && typeof value === "number") { + return `${value.toFixed(1)}%`; + } + return String(value); +} + +function getShareColor( + rowIndex: number, + colIndex: number, + colorScale: TableColorScale | undefined, + data: Record +): string | undefined { + if (!colorScale) return undefined; + const colPos = colorScale.columns.indexOf(colIndex); + if (colPos === -1) return undefined; + + const ref = Number(data[colorScale.referenceMetric]); + const valueKey = colorScale.valueMetrics[rowIndex]?.[colPos]; + if (!valueKey) return undefined; + const val = Number(data[valueKey]); + if (isNaN(ref) || isNaN(val) || ref === 0) return undefined; + + const share = val / ref; + if (share < 0.25) return "text-green-600"; + if (share < 0.5) return "text-orange-500"; + return "text-red-600"; +} + +function getBadgeConfig( + colIndex: number, + badgeColumns?: TableBadgeColumn[] +): TableBadgeColumn | undefined { + return badgeColumns?.find((b) => b.column === colIndex); +} + +interface DynamicContentBlockProps { + block: ContentBlock; + data: Record; +} + +export function DynamicContentBlock({ + block, + data, +}: DynamicContentBlockProps) { + switch (block.type) { + case "text": + return ( +

+ {interpolateData(block.content, data)} +

+ ); + + case "table": + return ( + + + + {block.headers.map((header, i) => ( + {header} + ))} + + + + {block.rows.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => { + const colorClass = getShareColor( + rowIndex, + cellIndex, + block.colorScale, + data + ); + const badgeConfig = getBadgeConfig( + cellIndex, + block.badgeColumns + ); + const cellText = interpolateData(cell, data); + + if (badgeConfig) { + const variant = badgeConfig.colorMap?.[cellText] ?? "default"; + return ( + + + {cellText} + + + ); + } + + return ( + + + {cellText} + + + ); + })} + + ))} + +
+ ); + + case "expandableTable": { + const externalCallers = block.externalCallers ?? []; + return ( + +
+
+ {block.headers.map((header, i) => ( +
+ {header} +
+ ))} +
+ {block.rows.map((row: ExpandableTableRow, rowIndex: number) => { + const hasExpandedContent = row.expandedContent?.functions?.length; + // Check if any caller in this row has an external dependency + const rowHasExternalCaller = row.expandedContent?.functions?.some( + (fn) => fn.callers.some((c) => externalCallers.includes(c)) + ) ?? false; + return ( + + +
+
+ {hasExpandedContent ? ( + + ) : ( + + )} +
+ {row.cells.map((cell, cellIndex) => { + const badgeConfig = getBadgeConfig( + cellIndex, + block.badgeColumns + ); + const cellText = interpolateData(cell, data); + return ( +
+ {badgeConfig ? ( +
+ + {cellText} + + {rowHasExternalCaller && ( + + Ext. Dependency + + )} +
+ ) : ( + cellText + )} +
+ ); + })} +
+
+ {hasExpandedContent && ( + +
+
+ Permissioned entry points: +
+ {row.expandedContent!.functions.map( + (fn: PermissionedFunction, fnIndex: number) => ( +
+
+ + {fn.name} +
+
+ {fn.callers.map((caller, callerIndex) => { + const isExternalCaller = externalCallers.includes(caller); + return ( +
+ + {callerIndex === fn.callers.length - 1 + ? "└─" + : "├─"} + + {caller} + {isExternalCaller && ( + + External Dependency + + )} +
+ ); + })} +
+
+ ) + )} +
+
+ )} +
+ ); + })} +
+ ); + } + + case "dropdown": + return ( + + + + {block.label} + + +
+ {block.content.map((innerBlock, i) => ( + + ))} +
+
+
+
+ ); + + case "link": + return ( + + {block.text} + {block.external && } + + ); + + case "metric": { + const value = data[block.dataKey]; + if (value === undefined) return null; + return ( +
+ {block.label}: + + {formatDataValue(value, block.format)} + +
+ ); + } + + default: + return null; + } +} + +interface DynamicContentListProps { + blocks: ContentBlock[]; + data: Record; +} + +export function DynamicContentList({ + blocks, + data, +}: DynamicContentListProps) { + return ( +
+ {blocks.map((block, i) => ( + + ))} +
+ ); +} diff --git a/src/components/review-page/permissions-section.tsx b/src/components/review-page/permissions-section.tsx new file mode 100644 index 000000000..2c48b2ef4 --- /dev/null +++ b/src/components/review-page/permissions-section.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { SectionCard } from "./section-card"; +import { + DynamicContentList, + TagBadge, + interpolateData, +} from "./dynamic-content-block"; +import type { ReviewSectionConfig } from "@/lib/review-page/types"; + +interface PermissionsSectionProps { + config: ReviewSectionConfig; + data: Record; +} + +export function PermissionsSection({ + config, + data, +}: PermissionsSectionProps) { + return ( + +
+ {config.subsections.map((sub, i) => ( +
+
+

{sub.title}

+ {sub.badge && ( + + {sub.badge} + + )} +
+ {sub.subtitle && ( +

+ {interpolateData(sub.subtitle, data)} +

+ )} + +
+ ))} +
+
+ ); +} diff --git a/src/components/review-page/review-header.tsx b/src/components/review-page/review-header.tsx new file mode 100644 index 000000000..87ff98186 --- /dev/null +++ b/src/components/review-page/review-header.tsx @@ -0,0 +1,39 @@ +import { Badge } from "@/components/ui/badge"; + +interface ReviewHeaderProps { + tokenName: string; + protocolName: string; + chain: string; + address?: string; +} + +export function ReviewHeader({ + tokenName, + protocolName, + chain, + address, +}: ReviewHeaderProps) { + return ( +
+

+ {tokenName} +

+
+ {protocolName} + {chain} +
+ {address && ( + + )} +
+ ); +} diff --git a/src/components/review-page/review-page-layout.tsx b/src/components/review-page/review-page-layout.tsx new file mode 100644 index 000000000..eabdb40b3 --- /dev/null +++ b/src/components/review-page/review-page-layout.tsx @@ -0,0 +1,78 @@ +import { ReviewHeader } from "./review-header"; +import { CollateralsSection } from "./collaterals-section"; +import { StrategySection } from "./strategy-section"; +import { DependenciesSection } from "./dependencies-section"; +import { ActorsSection } from "./actors-section"; +import { PermissionsSection } from "./permissions-section"; +import { CodeAuditsSection } from "./code-audits-section"; +import type { + ReviewConfig, + ReviewApiData, + StablecoinSections, + VaultSections, +} from "@/lib/review-page/types"; +import { isVaultReview } from "@/lib/review-page/types"; + +interface ReviewPageLayoutProps { + config: ReviewConfig; + apiData: ReviewApiData; +} + +export function ReviewPageLayout({ config, apiData }: ReviewPageLayoutProps) { + const isVault = isVaultReview(config); + + return ( +
+ + +
+
+ {isVault ? ( + + ) : ( + + )} + + +
+ +
+ {isVault && ( + + )} + + {!isVault && ( + + )} + + +
+
+
+ ); +} diff --git a/src/components/review-page/section-card.tsx b/src/components/review-page/section-card.tsx new file mode 100644 index 000000000..e70103785 --- /dev/null +++ b/src/components/review-page/section-card.tsx @@ -0,0 +1,30 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; + +interface SectionCardProps { + title: string; + description?: string; + children: React.ReactNode; + className?: string; +} + +export function SectionCard({ + title, + description, + children, + className, +}: SectionCardProps) { + return ( + + + + {title} + + {description && ( +

{description}

+ )} +
+ {children} +
+ ); +} diff --git a/src/components/review-page/strategy-section.tsx b/src/components/review-page/strategy-section.tsx new file mode 100644 index 000000000..c2e1cdeb9 --- /dev/null +++ b/src/components/review-page/strategy-section.tsx @@ -0,0 +1,30 @@ +import { SectionCard } from "./section-card"; +import { VaultChart } from "./vault-chart"; +import { DynamicContentList } from "./dynamic-content-block"; +import type { ReviewSectionConfig, VaultData } from "@/lib/review-page/types"; + +interface StrategySectionProps { + config: ReviewSectionConfig; + vault: VaultData; + data: Record; +} + +export function StrategySection({ + config, + vault, + data, +}: StrategySectionProps) { + return ( + + +
+ {config.subsections.map((sub, i) => ( +
+

{sub.title}

+ +
+ ))} +
+
+ ); +} diff --git a/src/components/review-page/vault-chart.tsx b/src/components/review-page/vault-chart.tsx new file mode 100644 index 000000000..624f28972 --- /dev/null +++ b/src/components/review-page/vault-chart.tsx @@ -0,0 +1,423 @@ +"use client"; + +import { useState } from "react"; +import { PieChart, Pie, Cell, Sector } from "recharts"; +import { + ChartConfig, + ChartContainer, +} from "@/components/ui/chart"; +import type { VaultData } from "@/lib/review-page/types"; + +interface VaultChartProps { + data: VaultData; + className?: string; +} + +function formatUsd(value: number): string { + if (value >= 1_000_000_000) return `$${(value / 1_000_000_000).toFixed(1)}B`; + if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(0)}M`; + if (value >= 1_000) return `$${(value / 1_000).toFixed(0)}K`; + return `$${value.toFixed(0)}`; +} + +const renderActiveShape = (props: any) => { + const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill } = props; + return ( + + + + ); +}; + +export function VaultChart({ data, className }: VaultChartProps) { + const [activeIndex, setActiveIndex] = useState(null); + const chartConfig = {} satisfies ChartConfig; + const size = 320; + const center = size / 2; + const bezelWidth = 22; + const outerRadius = size / 2 - bezelWidth / 2 - 2; + const boltRadius = 5; + const boltCount = 12; + + // Filter out zero-allocation markets for the chart + const chartMarkets = data.markets.filter((m) => m.allocation > 0); + const activeMarket = activeIndex !== null ? chartMarkets[activeIndex] : null; + + // Bolt positions around the bezel + const boltPositions = Array.from({ length: boltCount }, (_, i) => { + const angle = (i * 2 * Math.PI) / boltCount - Math.PI / 2; + const r = outerRadius - 1; + return { + x: center + r * Math.cos(angle), + y: center + r * Math.sin(angle), + }; + }); + + return ( +
setActiveIndex(null)}> + {/* Vault Door Container */} +
+
+ {/* Main vault body */} +
+ {/* Outer vault bezel SVG */} + + + {/* Dark steel gradient for vault bezel */} + + + + + + + + + {/* Bolt gradient */} + + + + + + + {/* Groove shadow */} + + + + + + + + + {/* Outer bezel ring */} + + + {/* Bolts/rivets around the bezel */} + {boltPositions.map((pos, i) => ( + + + {/* Bolt slot */} + + + ))} + + {/* Engraved groove */} + + + {/* Inner border */} + + + + {/* Chart area */} +
+ {/* Cross handle overlay */} +
+ + + + + + + + + + + + + + + + + + + + + + + + + {/* Horizontal bar shadow + bar */} + + + + {/* Vertical bar shadow + bar */} + + + + {/* Center hub - outer ring */} + + {/* Hub highlight */} + + {/* Hub inner ring */} + + {/* Hub center dot */} + + +
+ + {/* Metallic gradient definitions */} + + + {/* cbBTC - Metallic blue */} + + + + + + + + {/* WBTC - Metallic gold/orange */} + + + + + + + + {/* wstETH - Metallic light blue */} + + + + + + + + {/* WETH - Metallic purple */} + + + + + + + + + + + + + setActiveIndex(index)} + onMouseLeave={() => setActiveIndex(null)} + onClick={(_, index, e) => { + e.stopPropagation(); + setActiveIndex((prev) => (prev === index ? null : index)); + }} + > + {chartMarkets.map((entry, index) => ( + + ))} + + + +
+
+
+
+ + {/* Hover info / Total value */} +
+ {activeMarket ? ( + <> +
{formatUsd(activeMarket.allocation)}
+
+ {activeMarket.name} ({((activeMarket.allocation / data.totalAllocation) * 100).toFixed(1)}%) · {activeMarket.apy}% APY +
+ + ) : ( + <> +
{formatUsd(data.totalAllocation)}
+
Total Allocation
+ + )} +
+ + {/* Legend */} +
+ {chartMarkets.map((market, i) => ( +
+
+ + {market.name} ({formatUsd(market.allocation)}) + +
+ ))} +
+
+ ); +} diff --git a/src/content/review-configs/liquity-v2.json b/src/content/review-configs/liquity-v2.json new file mode 100644 index 000000000..c36b5bac2 --- /dev/null +++ b/src/content/review-configs/liquity-v2.json @@ -0,0 +1,286 @@ +{ + "protocolSlug": "liquity-v2", + "protocolName": "Liquity V2", + "tokenName": "BOLD", + "chain": "Ethereum", + "sections": { + "collaterals": { + "title": "Collaterals", + "description": "BOLD is backed by ETH, WstETH, and rETH deposits.", + "subsections": [ + { + "title": "Collateral Breakdown", + "content": [ + { + "type": "text", + "content": "Total collateral value: {{totalCollateralValue}}. The protocol accepts three collateral types." + }, + { + "type": "table", + "headers": ["Collateral", "Value", "Share"], + "rows": [ + ["ETH", "{{ethCollateralValue}}", "{{ethCollateralPct}}"], + ["WstETH", "{{wstethCollateralValue}}", "{{wstethCollateralPct}}"], + ["rETH", "{{rethCollateralValue}}", "{{rethCollateralPct}}"] + ] + } + ] + }, + { + "title": "Stability Pool", + "content": [ + { + "type": "text", + "content": "The Stability Pool acts as the primary liquidation mechanism, holding BOLD deposits ({{stabilityPoolSize}}) that absorb liquidated collateral." + } + ] + } + ] + }, + "dependencies": { + "title": "Dependencies", + "subsections": [ + { + "title": "Oracle - Chainlink", + "content": [ + { + "type": "text", + "content": "Liquity V2 uses Chainlink as the primary price oracle with a Tellor-based fallback. If both oracles fail, the system falls back to the last known good price." + }, + { + "type": "table", + "headers": ["Asset", "Oracle", "TVS", "Impacted"], + "rows": [ + ["ETH", "0x5f4...", "{{ethCollateralValue}}", "{{totalCollateralValue}}"], + ["WstETH", "0x8A9...", "{{wstethCollateralValue}}", "{{totalCollateralValue}}"], + ["rETH", "0xE86...", "{{rethCollateralValue}}", "{{totalCollateralValue}}"] + ], + "colorScale": { + "columns": [2, 3], + "referenceMetric": "totalCollateralValueRaw", + "valueMetrics": [ + ["ethCollateralValueRaw", "totalCollateralValueRaw"], + ["wstethCollateralValueRaw", "totalCollateralValueRaw"], + ["rethCollateralValueRaw", "totalCollateralValueRaw"] + ] + } + } + ] + } + ] + }, + "actors": { + "title": "Actors", + "subsections": [ + { + "title": "Governance", + "content": [ + { + "type": "text", + "content": "Liquity V2 is designed to be governance-free. All protocol parameters are immutable and set at deployment." + } + ] + }, + { + "title": "Liquidators", + "content": [ + { + "type": "text", + "content": "Anyone can trigger liquidations when a trove's collateral ratio falls below the minimum threshold. Liquidators are incentivized by gas compensation and collateral surplus." + } + ] + }, + { + "title": "Redemptions", + "content": [ + { + "type": "text", + "content": "Anyone can trigger redemptions by essentially buying the collateral of the troves with the lowest interest rates for BOLD. This is only profitable when the price of BOLD is < $1 (minus the redemption fee)." + } + ] + } + ] + }, + "codeAndAudits": { + "title": "Contracts & Audits", + "subsections": [ + { + "title": "Admin rights", + "content": [ + { + "type": "text", + "content": "There are no admin rights in Liquity V2. All protocol parameters are immutable and set at deployment." + } + ] + }, + { + "title": "Contracts holding funds", + "content": [ + { + "type": "expandableTable", + "headers": ["Name", "Address", "Tags", "TVS"], + "rows": [ + { + "cells": ["BoldToken", "0x6440f144...", "Immutable", "{{totalCollateralValue}}"], + "expandedContent": { + "functions": [ + { + "name": "mint()", + "callers": ["ActivePool", "StabilityPool"] + }, + { + "name": "burn()", + "callers": ["BorrowerOperations", "StabilityPool"] + } + ] + } + }, + { + "cells": ["StabilityPool (ETH)", "0x5721cbbd...", "Immutable", "{{stabilityPoolSize}}"], + "expandedContent": { + "functions": [ + { + "name": "triggerBoldRewards()", + "callers": ["ActivePool"] + } + ] + } + }, + { + "cells": ["StabilityPool (WstETH)", "0x9502b7c3...", "Immutable", "{{stabilityPoolSize}}"], + "expandedContent": { + "functions": [ + { + "name": "triggerBoldRewards()", + "callers": ["ActivePool"] + } + ] + } + }, + { + "cells": ["StabilityPool (rETH)", "0xd442E410...", "Immutable", "{{stabilityPoolSize}}"], + "expandedContent": { + "functions": [ + { + "name": "triggerBoldRewards()", + "callers": ["ActivePool"] + } + ] + } + }, + { + "cells": ["ActivePool (ETH)", "0xeB5A8C82...", "Immutable", "{{ethCollateralValue}}"], + "expandedContent": { + "functions": [ + { + "name": "sendColl()", + "callers": ["BorrowerOperations", "TroveManager"] + }, + { + "name": "receiveColl()", + "callers": ["BorrowerOperations", "TroveManager"] + }, + { + "name": "accountForReceivedColl()", + "callers": ["DefaultPool"] + }, + { + "name": "setShutdownFlag()", + "callers": ["TroveManager"] + } + ] + } + }, + { + "cells": ["ActivePool (WstETH)", "0x531a8f99...", "Immutable", "{{wstethCollateralValue}}"], + "expandedContent": { + "functions": [ + { + "name": "sendColl()", + "callers": ["BorrowerOperations", "TroveManager"] + }, + { + "name": "receiveColl()", + "callers": ["BorrowerOperations", "TroveManager"] + }, + { + "name": "accountForReceivedColl()", + "callers": ["DefaultPool"] + }, + { + "name": "setShutdownFlag()", + "callers": ["TroveManager"] + } + ] + } + }, + { + "cells": ["ActivePool (rETH)", "0x9074D72c...", "Immutable", "{{rethCollateralValue}}"], + "expandedContent": { + "functions": [ + { + "name": "sendColl()", + "callers": ["BorrowerOperations", "TroveManager"] + }, + { + "name": "receiveColl()", + "callers": ["BorrowerOperations", "TroveManager"] + }, + { + "name": "accountForReceivedColl()", + "callers": ["DefaultPool"] + }, + { + "name": "setShutdownFlag()", + "callers": ["TroveManager"] + } + ] + } + } + ], + "badgeColumns": [ + { + "column": 2, + "colorMap": { + "Immutable": "immutable", + "Upgradeable": "upgradeable", + "EOA Upgradeable": "eoa", + "External Dependency": "external" + } + } + ], + "externalCallers": ["BorrowerOperations"] + } + ] + }, + { + "title": "Audits", + "content": [ + { + "type": "table", + "headers": ["Auditor", "Date"], + "rows": [ + ["ChainSecurity", "Q4-2024"], + ["DeDaub", "Q4-2024"], + ["Coinspect", "Q4-2024"], + ["Recon", "Q4-2024"], + ["Cantina", "Q4-2024"] + ] + } + ] + }, + { + "title": "Source Code", + "content": [ + { + "type": "link", + "text": "GitHub Repository", + "href": "https://github.com/liquity/bold", + "external": true + } + ] + } + ] + } + } +} diff --git a/src/content/review-configs/steakhouse-usdc.json b/src/content/review-configs/steakhouse-usdc.json new file mode 100644 index 000000000..ba7abe857 --- /dev/null +++ b/src/content/review-configs/steakhouse-usdc.json @@ -0,0 +1,357 @@ +{ + "protocolSlug": "steakhouse-usdc", + "protocolName": "Morpho", + "tokenName": "Steakhouse USDC", + "chain": "Ethereum", + "address": "0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB", + "reviewType": "vault", + "sections": { + "strategy": { + "title": "Vault", + "description": "The Steakhouse USDC vault lends USDC on Morpho Blue markets against different wrapped versions of ETH and BTC.", + "subsections": [ + { + "title": "Market Allocation", + "content": [ + { + "type": "text", + "content": "Total allocation: {{totalAllocation}} across 4 active Morpho Blue lending markets. All markets use an 86% LLTV." + }, + { + "type": "table", + "headers": ["Market", "Allocation", "Share", "Supply Cap", "APY"], + "rows": [ + ["cbBTC/USDC", "{{cbbtcAllocation}}", "{{cbbtcAllocationPct}}", "{{cbbtcSupplyCap}}", "{{cbbtcApy}}"], + ["WBTC/USDC", "{{wbtcAllocation}}", "{{wbtcAllocationPct}}", "{{wbtcSupplyCap}}", "{{wbtcApy}}"], + ["wstETH/USDC", "{{wstethAllocation}}", "{{wstethAllocationPct}}", "{{wstethSupplyCap}}", "{{wstethApy}}"], + ["WETH/USDC", "{{wethAllocation}}", "{{wethAllocationPct}}", "{{wethSupplyCap}}", "{{wethApy}}"] + ], + "colorScale": { + "columns": [1], + "referenceMetric": "totalAllocationRaw", + "valueMetrics": [ + ["cbbtcAllocationRaw"], + ["wbtcAllocationRaw"], + ["wstethAllocationRaw"], + ["wethAllocationRaw"] + ] + } + } + ] + }, + { + "title": "Supply Caps", + "content": [ + { + "type": "text", + "content": "Supply caps are set per market by the curator and enforced on-chain by the MetaMorpho vault contract. They limit the maximum amount the vault can allocate to each market." + } + ] + } + ] + }, + "permissions": { + "title": "Admin Permissions", + "description": "Role-based access control on the MetaMorpho vault. Sensitive changes are subject to a timelock delay, during which the guardian can veto.", + "subsections": [ + { + "title": "Owner", + "badge": "7 Day Delay", + "badgeVariant": "timelocked", + "subtitle": "{{owner}} · TVS {{ownerTvs}}", + "content": [ + { + "type": "text", + "content": "Controls core governance parameters including curator and guardian selection, fee configuration, and timelock duration. Most owner actions are subject to a 7-day timelock." + }, + { + "type": "dropdown", + "label": "Functions", + "content": [ + { + "type": "table", + "headers": ["Function", "Restriction"], + "rows": [ + ["setCurator()", "7 Day Delay"], + ["setGuardian()", "7 Day Delay"], + ["submitTimelock()", "7 Day Delay"], + ["setFee()", "7 Day Delay"], + ["setFeeRecipient()", "No Delay"] + ], + "badgeColumns": [ + { + "column": 1, + "colorMap": { + "7 Day Delay": "timelocked", + "No Delay": "direct" + } + } + ] + } + ] + } + ] + }, + { + "title": "Curator", + "badge": "7 Day Delay", + "badgeVariant": "timelocked", + "subtitle": "{{curator}} · TVS {{curatorTvs}}", + "content": [ + { + "type": "text", + "content": "Manages market selection and supply caps for the vault. Can propose adding new markets or adjusting existing caps, subject to the timelock." + }, + { + "type": "dropdown", + "label": "Functions", + "content": [ + { + "type": "table", + "headers": ["Function", "Restriction"], + "rows": [ + ["submitCap()", "7 Day Delay"], + ["submitMarketRemoval()", "7 Day Delay"] + ], + "badgeColumns": [ + { + "column": 1, + "colorMap": { + "7 Day Delay": "timelocked", + "No Delay": "direct" + } + } + ] + } + ] + } + ] + }, + { + "title": "Allocator", + "badge": "No Delay", + "badgeVariant": "direct", + "subtitle": "{{allocator}} · TVS {{allocatorTvs}}", + "content": [ + { + "type": "text", + "content": "Reallocates funds between approved markets in real-time. Cannot add new markets or change supply caps — only redistribute within existing limits." + }, + { + "type": "dropdown", + "label": "Functions", + "content": [ + { + "type": "table", + "headers": ["Function", "Restriction"], + "rows": [ + ["reallocate()", "No Delay"] + ], + "badgeColumns": [ + { + "column": 1, + "colorMap": { + "7 Day Delay": "timelocked", + "No Delay": "direct" + } + } + ] + } + ] + } + ] + }, + { + "title": "Guardian", + "badge": "No Delay", + "badgeVariant": "direct", + "subtitle": "{{guardian}} · TVS {{guardianTvs}}", + "content": [ + { + "type": "text", + "content": "Can veto any pending timelock action, providing a safety check against malicious or unwanted governance changes. Acts as an emergency brake." + }, + { + "type": "dropdown", + "label": "Functions", + "content": [ + { + "type": "table", + "headers": ["Function", "Restriction"], + "rows": [ + ["revokePendingCap()", "No Delay"], + ["revokePendingTimelock()", "No Delay"], + ["revokePendingGuardian()", "No Delay"] + ], + "badgeColumns": [ + { + "column": 1, + "colorMap": { + "7 Day Delay": "timelocked", + "No Delay": "direct" + } + } + ] + } + ] + } + ] + } + ] + }, + "dependencies": { + "title": "Dependencies", + "subsections": [ + { + "title": "Oracle", + "content": [ + { + "type": "text", + "content": "Each Morpho Blue market uses its own oracle instance for collateral price feeds. The vault does not directly depend on oracles, but the underlying markets do." + }, + { + "type": "table", + "headers": ["Market", "Oracle Type", "Allocation"], + "rows": [ + ["cbBTC/USDC", "Chainlink", "{{cbbtcAllocation}}"], + ["WBTC/USDC", "Chainlink", "{{wbtcAllocation}}"], + ["wstETH/USDC", "Chainlink + Exchange Rate", "{{wstethAllocation}}"], + ["WETH/USDC", "Chainlink", "{{wethAllocation}}"] + ] + } + ] + }, + { + "title": "Token Dependencies", + "content": [ + { + "type": "text", + "content": "The vault depends on USDC as the loan asset and accepts wrapped assets as collateral. Each token introduces a dependency on the issuer or protocol responsible for it." + }, + { + "type": "table", + "headers": ["Token", "Issuer", "Type", "Allocation"], + "rows": [ + ["cbBTC", "Coinbase", "Centralized custodian", "{{cbbtcAllocation}}"], + ["WBTC", "BitGo", "Centralized custodian", "{{wbtcAllocation}}"], + ["wstETH", "Lido", "Liquid staking protocol", "{{wstethAllocation}}"], + ["WETH", "Canonical", "Wrapped ETH", "{{wethAllocation}}"], + ["USDC", "Circle", "Centralized stablecoin", "{{totalAllocation}}"] + ], + "badgeColumns": [ + { + "column": 2, + "colorMap": { + "Centralized custodian": "centralized", + "Centralized stablecoin": "centralized", + "Liquid staking protocol": "staking", + "Wrapped ETH": "canonical" + } + } + ] + } + ] + }, + { + "title": "Morpho Protocol", + "content": [ + { + "type": "text", + "content": "The vault depends on the Morpho Blue singleton contract for all lending operations. Morpho Blue is immutable and permissionless." + } + ] + } + ] + }, + "codeAndAudits": { + "title": "Contracts & Audits", + "subsections": [ + { + "title": "Vault Contract", + "content": [ + { + "type": "text", + "content": "Vault address: {{vaultAddress}}. The MetaMorpho vault contract is immutable (non-upgradeable). Governance parameters (curator, allocator, guardian, timelock) are configurable via the owner role, subject to timelock." + } + ] + }, + { + "title": "Contracts", + "content": [ + { + "type": "expandableTable", + "headers": ["Name", "Address", "Tags"], + "rows": [ + { + "cells": ["MetaMorpho Vault", "0xBEEF0173...", "Immutable"], + "expandedContent": { + "functions": [ + { + "name": "setCurator()", + "callers": ["Owner"] + }, + { + "name": "setGuardian()", + "callers": ["Owner"] + }, + { + "name": "submitCap()", + "callers": ["Curator", "Owner"] + }, + { + "name": "reallocate()", + "callers": ["Allocator", "Owner"] + } + ] + } + }, + { + "cells": ["Morpho Blue", "0xBBBBBBbb...", "Immutable"] + } + ], + "badgeColumns": [ + { + "column": 2, + "colorMap": { + "Immutable": "immutable", + "Upgradeable": "upgradeable" + } + } + ] + } + ] + }, + { + "title": "Audits", + "content": [ + { + "type": "table", + "headers": ["Auditor", "Scope", "Date"], + "rows": [ + ["Spearbit", "MetaMorpho", "Q1-2024"], + ["Cantina", "Morpho Blue", "Q4-2023"] + ] + } + ] + }, + { + "title": "Source Code", + "content": [ + { + "type": "link", + "text": "MetaMorpho GitHub", + "href": "https://github.com/morpho-org/metamorpho", + "external": true + }, + { + "type": "link", + "text": "Morpho Blue GitHub", + "href": "https://github.com/morpho-org/morpho-blue", + "external": true + } + ] + } + ] + } + } +} diff --git a/src/lib/review-page/types.ts b/src/lib/review-page/types.ts new file mode 100644 index 000000000..aa9dd3555 --- /dev/null +++ b/src/lib/review-page/types.ts @@ -0,0 +1,173 @@ +// ============================================================ +// API DATA TYPES (returned by the mock service) +// ============================================================ + +export interface CollateralSlice { + name: string; + value: number; + color: string; + category: string; +} + +export interface CollateralData { + totalValue: number; + categories: { + name: string; + value: number; + color: string; + }[]; + assets: CollateralSlice[]; +} + +export interface MarketSlice { + name: string; + allocation: number; + supplyCap: number; + apy: number; + color: string; + lltv: string; +} + +export interface VaultData { + totalAllocation: number; + markets: MarketSlice[]; +} + +export interface ReviewApiData { + protocolSlug: string; + tokenName: string; + tokenSymbol: string; + collaterals?: CollateralData; + vault?: VaultData; + data: Record; +} + +// ============================================================ +// REVIEWER CONFIG TYPES (authored as JSON per protocol) +// ============================================================ + +export interface TextBlock { + type: "text"; + /** Supports {{dataKey}} interpolation from API data */ + content: string; +} + +export interface TableColorScale { + /** Column indices (0-based) to apply coloring to */ + columns: number[]; + /** Data key for the reference value (denominator), must be a number */ + referenceMetric: string; + /** Per-row data keys for raw numeric values of the colored columns */ + valueMetrics: string[][]; +} + +export interface TableBadgeColumn { + /** Column index (0-based) to render as badges */ + column: number; + /** Map cell text to a Tailwind color class, e.g. { "Immutable": "bg-green-600" } */ + colorMap?: Record; +} + +export interface TableBlock { + type: "table"; + headers: string[]; + /** Each row is an array of strings; supports {{dataKey}} interpolation */ + rows: string[][]; + /** Optional color scale: colors cells green/orange/red based on value share */ + colorScale?: TableColorScale; + /** Columns to render as styled badges */ + badgeColumns?: TableBadgeColumn[]; +} + +export interface PermissionedFunction { + name: string; + callers: string[]; +} + +export interface ExpandableTableRow { + cells: string[]; + expandedContent?: { + functions: PermissionedFunction[]; + }; +} + +export interface ExpandableTableBlock { + type: "expandableTable"; + headers: string[]; + rows: ExpandableTableRow[]; + badgeColumns?: TableBadgeColumn[]; + colorScale?: TableColorScale; + /** Callers that have external dependencies (e.g., oracle) */ + externalCallers?: string[]; +} + +export interface DropdownBlock { + type: "dropdown"; + label: string; + content: ContentBlock[]; +} + +export interface LinkBlock { + type: "link"; + text: string; + href: string; + external?: boolean; +} + +export interface MetricBlock { + type: "metric"; + label: string; + dataKey: string; + format?: "usd" | "percent" | "number" | "string"; +} + +export type ContentBlock = + | TextBlock + | TableBlock + | ExpandableTableBlock + | DropdownBlock + | LinkBlock + | MetricBlock; + +export interface SubSection { + title: string; + badge?: string; + badgeVariant?: string; + /** Supports {{dataKey}} interpolation; shown below the title */ + subtitle?: string; + content: ContentBlock[]; +} + +export interface ReviewSectionConfig { + title: string; + description?: string; + subsections: SubSection[]; +} + +export interface StablecoinSections { + collaterals: ReviewSectionConfig; + dependencies: ReviewSectionConfig; + actors: ReviewSectionConfig; + codeAndAudits: ReviewSectionConfig; +} + +export interface VaultSections { + strategy: ReviewSectionConfig; + permissions: ReviewSectionConfig; + dependencies: ReviewSectionConfig; + codeAndAudits: ReviewSectionConfig; +} + +export interface ReviewConfig { + protocolSlug: string; + protocolName: string; + tokenName: string; + chain: string; + address?: string; + reviewType?: "stablecoin" | "vault"; + sections: StablecoinSections | VaultSections; +} + +export function isVaultReview(config: ReviewConfig): boolean { + return config.reviewType === "vault"; +} diff --git a/src/services/review-data.ts b/src/services/review-data.ts new file mode 100644 index 000000000..7a81bb868 --- /dev/null +++ b/src/services/review-data.ts @@ -0,0 +1,171 @@ +import { ReviewApiData, CollateralData } from "@/lib/review-page/types"; + +const MOCK_DATA: Record = { + "liquity-v2": { + protocolSlug: "liquity-v2", + tokenName: "BOLD", + tokenSymbol: "BOLD", + collaterals: { + totalValue: 38_100_000, + categories: [ + { name: "Native ETH", value: 8_700_000, color: "#627EEA" }, + { name: "WstETH", value: 24_000_000, color: "#00A3FF" }, + { name: "rETH", value: 5_400_000, color: "#E8663D" }, + ], + assets: [ + { + name: "ETH", + value: 8_700_000, + color: "url(#metallic-eth)", + category: "Native ETH", + }, + { + name: "WstETH", + value: 24_000_000, + color: "url(#metallic-wsteth)", + category: "WstETH", + }, + { + name: "rETH", + value: 5_400_000, + color: "url(#metallic-reth)", + category: "rETH", + }, + ], + }, + data: { + totalCollateralValue: "$38.1M", + ethCollateralValue: "$8.7M", + ethCollateralPct: "22.8%", + wstethCollateralValue: "$24M", + wstethCollateralPct: "63.0%", + rethCollateralValue: "$5.4M", + rethCollateralPct: "14.2%", + totalCollateralValueRaw: 38_100_000, + ethCollateralValueRaw: 8_700_000, + wstethCollateralValueRaw: 24_000_000, + rethCollateralValueRaw: 5_400_000, + stabilityPoolSize: "$25M", + totalTroves: "3,241", + }, + }, + "steakhouse-usdc": { + protocolSlug: "steakhouse-usdc", + tokenName: "Steakhouse USDC", + tokenSymbol: "steakUSDC", + vault: { + totalAllocation: 325_860_000, + markets: [ + { + name: "cbBTC/USDC", + allocation: 223_070_000, + supplyCap: 999_910_000, + apy: 3.13, + color: "url(#metallic-cbbtc)", + lltv: "86%", + }, + { + name: "WBTC/USDC", + allocation: 80_830_000, + supplyCap: 199_980_000, + apy: 3.1, + color: "url(#metallic-wbtc)", + lltv: "86%", + }, + { + name: "wstETH/USDC", + allocation: 21_960_000, + supplyCap: 199_980_000, + apy: 3.42, + color: "url(#metallic-wsteth-vault)", + lltv: "86%", + }, + { + name: "WETH/USDC", + allocation: 8_150_000, + supplyCap: 199_980_000, + apy: 2.88, + color: "url(#metallic-weth)", + lltv: "86%", + }, + ], + }, + data: { + totalAllocation: "$325.9M", + cbbtcAllocation: "$223.07M", + cbbtcAllocationPct: "68.5%", + cbbtcSupplyCap: "$999.91M", + cbbtcApy: "3.13%", + wbtcAllocation: "$80.83M", + wbtcAllocationPct: "24.8%", + wbtcSupplyCap: "$199.98M", + wbtcApy: "3.10%", + wstethAllocation: "$21.96M", + wstethAllocationPct: "6.7%", + wstethSupplyCap: "$199.98M", + wstethApy: "3.42%", + wethAllocation: "$8.15M", + wethAllocationPct: "2.5%", + wethSupplyCap: "$199.98M", + wethApy: "2.88%", + totalAllocationRaw: 325_860_000, + cbbtcAllocationRaw: 223_070_000, + wbtcAllocationRaw: 80_830_000, + wstethAllocationRaw: 21_960_000, + wethAllocationRaw: 8_150_000, + vaultAddress: "0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB", + owner: "Steakhouse Financial (5-of-8 multisig)", + curator: "Steakhouse Financial (2-of-5 multisig)", + allocator: "Steakhouse (1 EOA)", + guardian: "Aragon DAO", + ownerTvs: "$325.9M", + curatorTvs: "$325.9M", + allocatorTvs: "$325.9M", + guardianTvs: "$325.9M", + timelock: "7-day", + }, + }, +}; + +class ReviewDataService { + private static instance: ReviewDataService; + private cache: Map = + new Map(); + private CACHE_DURATION = 5 * 60 * 1000; + + private constructor() {} + + public static getInstance(): ReviewDataService { + if (!ReviewDataService.instance) { + ReviewDataService.instance = new ReviewDataService(); + } + return ReviewDataService.instance; + } + + public async getReviewData(slug: string): Promise { + const cached = this.cache.get(slug); + const now = Date.now(); + + if (cached && now - cached.timestamp < this.CACHE_DURATION) { + return cached.data; + } + + // TODO: Replace with real API call + const data = MOCK_DATA[slug] ?? null; + + if (data) { + this.cache.set(slug, { data, timestamp: now }); + } + + return data; + } + + public async getCollateralData( + slug: string + ): Promise { + const data = await this.getReviewData(slug); + return data?.collaterals ?? null; + } +} + +export const reviewDataService = ReviewDataService.getInstance();