From d6855aeae521c4a8fc4edabb896184a2aba5ec5a Mon Sep 17 00:00:00 2001 From: cyril <6944787+cyril-dfi@users.noreply.github.com> Date: Thu, 5 Feb 2026 15:54:33 +0100 Subject: [PATCH 01/21] feat: urgent redemptions --- frontend/app/src/app/redeem/urgent/page.tsx | 5 + frontend/app/src/comps/InfoBox/InfoBox.tsx | 31 ++ frontend/app/src/content.tsx | 96 ++++ frontend/app/src/liquity-utils.ts | 86 ++++ .../src/screens/RedeemScreen/RedeemScreen.tsx | 73 +-- .../TroveSelectionTable.tsx | 236 ++++++++++ .../UrgentRedeemScreen/UrgentRedeemScreen.tsx | 441 ++++++++++++++++++ frontend/app/src/services/Prices.tsx | 25 + frontend/app/src/services/TransactionFlow.tsx | 4 + .../app/src/tx-flows/redeemCollateral.tsx | 12 +- .../app/src/tx-flows/urgentRedemption.tsx | 201 ++++++++ frontend/app/src/urgent-redemption-utils.ts | 153 ++++++ 12 files changed, 1334 insertions(+), 29 deletions(-) create mode 100644 frontend/app/src/app/redeem/urgent/page.tsx create mode 100644 frontend/app/src/comps/InfoBox/InfoBox.tsx create mode 100644 frontend/app/src/screens/UrgentRedeemScreen/TroveSelectionTable.tsx create mode 100644 frontend/app/src/screens/UrgentRedeemScreen/UrgentRedeemScreen.tsx create mode 100644 frontend/app/src/tx-flows/urgentRedemption.tsx create mode 100644 frontend/app/src/urgent-redemption-utils.ts diff --git a/frontend/app/src/app/redeem/urgent/page.tsx b/frontend/app/src/app/redeem/urgent/page.tsx new file mode 100644 index 000000000..402a49e63 --- /dev/null +++ b/frontend/app/src/app/redeem/urgent/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/InfoBox/InfoBox.tsx b/frontend/app/src/comps/InfoBox/InfoBox.tsx new file mode 100644 index 000000000..4703b4444 --- /dev/null +++ b/frontend/app/src/comps/InfoBox/InfoBox.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from "react"; + +import { css } from "@/styled-system/css"; + +export function InfoBox(props: { + title?: ReactNode; + children?: ReactNode; +}) { + return ( +
+ {props.title && ( +
+

{props.title}

+
+ )} + + {props.children} +
+ ); +} diff --git a/frontend/app/src/content.tsx b/frontend/app/src/content.tsx index 7d887b2b7..3dc85e980 100644 --- a/frontend/app/src/content.tsx +++ b/frontend/app/src/content.tsx @@ -600,6 +600,102 @@ export default { ), }, + urgentRedeemScreen: { + headingTitle: "Urgent Redemptions", + headingTitleActive: "Urgent 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: "Including 2% bonus", + bonusCappedLabel: "Including bonus (reduced)", + bonusTooltip: "Urgent redemptions include a 2% bonus on the collateral received.", + bonusCappedTooltip: + "The bonus is reduced because some selected troves don't have enough collateral to cover the full 2%.", + redemptionFeeLabel: "Redemption fee", + redemptionFeeValue: "0% (no fee)", + slippageTolerance: "Slippage tolerance", + customSlippagePlaceholder: "Custom %", + competitionWarning: ( + <> + Urgent redemptions are competitive. Other users may redeem from your selected troves + before your transaction confirms. + + ), + 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 urgent redemption was successful.", + noShutdown: { + title: "No Branches in Shutdown Mode", + body: ( + <> + Urgent 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 Urgent Redemptions Available", + body: "No troves are currently available for urgent redemption in this branch.", + }, + troveTable: { + trovesSelected: (count: number) => `${count} ${count === 1 ? "trove" : "troves"} selected`, + totalDebt: "Total debt:", + totalDebtUnit: "BOLD", + totalColl: "Total coll:", + deselectPage: "Clear all", + selectAllOnPage: "Select all on page", + clearSelection: "Clear selection", + columnTroveId: "Trove ID", + columnCollateral: "Collateral", + columnDebt: "Debt", + columnIcr: "ICR", + icrFullBonus: "ICR >= 102%: Guaranteed full bonus", + icrPartialBonus: "ICR < 102%: Partial bonus possible", + 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: "Urgent redemptions have 0% fee and include a 2% collateral bonus.", + youReceiveToken: (tokenName: string) => `You receive ${tokenName}`, + receiveTooltip: (tokenName: string) => + `This is the estimated amount of ${tokenName} you will receive, including` + + " the 2% 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. + Urgent 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 Urgent Redemption", + }, + }, + manualLoanIdInput: { title: "Data API error", description: diff --git a/frontend/app/src/liquity-utils.ts b/frontend/app/src/liquity-utils.ts index b33874520..d859ec358 100644 --- a/frontend/app/src/liquity-utils.ts +++ b/frontend/app/src/liquity-utils.ts @@ -1920,3 +1920,89 @@ 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 type RedeemableTrove = { + id: string; + troveId: TroveId; + debt: Dnum; + coll: Dnum; + stake: Dnum; + interestRate: Dnum; +}; + +async function fetchRedeemableTroves( + wagmiConfig: WagmiConfig, + branchId: BranchId, + maxTroves: number = 200, +): 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 ?? 100; + + 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/RedeemScreen/RedeemScreen.tsx b/frontend/app/src/screens/RedeemScreen/RedeemScreen.tsx index 0f5ab251b..7102731df 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,13 +12,14 @@ 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"; import { css } from "@/styled-system/css"; import { HFlex, IconExternal, InfoTooltip, InputField, TextButton, TokenIcon, VFlex } from "@liquity2/uikit"; import * as dn from "dnum"; +import Link from "next/link"; const TRUNCATED_THRESHOLD = dnum18(100); // wei const maxIterationsPerCollateral = REDEMPTION_MAX_ITERATIONS_PER_COLL; @@ -46,6 +46,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 +129,12 @@ export function RedeemScreen() { }} > + {hasShutdownBranches && ( + getBranch(b.branchId).symbol)} + activeBranches={activeBranches.map((b) => getBranch(b.branchId).symbol)} + /> + )} 0 + ? props.activeBranches.join(", ") + : "none"; + return ( -
- {props.title && ( -
-

{props.title}

-
- )} - - {props.children} -
+ +
+ 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{" "} + + urgent redemptions + + {" "}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..1532c3d04 --- /dev/null +++ b/frontend/app/src/screens/UrgentRedeemScreen/TroveSelectionTable.tsx @@ -0,0 +1,236 @@ +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 { MIN_GUARANTEED_ICR, 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[]; + selectedTroveIds: Set; + onSelectionChange: (selected: Set) => void; +}; + +export function TroveSelectionTable({ + troves, + selectedTroveIds, + onSelectionChange, +}: TroveSelectionTableProps) { + const [currentPage, setCurrentPage] = useState(0); + const sortedTroves = useMemo(() => { + return [...troves].sort((a, b) => dn.cmp(b.icr, a.icr)); + }, [troves]); + 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: string) => { + 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}{" "} + + + + +
+ + +