Skip to content

Commit 2079e5c

Browse files
Merge pull request #1197 from liquity/urgent-redemptions
[App] Urgent redemptions
2 parents dfbe823 + 2991b60 commit 2079e5c

18 files changed

Lines changed: 1436 additions & 69 deletions

File tree

contracts/utils/deployment-manifest-to-app-env.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const ZDeploymentManifest = z.object({
3939
debtInFrontHelper: ZAddress,
4040
exchangeHelpers: ZAddress,
4141
exchangeHelpersV2: ZAddress,
42+
redemptionHelper: ZAddress,
4243

4344
governance: z.object({
4445
LUSDToken: ZAddress,
@@ -189,6 +190,8 @@ function contractNameToAppEnvVariable(contractName: string, prefix: string = "")
189190
return `${prefix}_EXCHANGE_HELPERS`;
190191
case "exchangeHelpersV2":
191192
return `${prefix}_EXCHANGE_HELPERS_V2`;
193+
case "redemptionHelper":
194+
return `${prefix}_REDEMPTION_HELPER`;
192195

193196
// collateral contracts
194197
case "activePool":
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { UrgentRedeemScreen } from "@/src/screens/UrgentRedeemScreen/UrgentRedeemScreen";
2+
3+
export default function Page() {
4+
return <UrgentRedeemScreen />;
5+
}

frontend/app/src/comps/AppLayout/AppLayout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { ReactNode } from "react";
55
import { Banner } from "@/Banner";
66
import { LegacyPositionsBanner } from "@/src/comps/LegacyPositionsBanner/LegacyPositionsBanner";
77
import { SafetyModeBanner } from "@/src/comps/SafetyModeBanner/SafetyModeBanner";
8+
import { ShutdownModeBanner } from "@/src/comps/ShutdownModeBanner/ShutdownModeBanner";
89
import { SubgraphDownBanner } from "@/src/comps/SubgraphDownBanner/SubgraphDownBanner";
910
import { V1StabilityPoolBanner } from "@/src/comps/V1StabilityPoolBanner/V1StabilityPoolBanner";
1011
import { V1StakingBanner } from "@/src/comps/V1StakingBanner/V1StakingBanner";
@@ -45,6 +46,7 @@ export function AppLayout({
4546
{LEGACY_CHECK && <LegacyPositionsBanner />}
4647
{SUBGRAPH_CHECK && <SubgraphDownBanner />}
4748
{SAFETY_MODE_CHECK && <SafetyModeBanner />}
49+
{SAFETY_MODE_CHECK && <ShutdownModeBanner />}
4850
<div
4951
className={css({
5052
display: "flex",
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { ReactNode } from "react";
2+
3+
import { css } from "@/styled-system/css";
4+
5+
export function InfoBox(props: {
6+
title?: ReactNode;
7+
children?: ReactNode;
8+
}) {
9+
return (
10+
<section
11+
className={css({
12+
display: "flex",
13+
flexDirection: "column",
14+
gap: 8,
15+
padding: 16,
16+
color: "infoSurfaceContent",
17+
background: "infoSurface",
18+
border: "1px solid token(colors.infoSurfaceBorder)",
19+
borderRadius: 8,
20+
})}
21+
>
22+
{props.title && (
23+
<header className={css({ display: "flex", flexDirection: "column", fontSize: 16 })}>
24+
<h1 className={css({ fontWeight: 600 })}>{props.title}</h1>
25+
</header>
26+
)}
27+
28+
{props.children}
29+
</section>
30+
);
31+
}

frontend/app/src/comps/SafetyModeBanner/SafetyModeBanner.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,26 @@
11
"use client";
22

33
import { InfoBanner } from "@/src/comps/InfoBanner/InfoBanner";
4-
import { useSafetyMode } from "@/src/liquity-utils";
4+
import { useSafetyMode, useShutdownStatus } from "@/src/liquity-utils";
55
import { token } from "@/styled-system/tokens";
66
import { IconWarning } from "@liquity2/uikit";
77

88
export function SafetyModeBanner() {
99
const safetyMode = useSafetyMode();
10+
const shutdownStatus = useShutdownStatus();
11+
12+
const shutdownBranchIds = new Set(
13+
shutdownStatus.data?.filter((b) => b.isShutdown).map((b) => b.branchId) ?? [],
14+
);
15+
16+
const branchesInSafetyMode = (safetyMode.data?.branchesInSafetyMode ?? [])
17+
.filter((b) => !shutdownBranchIds.has(b.branchId));
1018

11-
const branchesInSafetyMode = safetyMode.data?.branchesInSafetyMode ?? [];
1219
const branchNames = branchesInSafetyMode.map((b) => b.symbol).join(", ");
1320

1421
return (
1522
<InfoBanner
16-
show={Boolean(safetyMode.data?.isAnySafetyMode)}
23+
show={branchesInSafetyMode.length > 0}
1724
icon={<IconWarning size={16} />}
1825
messageDesktop={
1926
<>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"use client";
2+
3+
import { InfoBanner } from "@/src/comps/InfoBanner/InfoBanner";
4+
import { getBranch, useShutdownStatus } from "@/src/liquity-utils";
5+
import { token } from "@/styled-system/tokens";
6+
import { IconWarning } from "@liquity2/uikit";
7+
8+
export function ShutdownModeBanner() {
9+
const shutdownStatus = useShutdownStatus();
10+
11+
const branchesInShutdown = shutdownStatus.data?.filter((b) => b.isShutdown) ?? [];
12+
const branchNames = branchesInShutdown.map((b) => getBranch(b.branchId).symbol).join(", ");
13+
14+
return (
15+
<InfoBanner
16+
show={branchesInShutdown.length > 0}
17+
icon={<IconWarning size={16} />}
18+
messageDesktop={
19+
<>
20+
The {branchNames} branch{branchesInShutdown.length > 1 ? "es are" : " is"} in Shutdown Mode.
21+
You can only close positions or redeem BOLD.
22+
</>
23+
}
24+
linkLabel="Learn more"
25+
linkLabelMobile="Shutdown Mode active"
26+
linkHref="https://docs.liquity.org/v2-faq/borrowing-and-liquidations#what-is-shutdown-mode"
27+
linkExternal
28+
backgroundColor={token("colors.brandGolden")}
29+
foregroundColor={token("colors.brandGoldenContent")}
30+
/>
31+
);
32+
}

frontend/app/src/content.tsx

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,97 @@ export default {
600600
</>
601601
),
602602
},
603+
shutdownWarning: {
604+
title: "Branch Shutdown",
605+
borrowMessage: (collName: string) => (
606+
<>
607+
The {collName} branch is in shutdown mode. New loans cannot be opened on this branch.
608+
</>
609+
),
610+
loanMessage: (collName: string) => (
611+
<>
612+
The {collName} branch is in shutdown mode. Loan adjustments are not available. You can only close your loan.
613+
</>
614+
),
615+
},
616+
urgentRedeemScreen: {
617+
headingTitle: "Shutdown Redemptions",
618+
headingTitleActive: "Shutdown Redemption",
619+
selectBranchLabel: "Select branch",
620+
redeemFieldLabel: "You redeem",
621+
insufficientBalance: (balance: string) => `Insufficient BOLD balance. You have ${balance} BOLD.`,
622+
amountCapped: (amount: string) => `Capped to ${amount} BOLD (max amount redeemable).`,
623+
youReceive: "You receive",
624+
bonusLabel: (bonusPct: string) => `Including ${bonusPct} bonus`,
625+
bonusTooltip: (bonusPct: string) => `Shutdown redemptions include a ${bonusPct} bonus on the collateral received.`,
626+
slippageTolerance: "Slippage tolerance",
627+
manualTrovesLabel: "Manually selected troves",
628+
autoTrovesLabel: "Auto-selected troves",
629+
useAutoSelection: "Use auto-selection",
630+
manuallySelectTroves: "Manually select troves",
631+
trovesCount: (count: number) => `${count} ${count === 1 ? "trove" : "troves"} will be used for this redemption.`,
632+
action: "Redeem",
633+
backLink: "Back",
634+
successLink: "Go to the Dashboard",
635+
successMessage: "The shutdown redemption was successful.",
636+
noShutdown: {
637+
title: "No Branches in Shutdown Mode",
638+
body: (
639+
<>
640+
Shutdown redemptions are only available when a branch is in shutdown mode. Currently, all branches are
641+
operating normally.
642+
</>
643+
),
644+
link: "Go to standard redemptions",
645+
},
646+
noTroves: {
647+
title: "No Shutdown Redemptions Available",
648+
body: "No troves are currently available for shutdown redemption in this branch.",
649+
},
650+
troveTable: {
651+
trovesSelected: (count: number) => `${count} ${count === 1 ? "trove" : "troves"} selected`,
652+
totalDebt: "Total debt:",
653+
totalDebtUnit: "BOLD",
654+
totalColl: "Total coll:",
655+
deselectAll: "Clear all",
656+
selectAllOnPage: "Select all on page",
657+
clearSelection: "Clear selection",
658+
columnTroveId: "Trove ID",
659+
columnCollateral: "Collateral",
660+
columnDebt: "Debt",
661+
columnIcr: "ICR",
662+
noTrovesAvailable: "No troves available",
663+
page: (current: number, total: number) => `Page ${current} of ${total}`,
664+
previous: "Previous",
665+
next: "Next",
666+
},
667+
txFlow: {
668+
title: "Review & Send Transaction",
669+
youRedeemBold: "You redeem BOLD",
670+
redeemTooltip: (bonusPct: string) =>
671+
`Shutdown redemptions have 0% fee and include a ${bonusPct} collateral bonus.`,
672+
youReceiveToken: (tokenName: string) => `You receive ${tokenName}`,
673+
receiveTooltip: (tokenName: string, bonusPct: string) =>
674+
`This is the estimated amount of ${tokenName} you will receive, including`
675+
+ ` the ${bonusPct} bonus. The actual amount may vary based on the selected troves.`,
676+
trovesLabel: "Troves to redeem from",
677+
trovesTooltip: (
678+
<>
679+
The number of troves that will be used for this redemption. Shutdown redemptions are competitive - other users
680+
may redeem from these troves before your transaction confirms.
681+
</>
682+
),
683+
trovesValue: (count: number) => `${count} ${count === 1 ? "trove" : "troves"}`,
684+
slippageTooltip: (threshold: N) => (
685+
<>
686+
If the actual collateral received is less than {threshold}{" "}
687+
of the expected amount, the transaction will revert.
688+
</>
689+
),
690+
approveStep: "Approve BOLD",
691+
redeemStep: "Execute Shutdown Redemption",
692+
},
693+
},
603694
dataSources: {
604695
title: "Data Sources",
605696
description:

frontend/app/src/graphql/graphql.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ export type Scalars = {
2323
Timestamp: { input: string; output: string; }
2424
};
2525

26+
/** Indicates whether the current, partially filled bucket should be included in the response. Defaults to `exclude` */
27+
export enum Aggregation_Current {
28+
/** Exclude the current, partially filled bucket from the response */
29+
Exclude = 'exclude',
30+
/** Include the current, partially filled bucket in the response */
31+
Include = 'include'
32+
}
33+
2634
export enum Aggregation_Interval {
2735
Day = 'day',
2836
Hour = 'hour'
@@ -54,10 +62,8 @@ export type BorrowerInfo_Filter = {
5462
and?: InputMaybe<Array<InputMaybe<BorrowerInfo_Filter>>>;
5563
collSurplusBalance?: InputMaybe<Array<Scalars['BigInt']['input']>>;
5664
collSurplusBalance_contains?: InputMaybe<Array<Scalars['BigInt']['input']>>;
57-
collSurplusBalance_contains_nocase?: InputMaybe<Array<Scalars['BigInt']['input']>>;
5865
collSurplusBalance_not?: InputMaybe<Array<Scalars['BigInt']['input']>>;
5966
collSurplusBalance_not_contains?: InputMaybe<Array<Scalars['BigInt']['input']>>;
60-
collSurplusBalance_not_contains_nocase?: InputMaybe<Array<Scalars['BigInt']['input']>>;
6167
id?: InputMaybe<Scalars['ID']['input']>;
6268
id_gt?: InputMaybe<Scalars['ID']['input']>;
6369
id_gte?: InputMaybe<Scalars['ID']['input']>;
@@ -68,24 +74,18 @@ export type BorrowerInfo_Filter = {
6874
id_not_in?: InputMaybe<Array<Scalars['ID']['input']>>;
6975
lastCollSurplusClaimAt?: InputMaybe<Array<Scalars['BigInt']['input']>>;
7076
lastCollSurplusClaimAt_contains?: InputMaybe<Array<Scalars['BigInt']['input']>>;
71-
lastCollSurplusClaimAt_contains_nocase?: InputMaybe<Array<Scalars['BigInt']['input']>>;
7277
lastCollSurplusClaimAt_not?: InputMaybe<Array<Scalars['BigInt']['input']>>;
7378
lastCollSurplusClaimAt_not_contains?: InputMaybe<Array<Scalars['BigInt']['input']>>;
74-
lastCollSurplusClaimAt_not_contains_nocase?: InputMaybe<Array<Scalars['BigInt']['input']>>;
7579
nextOwnerIndexes?: InputMaybe<Array<Scalars['Int']['input']>>;
7680
nextOwnerIndexes_contains?: InputMaybe<Array<Scalars['Int']['input']>>;
77-
nextOwnerIndexes_contains_nocase?: InputMaybe<Array<Scalars['Int']['input']>>;
7881
nextOwnerIndexes_not?: InputMaybe<Array<Scalars['Int']['input']>>;
7982
nextOwnerIndexes_not_contains?: InputMaybe<Array<Scalars['Int']['input']>>;
80-
nextOwnerIndexes_not_contains_nocase?: InputMaybe<Array<Scalars['Int']['input']>>;
8183
or?: InputMaybe<Array<InputMaybe<BorrowerInfo_Filter>>>;
8284
troves?: InputMaybe<Scalars['Int']['input']>;
8385
trovesByCollateral?: InputMaybe<Array<Scalars['Int']['input']>>;
8486
trovesByCollateral_contains?: InputMaybe<Array<Scalars['Int']['input']>>;
85-
trovesByCollateral_contains_nocase?: InputMaybe<Array<Scalars['Int']['input']>>;
8687
trovesByCollateral_not?: InputMaybe<Array<Scalars['Int']['input']>>;
8788
trovesByCollateral_not_contains?: InputMaybe<Array<Scalars['Int']['input']>>;
88-
trovesByCollateral_not_contains_nocase?: InputMaybe<Array<Scalars['Int']['input']>>;
8989
troves_gt?: InputMaybe<Scalars['Int']['input']>;
9090
troves_gte?: InputMaybe<Scalars['Int']['input']>;
9191
troves_in?: InputMaybe<Array<Scalars['Int']['input']>>;

frontend/app/src/liquity-utils.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1920,3 +1920,97 @@ export function useSafetyMode() {
19201920
enabled: Boolean(allRatios.data),
19211921
});
19221922
}
1923+
1924+
export type ShutdownStatus = {
1925+
branchId: BranchId;
1926+
isShutdown: boolean;
1927+
};
1928+
1929+
export function useShutdownStatus() {
1930+
const branches = getBranches();
1931+
1932+
return useReadContracts({
1933+
contracts: branches.map((branch) => ({
1934+
...branch.contracts.TroveManager,
1935+
functionName: "shutdownTime" as const,
1936+
})),
1937+
allowFailure: false,
1938+
query: {
1939+
refetchInterval: 12_000,
1940+
select: (results): ShutdownStatus[] => {
1941+
return results.map((shutdownTime, index) => {
1942+
const branch = branches[index];
1943+
if (!branch) {
1944+
throw new Error(`Branch at index ${index} not found`);
1945+
}
1946+
return {
1947+
branchId: branch.branchId,
1948+
isShutdown: Number(shutdownTime) > 0,
1949+
};
1950+
});
1951+
},
1952+
},
1953+
});
1954+
}
1955+
1956+
export function useIsBranchShutdown(branchId: BranchId) {
1957+
const shutdownStatus = useShutdownStatus();
1958+
return {
1959+
...shutdownStatus,
1960+
data: shutdownStatus.data?.find((s) => s.branchId === branchId)?.isShutdown ?? false,
1961+
};
1962+
}
1963+
1964+
export type RedeemableTrove = {
1965+
id: string;
1966+
troveId: TroveId;
1967+
debt: Dnum;
1968+
coll: Dnum;
1969+
stake: Dnum;
1970+
interestRate: Dnum;
1971+
};
1972+
1973+
async function fetchRedeemableTroves(
1974+
wagmiConfig: WagmiConfig,
1975+
branchId: BranchId,
1976+
maxTroves: number,
1977+
): Promise<RedeemableTrove[]> {
1978+
const MultiTroveGetter = getProtocolContract("MultiTroveGetter");
1979+
1980+
const troves = await readContract(wagmiConfig, {
1981+
...MultiTroveGetter,
1982+
functionName: "getMultipleSortedTroves",
1983+
args: [BigInt(branchId), -1n, BigInt(maxTroves)],
1984+
});
1985+
1986+
return troves
1987+
.filter((trove) => trove.entireDebt > 0n)
1988+
.map((trove) => ({
1989+
id: getPrefixedTroveId(branchId, `0x${trove.id.toString(16)}` as TroveId),
1990+
troveId: `0x${trove.id.toString(16)}` as TroveId,
1991+
debt: dnum18(trove.entireDebt),
1992+
coll: dnum18(trove.entireColl),
1993+
stake: dnum18(trove.stake),
1994+
interestRate: dnum18(trove.annualInterestRate),
1995+
}));
1996+
}
1997+
1998+
export function useRedeemableTroves(
1999+
branchId: BranchId | null,
2000+
options?: { first?: number },
2001+
) {
2002+
const wagmiConfig = useWagmiConfig();
2003+
const maxTroves = options?.first ?? 200;
2004+
2005+
return useQuery<RedeemableTrove[]>({
2006+
queryKey: ["redeemableTroves", branchId, maxTroves],
2007+
queryFn: async () => {
2008+
if (branchId === null) {
2009+
return [];
2010+
}
2011+
return fetchRedeemableTroves(wagmiConfig, branchId, maxTroves);
2012+
},
2013+
enabled: branchId !== null,
2014+
refetchInterval: 12_000,
2015+
});
2016+
}

0 commit comments

Comments
 (0)