Skip to content

Commit

Permalink
Loan screen: add the interest rate fee cooldown (#835)
Browse files Browse the repository at this point in the history
  • Loading branch information
bpierre authored Feb 18, 2025
1 parent f8537ac commit 5415a12
Show file tree
Hide file tree
Showing 8 changed files with 200 additions and 13 deletions.
1 change: 1 addition & 0 deletions frontend/app/src/characters.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export const INFINITY = "∞";
export const ARROW_RIGHT = "→";
export const NBSP = "\u00A0";
6 changes: 6 additions & 0 deletions frontend/app/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import type { CollateralSymbol, RiskLevel } from "@/src/types";
import { norm } from "@liquity2/uikit";
import * as dn from "dnum";

export const ONE_SECOND = 1000;
export const ONE_MINUTE = 60 * ONE_SECOND;
export const ONE_HOUR = 60 * ONE_MINUTE;
export const ONE_DAY = 24 * ONE_HOUR;

export const GAS_MIN_HEADROOM = 100_000;
export const GAS_RELATIVE_HEADROOM = 0.25;
export const GAS_ALLOCATE_LQTY_MIN_HEADROOM = 350_000;
Expand All @@ -20,6 +25,7 @@ export const ETH_MAX_RESERVE = dn.from(0.1, 18); // leave 0.1 ETH when users cli

export const ETH_GAS_COMPENSATION = dn.from(0.0375, 18); // see contracts/src/Dependencies/Constants.sol

export const INTEREST_RATE_ADJ_COOLDOWN = 7 * 24 * 60 * 60; // 7 days in seconds
export const INTEREST_RATE_MIN = 0.5; // 0.5% annualized
export const INTEREST_RATE_MAX = 25; // 25% annualized
export const INTEREST_RATE_DEFAULT = 10;
Expand Down
39 changes: 38 additions & 1 deletion frontend/app/src/formatting.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fmtnum } from "@/src/formatting";
import { ONE_DAY, ONE_HOUR, ONE_MINUTE, ONE_SECOND } from "@/src/constants";
import { fmtnum, formatRelativeTime } from "@/src/formatting";
import * as dn from "dnum";
import { expect, test } from "vitest";

Expand Down Expand Up @@ -59,3 +60,39 @@ test("fmtnum() works", () => {
expect(fmtnum(dn.from(123.456), 1)).toBe("123.5");
expect(fmtnum(dn.from(123.456), 3)).toBe("123.456");
});

test("formatRelativeTime() works", () => {
// days
expect(formatRelativeTime(ONE_DAY)).toBe("tomorrow");
expect(formatRelativeTime(-ONE_DAY)).toBe("yesterday");
expect(formatRelativeTime(2 * ONE_DAY)).toBe("in 2 days");
expect(formatRelativeTime(-2 * ONE_DAY)).toBe("2 days ago");

// hours
expect(formatRelativeTime(ONE_HOUR)).toBe("in 1 hour");
expect(formatRelativeTime(-ONE_HOUR)).toBe("1 hour ago");
expect(formatRelativeTime(2 * ONE_HOUR)).toBe("in 2 hours");
expect(formatRelativeTime(-2 * ONE_HOUR)).toBe("2 hours ago");

// minutes
expect(formatRelativeTime(ONE_MINUTE)).toBe("in 1 minute");
expect(formatRelativeTime(-ONE_MINUTE)).toBe("1 minute ago");
expect(formatRelativeTime(2 * ONE_MINUTE)).toBe("in 2 minutes");
expect(formatRelativeTime(-2 * ONE_MINUTE)).toBe("2 minutes ago");

// seconds
expect(formatRelativeTime(ONE_SECOND)).toBe("in 1 second");
expect(formatRelativeTime(-ONE_SECOND)).toBe("1 second ago");
expect(formatRelativeTime(2 * ONE_SECOND)).toBe("in 2 seconds");
expect(formatRelativeTime(-2 * ONE_SECOND)).toBe("2 seconds ago");

// "just now" (anything less than a second)
expect(formatRelativeTime(0)).toBe("just now");
expect(formatRelativeTime(500)).toBe("just now");
expect(formatRelativeTime(ONE_SECOND - 1)).toBe("just now");
expect(formatRelativeTime(-(ONE_SECOND - 1))).toBe("just now");

// bigint
expect(formatRelativeTime(BigInt(ONE_DAY))).toBe("tomorrow");
expect(formatRelativeTime(BigInt(-ONE_DAY))).toBe("yesterday");
});
28 changes: 28 additions & 0 deletions frontend/app/src/formatting.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Dnum, RiskLevel } from "@/src/types";

import { ONE_DAY, ONE_HOUR, ONE_MINUTE, ONE_SECOND } from "@/src/constants";
import { DNUM_0, DNUM_1 } from "@/src/dnum-utils";
import * as dn from "dnum";
import { match, P } from "ts-pattern";
Expand Down Expand Up @@ -200,6 +201,33 @@ export function formatDate(date: Date, format: "full" | "iso" = "full") {
throw new Error(`Invalid date format: ${format}`);
}

const relativeTimeFormat = new Intl.RelativeTimeFormat(
"en-US",
{ numeric: "auto" },
);

const relativeTimeUnits = [
[ONE_DAY, "days"],
[ONE_HOUR, "hours"],
[ONE_MINUTE, "minutes"],
[ONE_SECOND, "seconds"],
] as const;

export function formatRelativeTime(duration: number | bigint) {
duration = Number(duration);

for (const [threshold, unit] of relativeTimeUnits) {
if (Math.abs(duration) >= threshold) {
return relativeTimeFormat.format(
Math.ceil(duration / threshold),
unit,
);
}
}

return "just now";
}

export function formatDuration(durationInSeconds: number | bigint) {
durationInSeconds = Number(durationInSeconds);

Expand Down
26 changes: 25 additions & 1 deletion frontend/app/src/liquity-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ import type { Address, CollateralSymbol, CollateralToken } from "@liquity2/uikit
import type { UseQueryResult } from "@tanstack/react-query";
import type { Config as WagmiConfig } from "wagmi";

import { DATA_REFRESH_INTERVAL, INTEREST_RATE_INCREMENT, INTEREST_RATE_MAX, INTEREST_RATE_MIN } from "@/src/constants";
import {
DATA_REFRESH_INTERVAL,
INTEREST_RATE_ADJ_COOLDOWN,
INTEREST_RATE_INCREMENT,
INTEREST_RATE_MAX,
INTEREST_RATE_MIN,
} from "@/src/constants";
import { CONTRACTS, getBranchContract, getProtocolContract } from "@/src/contracts";
import { dnum18, DNUM_0, dnumOrNull, jsonStringifyWithDnum } from "@/src/dnum-utils";
import { CHAIN_BLOCK_EXPLORER, ENV_BRANCHES, LIQUITY_STATS_URL } from "@/src/env";
Expand Down Expand Up @@ -757,3 +763,21 @@ export function useInterestBatchDelegates(
enabled: batchAddresses.length > 0,
});
}

export function useTroveRateUpdateCooldown(branchId: BranchId, troveId: TroveId) {
const wagmiConfig = useWagmiConfig();
return useQuery({
queryKey: ["troveRateUpdateCooldown", branchId, troveId],
queryFn: async () => {
const { lastInterestRateAdjTime } = await readContract(wagmiConfig, {
...getBranchContract(branchId, "TroveManager"),
functionName: "getLatestTroveData",
args: [BigInt(troveId)],
});
const cooldownEndTime = (
Number(lastInterestRateAdjTime) + INTEREST_RATE_ADJ_COOLDOWN
) * 1000;
return (now: number) => Math.max(0, cooldownEndTime - now);
},
});
}
4 changes: 3 additions & 1 deletion frontend/app/src/screens/LeverageScreen/LeverageScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -322,10 +322,12 @@ export function LeverageScreen() {
alignItems: "center",
gap: 4,
color: "contentAlt",
fontSize: 14,
})}
>
<IconSuggestion size={16} />
<span>You can adjust interest rate later</span>
<>You can adjust this rate at any time</>
<InfoTooltip {...infoTooltipProps(content.generalInfotooltips.interestRateAdjustment)} />
</span>
),
}}
Expand Down
4 changes: 2 additions & 2 deletions frontend/app/src/screens/LoanScreen/LoanScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ import { match, P } from "ts-pattern";
import { useReadContract } from "wagmi";
import { LoanScreenCard } from "./LoanScreenCard";
import { PanelClosePosition } from "./PanelClosePosition";
import { PanelInterestRate } from "./PanelInterestRate";
import { PanelUpdateBorrowPosition } from "./PanelUpdateBorrowPosition";
import { PanelUpdateLeveragePosition } from "./PanelUpdateLeveragePosition";
import { PanelUpdateRate } from "./PanelUpdateRate";

const TABS = [
{ label: "Update Loan", id: "colldebt" },
Expand Down Expand Up @@ -204,7 +204,7 @@ export function LoanScreen() {
? <PanelUpdateLeveragePosition loan={loan.data} />
: <PanelUpdateBorrowPosition loan={loan.data} />
)}
{action === "rate" && <PanelUpdateRate loan={loan.data} />}
{action === "rate" && <PanelInterestRate loan={loan.data} />}
{action === "close" && <PanelClosePosition loan={loan.data} />}
</>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
import type { DelegateMode } from "@/src/comps/InterestRateField/InterestRateField";
import type { PositionLoanCommitted } from "@/src/types";
import type { BranchId, PositionLoanCommitted, TroveId } from "@/src/types";

import { ARROW_RIGHT } from "@/src/characters";
import { ARROW_RIGHT, NBSP } from "@/src/characters";
import { Amount } from "@/src/comps/Amount/Amount";
import { ConnectWarningBox } from "@/src/comps/ConnectWarningBox/ConnectWarningBox";
import { Field } from "@/src/comps/Field/Field";
import { InterestRateField } from "@/src/comps/InterestRateField/InterestRateField";
import { UpdateBox } from "@/src/comps/UpdateBox/UpdateBox";
import content from "@/src/content";
import { useInputFieldValue } from "@/src/form-utils";
import { fmtnum } from "@/src/formatting";
import { fmtnum, formatRelativeTime } from "@/src/formatting";
import { formatRisk } from "@/src/formatting";
import { getLoanDetails } from "@/src/liquity-math";
import { getBranch, getCollToken } from "@/src/liquity-utils";
import { getBranch, getCollToken, useTroveRateUpdateCooldown } from "@/src/liquity-utils";
import { useAccount } from "@/src/services/Ethereum";
import { usePrice } from "@/src/services/Prices";
import { useTransactionFlow } from "@/src/services/TransactionFlow";
import { infoTooltipProps, riskLevelToStatusMode } from "@/src/uikit-utils";
import { css } from "@/styled-system/css";
import { addressesEqual, Button, HFlex, InfoTooltip, StatusDot } from "@liquity2/uikit";
import { addressesEqual, Button, HFlex, IconSuggestion, InfoTooltip, StatusDot } from "@liquity2/uikit";
import * as dn from "dnum";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";

export function PanelUpdateRate({
export function PanelInterestRate({
loan,
}: {
loan: PositionLoanCommitted;
Expand Down Expand Up @@ -53,7 +53,11 @@ export function PanelUpdateRate({
? "delegate"
: "manual",
);
const [interestRateDelegate, setInterestRateDelegate] = useState(loan.batchManager);
const [interestRateDelegate, setInterestRateDelegate] = useState(
loan.batchManager,
);

const updateRateCooldown = useUpdateRateCooldown(loan.branchId, loan.troveId);

const loanDetails = getLoanDetails(
loan.deposit,
Expand Down Expand Up @@ -111,6 +115,46 @@ export function PanelUpdateRate({
/>
}
footer={{
start: (
<Field.FooterInfo
label={updateRateCooldown.status === "success" && (
<span
className={css({
display: "flex",
alignItems: "center",
gap: 4,
color: "contentAlt",
fontSize: 14,
})}
>
<IconSuggestion size={16} />
{updateRateCooldown.active
? (
<>
Adjust without fee
<div ref={updateRateCooldown.remainingRef} />
</>
)
: <>No fee for rate adjustment</>}
<InfoTooltip
content={{
heading: "Interest rate updates",
body: (
<div>
Rate adjustments made within 7{NBSP}days of the last change incur a fee equal to 7{NBSP}days
of average interest.
</div>
),
footerLink: {
href: "https://docs.liquity.org/v2-faq/borrowing-and-liquidations#can-i-adjust-the-rate",
label: "Learn more",
},
}}
/>
</span>
)}
/>
),
end: (
<Field.FooterInfo
label={
Expand Down Expand Up @@ -214,3 +258,48 @@ export function PanelUpdateRate({
</>
);
}

function useUpdateRateCooldown(branchId: BranchId, troveId: TroveId) {
const cooldown = useTroveRateUpdateCooldown(branchId, troveId);

const remainingRef = useRef<HTMLDivElement>(null);
const [active, setActive] = useState(false);

useEffect(() => {
if (!cooldown.data) {
return;
}

const update = () => {
const remaining = cooldown.data(Date.now());

if (remaining === 0) {
if (active) {
setActive(false);
}
return;
}

if (!active && remaining > 0) {
setActive(true);
}

if (remaining > 0 && remainingRef.current) {
remainingRef.current.innerHTML = formatRelativeTime(remaining);
}
};

const timeout = setTimeout(update, 1000);
update();

return () => {
clearTimeout(timeout);
};
}, [cooldown.data, active]);

return {
active,
remainingRef,
status: cooldown.status,
};
}

0 comments on commit 5415a12

Please sign in to comment.