diff --git a/contracts/utils/deployment-manifest-to-app-env.ts b/contracts/utils/deployment-manifest-to-app-env.ts index ed431ddce..2f5f3ce45 100644 --- a/contracts/utils/deployment-manifest-to-app-env.ts +++ b/contracts/utils/deployment-manifest-to-app-env.ts @@ -39,6 +39,7 @@ const ZDeploymentManifest = z.object({ debtInFrontHelper: ZAddress, exchangeHelpers: ZAddress, exchangeHelpersV2: ZAddress, + redemptionHelper: ZAddress, governance: z.object({ LUSDToken: ZAddress, @@ -189,6 +190,8 @@ function contractNameToAppEnvVariable(contractName: string, prefix: string = "") return `${prefix}_EXCHANGE_HELPERS`; case "exchangeHelpersV2": return `${prefix}_EXCHANGE_HELPERS_V2`; + case "redemptionHelper": + return `${prefix}_REDEMPTION_HELPER`; // collateral contracts case "activePool": diff --git a/frontend/app/src/app/redeem/shutdown/page.tsx b/frontend/app/src/app/redeem/shutdown/page.tsx new file mode 100644 index 000000000..402a49e63 --- /dev/null +++ b/frontend/app/src/app/redeem/shutdown/page.tsx @@ -0,0 +1,5 @@ +import { UrgentRedeemScreen } from "@/src/screens/UrgentRedeemScreen/UrgentRedeemScreen"; + +export default function Page() { + return ; +} diff --git a/frontend/app/src/comps/AppLayout/AppLayout.tsx b/frontend/app/src/comps/AppLayout/AppLayout.tsx index 75975b827..0126b97ca 100644 --- a/frontend/app/src/comps/AppLayout/AppLayout.tsx +++ b/frontend/app/src/comps/AppLayout/AppLayout.tsx @@ -5,6 +5,7 @@ import type { ReactNode } from "react"; import { Banner } from "@/Banner"; import { LegacyPositionsBanner } from "@/src/comps/LegacyPositionsBanner/LegacyPositionsBanner"; import { SafetyModeBanner } from "@/src/comps/SafetyModeBanner/SafetyModeBanner"; +import { ShutdownModeBanner } from "@/src/comps/ShutdownModeBanner/ShutdownModeBanner"; import { SubgraphDownBanner } from "@/src/comps/SubgraphDownBanner/SubgraphDownBanner"; import { V1StabilityPoolBanner } from "@/src/comps/V1StabilityPoolBanner/V1StabilityPoolBanner"; import { V1StakingBanner } from "@/src/comps/V1StakingBanner/V1StakingBanner"; @@ -45,6 +46,7 @@ export function AppLayout({ {LEGACY_CHECK && } {SUBGRAPH_CHECK && } {SAFETY_MODE_CHECK && } + {SAFETY_MODE_CHECK && }
+ {props.title && ( +
+

{props.title}

+
+ )} + + {props.children} + + ); +} diff --git a/frontend/app/src/comps/SafetyModeBanner/SafetyModeBanner.tsx b/frontend/app/src/comps/SafetyModeBanner/SafetyModeBanner.tsx index 20141e96c..537fdd4d5 100644 --- a/frontend/app/src/comps/SafetyModeBanner/SafetyModeBanner.tsx +++ b/frontend/app/src/comps/SafetyModeBanner/SafetyModeBanner.tsx @@ -1,19 +1,26 @@ "use client"; import { InfoBanner } from "@/src/comps/InfoBanner/InfoBanner"; -import { useSafetyMode } from "@/src/liquity-utils"; +import { useSafetyMode, useShutdownStatus } from "@/src/liquity-utils"; import { token } from "@/styled-system/tokens"; import { IconWarning } from "@liquity2/uikit"; export function SafetyModeBanner() { const safetyMode = useSafetyMode(); + const shutdownStatus = useShutdownStatus(); + + const shutdownBranchIds = new Set( + shutdownStatus.data?.filter((b) => b.isShutdown).map((b) => b.branchId) ?? [], + ); + + const branchesInSafetyMode = (safetyMode.data?.branchesInSafetyMode ?? []) + .filter((b) => !shutdownBranchIds.has(b.branchId)); - const branchesInSafetyMode = safetyMode.data?.branchesInSafetyMode ?? []; const branchNames = branchesInSafetyMode.map((b) => b.symbol).join(", "); return ( 0} icon={} messageDesktop={ <> diff --git a/frontend/app/src/comps/ShutdownModeBanner/ShutdownModeBanner.tsx b/frontend/app/src/comps/ShutdownModeBanner/ShutdownModeBanner.tsx new file mode 100644 index 000000000..e63692318 --- /dev/null +++ b/frontend/app/src/comps/ShutdownModeBanner/ShutdownModeBanner.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { InfoBanner } from "@/src/comps/InfoBanner/InfoBanner"; +import { getBranch, useShutdownStatus } from "@/src/liquity-utils"; +import { token } from "@/styled-system/tokens"; +import { IconWarning } from "@liquity2/uikit"; + +export function ShutdownModeBanner() { + const shutdownStatus = useShutdownStatus(); + + const branchesInShutdown = shutdownStatus.data?.filter((b) => b.isShutdown) ?? []; + const branchNames = branchesInShutdown.map((b) => getBranch(b.branchId).symbol).join(", "); + + return ( + 0} + icon={} + messageDesktop={ + <> + The {branchNames} branch{branchesInShutdown.length > 1 ? "es are" : " is"} in Shutdown Mode. + You can only close positions or redeem BOLD. + + } + linkLabel="Learn more" + linkLabelMobile="Shutdown Mode active" + linkHref="https://docs.liquity.org/v2-faq/borrowing-and-liquidations#what-is-shutdown-mode" + linkExternal + backgroundColor={token("colors.brandGolden")} + foregroundColor={token("colors.brandGoldenContent")} + /> + ); +} diff --git a/frontend/app/src/content.tsx b/frontend/app/src/content.tsx index 206b74425..a182d1918 100644 --- a/frontend/app/src/content.tsx +++ b/frontend/app/src/content.tsx @@ -600,6 +600,97 @@ export default { ), }, + shutdownWarning: { + title: "Branch Shutdown", + borrowMessage: (collName: string) => ( + <> + The {collName} branch is in shutdown mode. New loans cannot be opened on this branch. + + ), + loanMessage: (collName: string) => ( + <> + The {collName} branch is in shutdown mode. Loan adjustments are not available. You can only close your loan. + + ), + }, + urgentRedeemScreen: { + headingTitle: "Shutdown Redemptions", + headingTitleActive: "Shutdown Redemption", + selectBranchLabel: "Select branch", + redeemFieldLabel: "You redeem", + insufficientBalance: (balance: string) => `Insufficient BOLD balance. You have ${balance} BOLD.`, + amountCapped: (amount: string) => `Capped to ${amount} BOLD (max amount redeemable).`, + youReceive: "You receive", + bonusLabel: (bonusPct: string) => `Including ${bonusPct} bonus`, + bonusTooltip: (bonusPct: string) => `Shutdown redemptions include a ${bonusPct} bonus on the collateral received.`, + slippageTolerance: "Slippage tolerance", + manualTrovesLabel: "Manually selected troves", + autoTrovesLabel: "Auto-selected troves", + useAutoSelection: "Use auto-selection", + manuallySelectTroves: "Manually select troves", + trovesCount: (count: number) => `${count} ${count === 1 ? "trove" : "troves"} will be used for this redemption.`, + action: "Redeem", + backLink: "Back", + successLink: "Go to the Dashboard", + successMessage: "The shutdown redemption was successful.", + noShutdown: { + title: "No Branches in Shutdown Mode", + body: ( + <> + Shutdown redemptions are only available when a branch is in shutdown mode. Currently, all branches are + operating normally. + + ), + link: "Go to standard redemptions", + }, + noTroves: { + title: "No Shutdown Redemptions Available", + body: "No troves are currently available for shutdown redemption in this branch.", + }, + troveTable: { + trovesSelected: (count: number) => `${count} ${count === 1 ? "trove" : "troves"} selected`, + totalDebt: "Total debt:", + totalDebtUnit: "BOLD", + totalColl: "Total coll:", + deselectAll: "Clear all", + selectAllOnPage: "Select all on page", + clearSelection: "Clear selection", + columnTroveId: "Trove ID", + columnCollateral: "Collateral", + columnDebt: "Debt", + columnIcr: "ICR", + noTrovesAvailable: "No troves available", + page: (current: number, total: number) => `Page ${current} of ${total}`, + previous: "Previous", + next: "Next", + }, + txFlow: { + title: "Review & Send Transaction", + youRedeemBold: "You redeem BOLD", + redeemTooltip: (bonusPct: string) => + `Shutdown redemptions have 0% fee and include a ${bonusPct} collateral bonus.`, + youReceiveToken: (tokenName: string) => `You receive ${tokenName}`, + receiveTooltip: (tokenName: string, bonusPct: string) => + `This is the estimated amount of ${tokenName} you will receive, including` + + ` the ${bonusPct} bonus. The actual amount may vary based on the selected troves.`, + trovesLabel: "Troves to redeem from", + trovesTooltip: ( + <> + The number of troves that will be used for this redemption. Shutdown redemptions are competitive - other users + may redeem from these troves before your transaction confirms. + + ), + trovesValue: (count: number) => `${count} ${count === 1 ? "trove" : "troves"}`, + slippageTooltip: (threshold: N) => ( + <> + If the actual collateral received is less than {threshold}{" "} + of the expected amount, the transaction will revert. + + ), + approveStep: "Approve BOLD", + redeemStep: "Execute Shutdown Redemption", + }, + }, dataSources: { title: "Data Sources", description: diff --git a/frontend/app/src/graphql/graphql.ts b/frontend/app/src/graphql/graphql.ts index 8161f419a..0fb9b3242 100644 --- a/frontend/app/src/graphql/graphql.ts +++ b/frontend/app/src/graphql/graphql.ts @@ -23,6 +23,14 @@ export type Scalars = { Timestamp: { input: string; output: string; } }; +/** Indicates whether the current, partially filled bucket should be included in the response. Defaults to `exclude` */ +export enum Aggregation_Current { + /** Exclude the current, partially filled bucket from the response */ + Exclude = 'exclude', + /** Include the current, partially filled bucket in the response */ + Include = 'include' +} + export enum Aggregation_Interval { Day = 'day', Hour = 'hour' @@ -54,10 +62,8 @@ export type BorrowerInfo_Filter = { and?: InputMaybe>>; collSurplusBalance?: InputMaybe>; collSurplusBalance_contains?: InputMaybe>; - collSurplusBalance_contains_nocase?: InputMaybe>; collSurplusBalance_not?: InputMaybe>; collSurplusBalance_not_contains?: InputMaybe>; - collSurplusBalance_not_contains_nocase?: InputMaybe>; id?: InputMaybe; id_gt?: InputMaybe; id_gte?: InputMaybe; @@ -68,24 +74,18 @@ export type BorrowerInfo_Filter = { id_not_in?: InputMaybe>; lastCollSurplusClaimAt?: InputMaybe>; lastCollSurplusClaimAt_contains?: InputMaybe>; - lastCollSurplusClaimAt_contains_nocase?: InputMaybe>; lastCollSurplusClaimAt_not?: InputMaybe>; lastCollSurplusClaimAt_not_contains?: InputMaybe>; - lastCollSurplusClaimAt_not_contains_nocase?: InputMaybe>; nextOwnerIndexes?: InputMaybe>; nextOwnerIndexes_contains?: InputMaybe>; - nextOwnerIndexes_contains_nocase?: InputMaybe>; nextOwnerIndexes_not?: InputMaybe>; nextOwnerIndexes_not_contains?: InputMaybe>; - nextOwnerIndexes_not_contains_nocase?: InputMaybe>; or?: InputMaybe>>; troves?: InputMaybe; trovesByCollateral?: InputMaybe>; trovesByCollateral_contains?: InputMaybe>; - trovesByCollateral_contains_nocase?: InputMaybe>; trovesByCollateral_not?: InputMaybe>; trovesByCollateral_not_contains?: InputMaybe>; - trovesByCollateral_not_contains_nocase?: InputMaybe>; troves_gt?: InputMaybe; troves_gte?: InputMaybe; troves_in?: InputMaybe>; diff --git a/frontend/app/src/liquity-utils.ts b/frontend/app/src/liquity-utils.ts index b33874520..b97468c83 100644 --- a/frontend/app/src/liquity-utils.ts +++ b/frontend/app/src/liquity-utils.ts @@ -1920,3 +1920,97 @@ export function useSafetyMode() { enabled: Boolean(allRatios.data), }); } + +export type ShutdownStatus = { + branchId: BranchId; + isShutdown: boolean; +}; + +export function useShutdownStatus() { + const branches = getBranches(); + + return useReadContracts({ + contracts: branches.map((branch) => ({ + ...branch.contracts.TroveManager, + functionName: "shutdownTime" as const, + })), + allowFailure: false, + query: { + refetchInterval: 12_000, + select: (results): ShutdownStatus[] => { + return results.map((shutdownTime, index) => { + const branch = branches[index]; + if (!branch) { + throw new Error(`Branch at index ${index} not found`); + } + return { + branchId: branch.branchId, + isShutdown: Number(shutdownTime) > 0, + }; + }); + }, + }, + }); +} + +export function useIsBranchShutdown(branchId: BranchId) { + const shutdownStatus = useShutdownStatus(); + return { + ...shutdownStatus, + data: shutdownStatus.data?.find((s) => s.branchId === branchId)?.isShutdown ?? false, + }; +} + +export type RedeemableTrove = { + id: string; + troveId: TroveId; + debt: Dnum; + coll: Dnum; + stake: Dnum; + interestRate: Dnum; +}; + +async function fetchRedeemableTroves( + wagmiConfig: WagmiConfig, + branchId: BranchId, + maxTroves: number, +): Promise { + const MultiTroveGetter = getProtocolContract("MultiTroveGetter"); + + const troves = await readContract(wagmiConfig, { + ...MultiTroveGetter, + functionName: "getMultipleSortedTroves", + args: [BigInt(branchId), -1n, BigInt(maxTroves)], + }); + + return troves + .filter((trove) => trove.entireDebt > 0n) + .map((trove) => ({ + id: getPrefixedTroveId(branchId, `0x${trove.id.toString(16)}` as TroveId), + troveId: `0x${trove.id.toString(16)}` as TroveId, + debt: dnum18(trove.entireDebt), + coll: dnum18(trove.entireColl), + stake: dnum18(trove.stake), + interestRate: dnum18(trove.annualInterestRate), + })); +} + +export function useRedeemableTroves( + branchId: BranchId | null, + options?: { first?: number }, +) { + const wagmiConfig = useWagmiConfig(); + const maxTroves = options?.first ?? 200; + + return useQuery({ + queryKey: ["redeemableTroves", branchId, maxTroves], + queryFn: async () => { + if (branchId === null) { + return []; + } + return fetchRedeemableTroves(wagmiConfig, branchId, maxTroves); + }, + enabled: branchId !== null, + refetchInterval: 12_000, + }); +} diff --git a/frontend/app/src/screens/BorrowScreen/BorrowScreen.tsx b/frontend/app/src/screens/BorrowScreen/BorrowScreen.tsx index a4deeb527..a7b731111 100644 --- a/frontend/app/src/screens/BorrowScreen/BorrowScreen.tsx +++ b/frontend/app/src/screens/BorrowScreen/BorrowScreen.tsx @@ -24,6 +24,7 @@ import { getCollToken, useBranchCollateralRatios, useBranchDebt, + useIsBranchShutdown, useNextOwnerIndex, usePredictOpenTroveUpfrontFee, useRedemptionRiskOfInterestRate, @@ -100,6 +101,7 @@ export function BorrowScreen() { const balances = useBalances(account.address, KNOWN_COLLATERAL_SYMBOLS); const collateralRatios = useBranchCollateralRatios(branch.id); + const isShutdown = useIsBranchShutdown(branch.id); const collBalance = balances[collateral.symbol]; if (!collBalance) { @@ -219,6 +221,7 @@ export function BorrowScreen() { const isDelegated = interestRateMode === "delegate" && interestRateDelegate; const allowSubmit = account.isConnected + && !isShutdown.data && deposit.parsed && dn.gt(deposit.parsed, 0) && debt.parsed @@ -520,7 +523,30 @@ export function BorrowScreen() { - {isCcrConditionsNotMet && collateralRatios.data + {isShutdown.data + ? ( + +
+
+ {content.shutdownWarning.title} +
+
+ {content.shutdownWarning.borrowMessage(collateral.name)} +
+
+
+ ) + : isCcrConditionsNotMet && collateralRatios.data ? (
diff --git a/frontend/app/src/screens/LoanScreen/LoanScreen.tsx b/frontend/app/src/screens/LoanScreen/LoanScreen.tsx index d28aaf8d6..62ed6b995 100644 --- a/frontend/app/src/screens/LoanScreen/LoanScreen.tsx +++ b/frontend/app/src/screens/LoanScreen/LoanScreen.tsx @@ -8,6 +8,7 @@ import { Field } from "@/src/comps/Field/Field"; import { FlowButton } from "@/src/comps/FlowButton/FlowButton"; import { LinkTextButton } from "@/src/comps/LinkTextButton/LinkTextButton"; import { Screen } from "@/src/comps/Screen/Screen"; +import { WarningBox } from "@/src/comps/WarningBox/WarningBox"; import content from "@/src/content"; import { TROVE_EXPLORER_0, TROVE_EXPLORER_1 } from "@/src/env"; import { fmtnum, formatDate } from "@/src/formatting"; @@ -16,6 +17,7 @@ import { getPrefixedTroveId, parsePrefixedTroveId, useCollateralSurplus, + useIsBranchShutdown, useLoan, } from "@/src/liquity-utils"; import { usePrice } from "@/src/services/Prices"; @@ -169,6 +171,7 @@ export function LoanScreen() { const { troveId, branchId } = parsePrefixedTroveId(paramPrefixedId); const loan = useLoan(branchId, troveId); + const isShutdown = useIsBranchShutdown(branchId); const loanMode = storedState.loanModes[paramPrefixedId] ?? loan.data?.type ?? "borrow"; useEffect(() => { @@ -177,6 +180,12 @@ export function LoanScreen() { } }, [loan.data?.troveId, loan.data?.branchId, paramPrefixedId]); + useEffect(() => { + if (isShutdown.data && action !== "close") { + router.push(`/loan/close?id=${paramPrefixedId}`, { scroll: false }); + } + }, [isShutdown.data, action, paramPrefixedId, router]); + const collToken = getCollToken(loan.data?.branchId ?? null); const collPriceUsd = usePrice(collToken?.symbol ?? null); @@ -391,39 +400,68 @@ export function LoanScreen() { )}
)} - ({ - label: compactMode ? labelCompact : label, - panelId: `p-${id}`, - tabId: `t-${id}`, - }))} - selected={TABS.findIndex(({ id }) => id === action)} - onSelect={(index) => { - if (!loan.data) { - return; - } - const tab = TABS[index]; - if (!tab) { - throw new Error("Invalid tab index"); - } - const id = getPrefixedTroveId( - loan.data.branchId, - loan.data.troveId, - ); - router.push( - `/loan/${tab.id}?id=${id}`, - { scroll: false }, - ); - }} - /> + {isShutdown.data && collToken && ( + +
+
+ {content.shutdownWarning.title} +
+
+ {content.shutdownWarning.loanMessage(collToken.name)} +
+
+
+ )} + + {!isShutdown.data && ( + ({ + label: compactMode ? labelCompact : label, + panelId: `p-${id}`, + tabId: `t-${id}`, + }))} + selected={TABS.findIndex(({ id }) => id === action)} + onSelect={(index) => { + if (!loan.data) { + return; + } + const tab = TABS[index]; + if (!tab) { + throw new Error("Invalid tab index"); + } + const id = getPrefixedTroveId( + loan.data.branchId, + loan.data.troveId, + ); + router.push( + `/loan/${tab.id}?id=${id}`, + { scroll: false }, + ); + }} + /> + )} - {action === "colldebt" && ( + {!isShutdown.data && action === "colldebt" && ( loanMode === "multiply" ? : )} - {action === "rate" && } - {action === "close" && } + {!isShutdown.data && action === "rate" && ( + + )} + {(isShutdown.data || action === "close") && ( + + )} )} diff --git a/frontend/app/src/screens/RedeemScreen/RedeemScreen.tsx b/frontend/app/src/screens/RedeemScreen/RedeemScreen.tsx index 0f5ab251b..b08c191bf 100644 --- a/frontend/app/src/screens/RedeemScreen/RedeemScreen.tsx +++ b/frontend/app/src/screens/RedeemScreen/RedeemScreen.tsx @@ -1,10 +1,9 @@ "use client"; -import type { ReactNode } from "react"; - import { Amount } from "@/src/comps/Amount/Amount"; import { Field } from "@/src/comps/Field/Field"; import { FlowButton } from "@/src/comps/FlowButton/FlowButton"; +import { InfoBox } from "@/src/comps/InfoBox/InfoBox"; import { LinkTextButton } from "@/src/comps/LinkTextButton/LinkTextButton"; import { Screen } from "@/src/comps/Screen/Screen"; import { Value } from "@/src/comps/Value/Value"; @@ -13,7 +12,7 @@ import content from "@/src/content"; import { dnum18, DNUM_0 } from "@/src/dnum-utils"; import { useInputFieldValue } from "@/src/form-utils"; import { fmtnum } from "@/src/formatting"; -import { getBranches, getCollToken, useRedemptionSimulation } from "@/src/liquity-utils"; +import { getBranch, getBranches, getCollToken, useRedemptionSimulation, useShutdownStatus } from "@/src/liquity-utils"; import { useCollateralRedemptionPrices, usePrice } from "@/src/services/Prices"; import { zipWith } from "@/src/utils"; import { useAccount, useBalance } from "@/src/wagmi-utils"; @@ -46,6 +45,11 @@ export function RedeemScreen() { const boldPrice = usePrice("BOLD"); const collPrices = useCollateralRedemptionPrices(branches.map((b) => b.symbol)); const boldRedeemed = useInputFieldValue(fmtnum); + const shutdownStatus = useShutdownStatus(); + + const shutdownBranches = shutdownStatus.data?.filter((b) => b.isShutdown) ?? []; + const activeBranches = shutdownStatus.data?.filter((b) => !b.isShutdown) ?? []; + const hasShutdownBranches = shutdownBranches.length > 0; const simulation = useRedemptionSimulation({ boldAmount: boldRedeemed.parsed ?? DNUM_0, @@ -124,6 +128,12 @@ export function RedeemScreen() { }} > + {hasShutdownBranches && ( + getBranch(b.branchId).symbol)} + activeBranches={activeBranches.map((b) => getBranch(b.branchId).symbol)} + /> + )} - {props.title && ( -
-

{props.title}

-
- )} + const shutdownNames = props.shutdownBranches.join(", "); + const activeNames = props.activeBranches.length > 0 + ? props.activeBranches.join(", ") + : "none"; - {props.children} - + return ( + +
+ Branch Shutdown Detected +
+
+ The following branches are in shutdown mode: {shutdownNames}. + {props.activeBranches.length > 0 + ? ` Standard redemptions will only use active branches (${activeNames}).` + : " No active branches available for standard redemptions."} +
+
+ Use{" "} + {" "} + to redeem from shutdown branches with a 2% bonus and a 0% fee. +
+
); } diff --git a/frontend/app/src/screens/UrgentRedeemScreen/TroveSelectionTable.tsx b/frontend/app/src/screens/UrgentRedeemScreen/TroveSelectionTable.tsx new file mode 100644 index 000000000..bc93c0983 --- /dev/null +++ b/frontend/app/src/screens/UrgentRedeemScreen/TroveSelectionTable.tsx @@ -0,0 +1,242 @@ +import type { Dnum, TroveId } from "@/src/types"; +import type { TroveWithICR } from "@/src/urgent-redemption-utils"; + +import { Amount } from "@/src/comps/Amount/Amount"; +import content from "@/src/content"; +import { DNUM_0 } from "@/src/dnum-utils"; +import { shortenTroveId } from "@/src/liquity-utils"; +import { sortByRedeemableValue, TROVES_PER_PAGE } from "@/src/urgent-redemption-utils"; +import { css } from "@/styled-system/css"; +import { Button, Checkbox, HFlex, VFlex } from "@liquity2/uikit"; +import * as dn from "dnum"; +import { useMemo, useState } from "react"; + +type TroveSelectionTableProps = { + troves: TroveWithICR[]; + price: Dnum; + selectedTroveIds: Set; + onSelectionChange: (selected: Set) => void; +}; + +export function TroveSelectionTable({ + troves, + price, + selectedTroveIds, + onSelectionChange, +}: TroveSelectionTableProps) { + const [currentPage, setCurrentPage] = useState(0); + const sortedTroves = useMemo( + () => sortByRedeemableValue(troves, price), + [troves, price], + ); + const totalPages = Math.ceil(sortedTroves.length / TROVES_PER_PAGE); + const paginatedTroves = useMemo(() => { + const start = currentPage * TROVES_PER_PAGE; + return sortedTroves.slice(start, start + TROVES_PER_PAGE); + }, [sortedTroves, currentPage]); + + const selectedTotals = useMemo(() => { + const selected = troves.filter((t) => selectedTroveIds.has(t.troveId)); + return { + count: selected.length, + totalDebt: selected.reduce((sum, t) => dn.add(sum, t.debt), DNUM_0), + totalColl: selected.reduce((sum, t) => dn.add(sum, t.coll), DNUM_0), + }; + }, [troves, selectedTroveIds]); + + const toggleTrove = (troveId: TroveId) => { + const newSelected = new Set(selectedTroveIds); + if (newSelected.has(troveId)) { + newSelected.delete(troveId); + } else { + newSelected.add(troveId); + } + onSelectionChange(newSelected); + }; + + const selectAllOnPage = () => { + const newSelected = new Set(selectedTroveIds); + for (const trove of paginatedTroves) { + newSelected.add(trove.troveId); + } + onSelectionChange(newSelected); + }; + + const clearSelection = () => { + onSelectionChange(new Set()); + }; + + const allOnPageSelected = paginatedTroves.every((t) => selectedTroveIds.has(t.troveId)); + + return ( + +
+ + + {content.urgentRedeemScreen.troveTable.trovesSelected(selectedTotals.count)} + + + + {content.urgentRedeemScreen.troveTable.totalDebt}{" "} + {" "} + {content.urgentRedeemScreen.troveTable.totalDebtUnit} + + + {content.urgentRedeemScreen.troveTable.totalColl}{" "} + + + + +
+ + +