diff --git a/frontend/app/src/app/troves/page.tsx b/frontend/app/src/app/troves/page.tsx new file mode 100644 index 000000000..3389de861 --- /dev/null +++ b/frontend/app/src/app/troves/page.tsx @@ -0,0 +1,5 @@ +import { TroveExplorerScreen } from "@/src/screens/TroveExplorerScreen/TroveExplorerScreen"; + +export default function TrovesPage() { + return ; +} diff --git a/frontend/app/src/comps/TopBar/Menu.tsx b/frontend/app/src/comps/TopBar/Menu.tsx index f10e563bf..96138f247 100644 --- a/frontend/app/src/comps/TopBar/Menu.tsx +++ b/frontend/app/src/comps/TopBar/Menu.tsx @@ -14,6 +14,7 @@ export type MenuItemType = | 'buy' | 'stream' | 'ecosystem' + | 'troves' // | 'stake'; export type HrefTarget = '_self' | '_blank'; diff --git a/frontend/app/src/comps/TopBar/TopBar.tsx b/frontend/app/src/comps/TopBar/TopBar.tsx index 987c59f3e..3eb89d20b 100644 --- a/frontend/app/src/comps/TopBar/TopBar.tsx +++ b/frontend/app/src/comps/TopBar/TopBar.tsx @@ -17,6 +17,7 @@ import { IconEarn, IconStake as IconStream, IconStake as IconEcosystem, + // IconSearch, // IconLeverage, // IconStake, } from "@liquity2/uikit"; @@ -32,6 +33,7 @@ const menuItems: ComponentProps["menuItems"] = [ [content.menu.dashboard, "/", IconDashboard, "dashboard", "_self"], [content.menu.borrow, "/borrow", IconBorrow, "borrow", "_self"], // [content.menu.multiply, "/multiply", IconLeverage, "multiply"], + // [content.menu.troves, "/troves", IconSearch, "troves", "_self"], [content.menu.earn, "/earn", IconEarn, "earn", "_self"], [content.menu.ecosystem, "/ecosystem", IconEcosystem, "ecosystem", "_self"], [content.menu.stream, "https://app.superfluid.org/", IconStream, "stream", "_blank"], diff --git a/frontend/app/src/content.tsx b/frontend/app/src/content.tsx index b15f6092a..b3f3a12ec 100644 --- a/frontend/app/src/content.tsx +++ b/frontend/app/src/content.tsx @@ -12,6 +12,7 @@ export default { dashboard: "Dashboard", borrow: "Borrow", multiply: "Multiply", + troves: "Troves", earn: "Earn", stake: "Stake", buy: "Buy USND", diff --git a/frontend/app/src/screens/TroveExplorerScreen/TroveExplorerScreen.tsx b/frontend/app/src/screens/TroveExplorerScreen/TroveExplorerScreen.tsx new file mode 100644 index 000000000..dd9c6ade1 --- /dev/null +++ b/frontend/app/src/screens/TroveExplorerScreen/TroveExplorerScreen.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { Screen } from "@/src/comps/Screen/Screen"; +import { useAllActiveTroves } from "@/src/subgraph-hooks"; +import { css } from "@/styled-system/css"; +import { useState } from "react"; +import { TroveTable } from "./TroveTable"; + +// Subgraph-sortable fields vs client-side computed fields +const SUBGRAPH_SORTABLE = new Set(["debt", "deposit", "interestRate"]); + +export function TroveExplorerScreen() { + const [orderBy, setOrderBy] = useState("debt"); + const [orderDirection, setOrderDirection] = useState<"asc" | "desc">("desc"); + const [currentPage, setCurrentPage] = useState(0); + const pageSize = 500; // fetch all troves (~222 total) + + // For subgraph-sortable fields, pass sort params to the query + // For computed fields (collateralValue, liqPrice, ltv), fetch all and sort client-side + const isSubgraphSort = SUBGRAPH_SORTABLE.has(orderBy); + + const { data: troves, isLoading } = useAllActiveTroves( + pageSize, + isSubgraphSort ? currentPage * pageSize : 0, + isSubgraphSort ? orderBy : "debt", + isSubgraphSort ? orderDirection : "desc", + ); + + const handleSort = (field: string) => { + if (orderBy === field) { + setOrderDirection(orderDirection === "asc" ? "desc" : "asc"); + } else { + setOrderBy(field); + setOrderDirection("desc"); + } + setCurrentPage(0); + }; + + const hasPrevPage = currentPage > 0; + const hasNextPage = isSubgraphSort && troves && troves.length === pageSize; + + return ( + +
+
+ +
+ + {(hasPrevPage || hasNextPage) && ( +
+ + + Page {currentPage + 1} + + +
+ )} +
+
+ ); +} diff --git a/frontend/app/src/screens/TroveExplorerScreen/TroveRow.tsx b/frontend/app/src/screens/TroveExplorerScreen/TroveRow.tsx new file mode 100644 index 000000000..15a6f0bd8 --- /dev/null +++ b/frontend/app/src/screens/TroveExplorerScreen/TroveRow.tsx @@ -0,0 +1,98 @@ +import type { TroveExplorerItem } from "@/src/types"; + +import { AddressLink } from "@/src/comps/AddressLink/AddressLink"; +import { Amount } from "@/src/comps/Amount/Amount"; +import { getLiquidationPrice, getLtv } from "@/src/liquity-math"; +import { usePrice } from "@/src/services/Prices"; +import { css } from "@/styled-system/css"; +import { TokenIcon } from "@liquity2/uikit"; +import * as dn from "dnum"; + +type Props = { + trove: TroveExplorerItem; +}; + +export function TroveRow({ trove }: Props) { + const collPrice = usePrice(trove.collateralSymbol); + + const collateralValue = collPrice.data + ? dn.mul(trove.deposit, collPrice.data) + : null; + + const liquidationPrice = getLiquidationPrice( + trove.deposit, + trove.borrowed, + Number(trove.minCollRatio) / 1e18, + ); + + const ltv = collPrice.data + ? getLtv(trove.deposit, trove.borrowed, collPrice.data) + : null; + + const statusLabel: Record = { + active: "Active", + closed: "Closed", + closedByOwner: "Closed", + liquidated: "Liquidated", + redeemed: "Redeemed", + }; + + const statusColor: Record = { + active: "#16a34a", + closed: "#6b7280", + closedByOwner: "#6b7280", + liquidated: "#dc2626", + redeemed: "#d97706", + }; + + return ( + + + + {statusLabel[trove.status] ?? trove.status} + + + +
+ + {trove.collateralSymbol} +
+ + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/frontend/app/src/screens/TroveExplorerScreen/TroveTable.tsx b/frontend/app/src/screens/TroveExplorerScreen/TroveTable.tsx new file mode 100644 index 000000000..30ccc4e8d --- /dev/null +++ b/frontend/app/src/screens/TroveExplorerScreen/TroveTable.tsx @@ -0,0 +1,188 @@ +import type { TroveExplorerItem } from "@/src/types"; + +import { getLiquidationPrice, getLtv } from "@/src/liquity-math"; +import { usePrice } from "@/src/services/Prices"; +import { css } from "@/styled-system/css"; +import * as dn from "dnum"; +import { useMemo } from "react"; +import { TroveRow } from "./TroveRow"; + +type Props = { + troves: TroveExplorerItem[]; + isLoading: boolean; + orderBy: string; + orderDirection: "asc" | "desc"; + onSort: (field: string) => void; +}; + +// Client-side sortable fields (computed from prices) +const CLIENT_SORT_FIELDS = new Set(["collateralValue", "liqPrice", "ltv"]); + +function useTrovePrices(troves: TroveExplorerItem[]) { + // Get unique collateral symbols + const symbols = useMemo( + () => [...new Set(troves.map((t) => t.collateralSymbol))], + [troves], + ); + + // Fetch prices for all collateral types + const priceQueries = symbols.map((s) => ({ + symbol: s, + // eslint-disable-next-line react-hooks/rules-of-hooks + query: usePrice(s), + })); + + const priceMap = new Map(); + for (const { symbol, query } of priceQueries) { + priceMap.set(symbol, query.data ?? null); + } + + return priceMap; +} + +export function TroveTable({ + troves, + isLoading, + orderBy, + orderDirection, + onSort, +}: Props) { + const priceMap = useTrovePrices(troves); + + // Client-side sort for computed fields + const sortedTroves = useMemo(() => { + if (!CLIENT_SORT_FIELDS.has(orderBy)) return troves; + + return [...troves].sort((a, b) => { + const priceA = priceMap.get(a.collateralSymbol); + const priceB = priceMap.get(b.collateralSymbol); + + let valA: number | null = null; + let valB: number | null = null; + + if (orderBy === "collateralValue") { + valA = priceA ? Number(dn.format(dn.mul(a.deposit, priceA))) : null; + valB = priceB ? Number(dn.format(dn.mul(b.deposit, priceB))) : null; + } else if (orderBy === "liqPrice") { + const liqA = getLiquidationPrice(a.deposit, a.borrowed, Number(a.minCollRatio) / 1e18); + const liqB = getLiquidationPrice(b.deposit, b.borrowed, Number(b.minCollRatio) / 1e18); + valA = liqA ? Number(dn.format(liqA)) : null; + valB = liqB ? Number(dn.format(liqB)) : null; + } else if (orderBy === "ltv") { + const ltvA = priceA ? getLtv(a.deposit, a.borrowed, priceA) : null; + const ltvB = priceB ? getLtv(b.deposit, b.borrowed, priceB) : null; + valA = ltvA ? Number(dn.format(ltvA)) : null; + valB = ltvB ? Number(dn.format(ltvB)) : null; + } + + // Nulls sort to end + if (valA === null && valB === null) return 0; + if (valA === null) return 1; + if (valB === null) return -1; + + return orderDirection === "asc" ? valA - valB : valB - valA; + }); + }, [troves, orderBy, orderDirection, priceMap]); + + const SortableHeader = ({ field, label }: { field: string; label: string }) => ( + onSort(field)} + className={css({ + cursor: "pointer", + userSelect: "none", + _hover: { + color: "content", + }, + })} + > + {label} + {orderBy === field && (orderDirection === "asc" ? " ↑" : " ↓")} + + ); + + if (isLoading) { + return ( +
+ Loading troves... +
+ ); + } + + if (sortedTroves.length === 0) { + return ( +
+ No active troves found +
+ ); + } + + return ( + + + + + + + + + + + + + + + {sortedTroves.map((trove) => ( + + ))} + +
Owner
+ ); +} diff --git a/frontend/app/src/subgraph-hooks.ts b/frontend/app/src/subgraph-hooks.ts index 0519c8222..9ba549ed2 100644 --- a/frontend/app/src/subgraph-hooks.ts +++ b/frontend/app/src/subgraph-hooks.ts @@ -2,7 +2,18 @@ import type { InterestBatchQuery as InterestBatchQueryType, TrovesByAccountQuery as TrovesByAccountQueryType, } from "@/src/graphql/graphql"; -import type { Address, CollIndex, Delegate, PositionEarn, PositionLoanCommitted, PrefixedTroveId, ReturnCombinedTroveReadCallData, ReturnTroveReadCallData } from "@/src/types"; +import type { + Address, + CollateralSymbol, + CollIndex, + Delegate, + PositionEarn, + PositionLoanCommitted, + PrefixedTroveId, + ReturnCombinedTroveReadCallData, + ReturnTroveReadCallData, + TroveExplorerItem, +} from "@/src/types"; import { DATA_REFRESH_INTERVAL } from "@/src/constants"; import { ACCOUNT_POSITIONS } from "@/src/demo-mode"; @@ -636,6 +647,122 @@ export function useTroveCount(options?: Options) { }); } +export function useAllActiveTroves( + pageSize: number, + skip: number, + orderBy: string, + orderDirection: "asc" | "desc", + options?: Options, +) { + const fieldMap: Record = { + debt: "debt", + deposit: "deposit", + interestRate: "interestRate", + }; + const subgraphOrderBy = fieldMap[orderBy] ?? "debt"; + + let queryFn = async (): Promise => { + const query = ` + query AllActiveTroves($first: Int!, $skip: Int!) { + troves( + where: { debt_gt: "0" } + first: $first + skip: $skip + orderBy: ${subgraphOrderBy} + orderDirection: ${orderDirection} + ) { + id + borrower + createdAt + debt + deposit + interestRate + status + troveId + updatedAt + collateral { + collIndex + minCollRatio + token { + symbol + name + } + } + interestBatch { + annualInterestRate + } + } + } + `; + + const response = await fetch(SUBGRAPH_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/graphql-response+json", + }, + body: JSON.stringify({ + query, + variables: { + first: pageSize, + skip, + }, + }), + }); + + if (!response.ok) { + throw new Error("Error while fetching active troves from the subgraph"); + } + + const result = await response.json(); + if (!result.data) { + throw new Error("Invalid response from the subgraph"); + } + + return result.data.troves.map((trove: { + id: string; + borrower: string; + createdAt: string; + debt: string; + deposit: string; + interestRate: string; + status: string; + troveId: string; + updatedAt: string; + collateral: { + collIndex: number; + minCollRatio: string; + token: { symbol: string; name: string }; + }; + interestBatch: { annualInterestRate: string } | null; + }): TroveExplorerItem => ({ + id: trove.id, + troveId: trove.troveId as TroveExplorerItem["troveId"], + borrower: trove.borrower as Address, + collateralSymbol: getContracts().collaterals[trove.collateral.collIndex]?.symbol ?? trove.collateral.token.symbol as CollateralSymbol, + collateralName: trove.collateral.token.name, + collIndex: trove.collateral.collIndex as CollIndex, + borrowed: dnum18(BigInt(trove.debt)), + deposit: dnum18(BigInt(trove.deposit)), + minCollRatio: BigInt(trove.collateral.minCollRatio), + interestRate: dnum18(BigInt(trove.interestBatch?.annualInterestRate ?? trove.interestRate)), + status: trove.status, + updatedAt: Number(trove.updatedAt) * 1000, + createdAt: Number(trove.createdAt) * 1000, + })); + }; + + if (DEMO_MODE) { + queryFn = async () => []; + } + + return useQuery({ + queryKey: ["AllActiveTroves", pageSize, skip, orderBy, orderDirection], + queryFn, + ...prepareOptions(options), + }); +} + function subgraphTroveToLoan( trove: TrovesByAccountQueryType["troves"][number], ): PositionLoanCommitted { diff --git a/frontend/app/src/subgraph-queries.ts b/frontend/app/src/subgraph-queries.ts index b0be1b40d..8dfc7c425 100644 --- a/frontend/app/src/subgraph-queries.ts +++ b/frontend/app/src/subgraph-queries.ts @@ -373,4 +373,4 @@ export const GovernanceUserAllocations = graphql(` allocated } } -`); \ No newline at end of file +`); diff --git a/frontend/app/src/types.ts b/frontend/app/src/types.ts index 18791be0c..cb72b777b 100644 --- a/frontend/app/src/types.ts +++ b/frontend/app/src/types.ts @@ -175,6 +175,21 @@ export type Vote = "for" | "against"; export type VoteAllocation = { vote: Vote | null; value: Dnum }; export type VoteAllocations = Record; +export type TroveExplorerItem = { + id: string; + troveId: TroveId; + borrower: Address; + collateralSymbol: CollateralSymbol; + collateralName: string; + collIndex: CollIndex; + borrowed: Dnum; + deposit: Dnum; + minCollRatio: bigint; + interestRate: Dnum; + status: string; + updatedAt: number; + createdAt: number; +}; export interface CombinedTroveData { id: bigint; entireDebt: bigint; @@ -278,4 +293,4 @@ export interface ReturnTroveReadCallData extends Trove { annualInterestRate: bigint; batchManager: Address; } -} \ No newline at end of file +}