diff --git a/frontend/app/src/comps/InfoBox/InfoBox.tsx b/frontend/app/src/comps/InfoBox/InfoBox.tsx new file mode 100644 index 000000000..cdf9703da --- /dev/null +++ b/frontend/app/src/comps/InfoBox/InfoBox.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; + +import { css } from "@/styled-system/css"; + +export function InfoBox({ + children, + gap = 16, +}: { + children: ReactNode; + gap?: number; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/frontend/app/src/comps/InputTokenBadge/InputTokenBadge.tsx b/frontend/app/src/comps/InputTokenBadge/InputTokenBadge.tsx new file mode 100644 index 000000000..7b06408ba --- /dev/null +++ b/frontend/app/src/comps/InputTokenBadge/InputTokenBadge.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from "react"; + +import { css } from "@/styled-system/css"; + +export function InputTokenBadge({ + background = true, + icon, + label, +}: { + background?: boolean; + icon?: ReactNode; + label: ReactNode; +}) { + return ( +
+ {icon} +
+ {label} +
+
+ ); +} diff --git a/frontend/app/src/comps/ValueUpdate/ValueUpdate.tsx b/frontend/app/src/comps/ValueUpdate/ValueUpdate.tsx new file mode 100644 index 000000000..e4e968e34 --- /dev/null +++ b/frontend/app/src/comps/ValueUpdate/ValueUpdate.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; + +import { css } from "@/styled-system/css"; +import { HFlex } from "@liquity2/uikit"; + +const ARROW_RIGHT = "→"; + +export function ValueUpdate({ + after, + before, + fontSize = 16, + tabularNums = true, +}: { + after: ReactNode; + before: ReactNode; + fontSize?: number; + tabularNums?: boolean; +}) { + return ( + +
{before}
+
{ARROW_RIGHT}
+
{after}
+
+ ); +} diff --git a/frontend/app/src/comps/WarningBox/WarningBox.tsx b/frontend/app/src/comps/WarningBox/WarningBox.tsx new file mode 100644 index 000000000..bb4be6232 --- /dev/null +++ b/frontend/app/src/comps/WarningBox/WarningBox.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from "react"; + +import { css } from "@/styled-system/css"; + +export function WarningBox({ + children, +}: { + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/frontend/app/src/screens/LoanScreen/LoanScreen.tsx b/frontend/app/src/screens/LoanScreen/LoanScreen.tsx index f89524ff0..b1abcc4b7 100644 --- a/frontend/app/src/screens/LoanScreen/LoanScreen.tsx +++ b/frontend/app/src/screens/LoanScreen/LoanScreen.tsx @@ -1,46 +1,18 @@ "use client"; import type { PositionLoan } from "@/src/types"; -import type { ReactNode } from "react"; -import { ConnectWarningBox } from "@/src/comps/ConnectWarningBox/ConnectWarningBox"; -import { Field } from "@/src/comps/Field/Field"; -import { LeverageField, useLeverageField } from "@/src/comps/LeverageField/LeverageField"; import { Position } from "@/src/comps/Position/Position"; import { Screen } from "@/src/comps/Screen/Screen"; -import { ETH_MAX_RESERVE, INTEREST_RATE_INCREMENT, INTEREST_RATE_MAX, INTEREST_RATE_MIN } from "@/src/constants"; -import content from "@/src/content"; -import { ACCOUNT_BALANCES, ACCOUNT_POSITIONS, getDebtBeforeRateBucketIndex, INTEREST_CHART } from "@/src/demo-mode"; -import { useInputFieldValue } from "@/src/form-utils"; -import { fmtnum, formatRisk } from "@/src/formatting"; -import { getLiquidationPriceFromLeverage, getLoanDetails } from "@/src/liquity-math"; -import { useAccount } from "@/src/services/Ethereum"; -import { usePrice } from "@/src/services/Prices"; -import { infoTooltipProps, riskLevelToStatusMode } from "@/src/uikit-utils"; +import { ACCOUNT_POSITIONS } from "@/src/demo-mode"; import { css } from "@/styled-system/css"; -import { - Button, - Checkbox, - Dropdown, - HFlex, - IconSettings, - InfoTooltip, - InputField, - lerp, - norm, - Slider, - StatusDot, - Tabs, - TextButton, - TokenIcon, - TOKENS_BY_SYMBOL, - VFlex, -} from "@liquity2/uikit"; -import * as dn from "dnum"; +import { IconSettings, Tabs, VFlex } from "@liquity2/uikit"; import { notFound, useRouter, useSearchParams, useSelectedLayoutSegment } from "next/navigation"; -import { useEffect, useMemo, useRef, useState } from "react"; - -const ARROW_RIGHT = "→"; +import { useMemo, useState } from "react"; +import { PanelClosePosition } from "./PanelClosePosition"; +import { PanelUpdateBorrowPosition } from "./PanelUpdateBorrowPosition"; +import { PanelUpdateLeveragePosition } from "./PanelUpdateLeveragePosition"; +import { PanelUpdateRate } from "./PanelUpdateRate"; const TABS = [ { label: "Collateral & Debt", id: "colldebt" }, @@ -106,1154 +78,17 @@ export function LoanScreen() { /> {action === "colldebt" && ( leverageMode - ? - : + ? + : )} - {action === "rate" && } - {action === "close" && } + {action === "rate" && } + {action === "close" && } ); } -type RelativeFieldMode = "add" | "remove"; - -function UpdateBorrowPositionPanel({ loan }: { loan: PositionLoan }) { - const router = useRouter(); - const account = useAccount(); - - const collateral = TOKENS_BY_SYMBOL[loan.collateral]; - const collPrice = usePrice(collateral.symbol); - const boldPriceUsd = usePrice("BOLD") ?? dn.from(0, 18); - - // deposit change - const [depositMode, setDepositMode] = useState("add"); - const depositChange = useInputFieldValue((value) => dn.format(value)); - - // deposit update - const newDeposit = depositChange.parsed && ( - depositMode === "remove" - ? dn.sub(loan.deposit, depositChange.parsed) - : dn.add(loan.deposit, depositChange.parsed) - ); - - const collMax = depositMode === "remove" ? loan.deposit : dn.sub( - ACCOUNT_BALANCES[collateral.symbol], - ETH_MAX_RESERVE, - ); - - // debt change - const [debtMode, setDebtMode] = useState("add"); - const debtChange = useInputFieldValue((value) => dn.format(value)); - const debtChangeUsd = debtChange.parsed && dn.mul(debtChange.parsed, boldPriceUsd); - - const newDebt = debtChange.parsed && ( - debtMode === "remove" - ? dn.sub(loan.borrowed, debtChange.parsed) - : dn.add(loan.borrowed, debtChange.parsed) - ); - - const boldMax = debtMode === "remove" ? ACCOUNT_BALANCES["BOLD"] : null; - - const loanDetails = getLoanDetails( - loan.deposit, - loan.borrowed, - loan.interestRate, - collateral.collateralRatio, - collPrice, - ); - - const newLoanDetails = getLoanDetails( - newDeposit, - newDebt, - loanDetails.interestRate, - collateral.collateralRatio, - collPrice, - ); - - const allowSubmit = account.isConnected && ( - !dn.eq(loanDetails.deposit ?? dn.from(0, 18), newLoanDetails.deposit ?? dn.from(0, 18)) - || !dn.eq(loanDetails.debt ?? dn.from(0, 18), newLoanDetails.debt ?? dn.from(0, 18)) - ); - - return ( - <> - - } - label={collateral.name} - /> - } - label={{ - start: depositMode === "remove" - ? "Decrease your collateral" - : "Increase your collateral", - end: ( - { - setDepositMode(index === 1 ? "remove" : "add"); - depositChange.setValue("0"); - }} - selected={depositMode === "remove" ? 1 : 0} - /> - ), - }} - labelHeight={32} - placeholder="0.00" - secondary={{ - start: (depositChange.parsed && collPrice) - ? "$" + fmtnum(dn.mul(depositChange.parsed, collPrice)) - : "$0.00", - end: ( - { - depositChange.setValue(dn.toString(collMax)); - }} - /> - ), - }} - /> - } - footer={[[ - , - loanDetails.deposit && newLoanDetails.deposit && ( - -
{fmtnum(loanDetails.deposit, 2)}
-
{ARROW_RIGHT}
- - } - value={ - -
- {fmtnum(newLoanDetails.deposit, 2)} {TOKENS_BY_SYMBOL[collateral.symbol].name} -
- -
- } - /> - ), - ]]} - /> - - } - label="BOLD" - /> - } - label={{ - start: debtMode === "remove" - ? "Decrease your debt" - : "Increase your debt", - end: ( - { - setDebtMode(index === 1 ? "remove" : "add"); - debtChange.setValue("0"); - }} - selected={debtMode === "remove" ? 1 : 0} - /> - ), - }} - labelHeight={32} - placeholder="0.00" - secondary={{ - start: debtChangeUsd - ? "$" + fmtnum(debtChangeUsd) - : "$0.00", - end: ( - boldMax && ( - { - debtChange.setValue(dn.toString(boldMax)); - }} - /> - ) - ), - }} - /> - } - footer={[ - [ - , - loanDetails.debt && newLoanDetails.debt && ( - -
{fmtnum(loanDetails.debt)}
-
{ARROW_RIGHT}
- - } - value={ - -
{fmtnum(newLoanDetails.debt)} BOLD
- -
- } - /> - ), - ], - ]} - /> - -
- - -
Liquidation risk
- - - {formatRisk(loanDetails.liquidationRisk)} -
- )} - after={newLoanDetails.liquidationRisk && ( - - - {formatRisk(newLoanDetails.liquidationRisk)} - - )} - /> - - -
- LTV -
- - {loanDetails.ltv && ( -
- {fmtnum(dn.mul(loanDetails.ltv, 100))}% -
- )} -
- {ARROW_RIGHT} -
- {newLoanDetails.ltv && ( -
- {fmtnum(dn.mul(newLoanDetails.ltv, dn.lt(newLoanDetails.ltv, 0) ? 0 : 100))}% -
- )} -
-
- -
Liquidation price
- - {loanDetails.liquidationPrice && ( -
- ${fmtnum(loanDetails.liquidationPrice)} -
- )} -
- {ARROW_RIGHT} -
- {newLoanDetails.liquidationPrice && ( -
- ${fmtnum(newLoanDetails.liquidationPrice)} -
- )} -
-
-
-
-
- -
- -
- - ); -} - -function UpdateLeveragePositionPanel({ loan }: { loan: PositionLoan }) { - const router = useRouter(); - const account = useAccount(); - - const collateral = TOKENS_BY_SYMBOL[loan.collateral]; - const collPrice = usePrice(collateral.symbol); - - // loan details before the update - const initialLoanDetails = getLoanDetails( - loan.deposit, - loan.borrowed, - loan.interestRate, - collateral.collateralRatio, - collPrice, - ); - - // deposit change - const [depositMode, setDepositMode] = useState("add"); - const depositChange = useInputFieldValue((value) => dn.format(value)); - const [userLeverageFactor, setUserLeverageFactor] = useState(initialLoanDetails.leverageFactor ?? 1); - - const newDepositPreLeverage = depositChange.parsed - ? (depositMode === "remove" - ? dn.sub(initialLoanDetails.depositPreLeverage ?? dn.from(0, 18), depositChange.parsed) - : dn.add(initialLoanDetails.depositPreLeverage ?? dn.from(0, 18), depositChange.parsed)) - : initialLoanDetails.depositPreLeverage; - - const newDeposit = dn.mul(newDepositPreLeverage ?? dn.from(0, 18), userLeverageFactor); - - const totalPositionValue = dn.mul(newDeposit, collPrice ?? dn.from(0, 18)); - - const newDebt = dn.sub( - totalPositionValue, - dn.mul(newDepositPreLeverage ?? dn.from(0, 18), collPrice ?? dn.from(0, 18)), - ); - - const newLoanDetails = getLoanDetails( - newDeposit, - newDebt, - initialLoanDetails.interestRate, - collateral.collateralRatio, - collPrice, - ); - - const liquidationPrice = getLiquidationPriceFromLeverage( - userLeverageFactor, - collPrice ?? dn.from(0, 18), - collateral.collateralRatio, - ); - - const newDepositUsd = collPrice && dn.mul(newDeposit, collPrice); - - const ltv = newDeposit && newLoanDetails.debt && newDepositUsd && dn.gt(newDepositUsd, 0) - ? dn.div(newLoanDetails.debt, dn.mul(newDeposit, newDepositUsd)) - : null; - - // leverage factor - const leverageField = useLeverageField({ - collPrice: collPrice ?? dn.from(0, 18), - collToken: collateral, - depositPreLeverage: newDepositPreLeverage, - maxLtvAllowedRatio: 1, // allow up to the max. LTV - }); - - useEffect(() => { - if (leverageField.leverageFactor !== userLeverageFactor) { - setUserLeverageFactor(leverageField.leverageFactor); - } - }, [leverageField.leverageFactor]); - - const initialLeverageFactorSet = useRef(false); - useEffect(() => { - if (initialLoanDetails.leverageFactor && !initialLeverageFactorSet.current) { - leverageField.updateLeverageFactor(initialLoanDetails.leverageFactor); - initialLeverageFactorSet.current = true; - } - }, [leverageField.updateLeverageFactor, initialLoanDetails.leverageFactor]); - - const depositMax = depositMode === "remove" - ? initialLoanDetails.depositPreLeverage - : dn.sub(ACCOUNT_BALANCES[collateral.symbol], ETH_MAX_RESERVE); - - const [agreeToLiquidationRisk, setAgreeToLiquidationRisk] = useState(false); - - const showAgreeToLiquidationRisk = ltv - ? dn.gt(ltv, newLoanDetails.maxLtvAllowed) - : false; - - const allowSubmit = account.isConnected && ( - !showAgreeToLiquidationRisk || agreeToLiquidationRisk - ) && ( - !dn.eq( - initialLoanDetails.deposit ?? dn.from(0, 18), - newLoanDetails.deposit ?? dn.from(0, 18), - ) || ( - initialLoanDetails.leverageFactor !== newLoanDetails.leverageFactor - ) - ); - - return ( - <> - - } - label={collateral.name} - /> - } - label={{ - start: depositMode === "remove" - ? "Decrease your deposit" - : "Increase your deposit", - end: ( - { - setDepositMode(index === 1 ? "remove" : "add"); - depositChange.setValue("0"); - }} - selected={depositMode === "remove" ? 1 : 0} - /> - ), - }} - labelHeight={32} - placeholder="0.00" - secondary={{ - start: collPrice && ( - depositChange.parsed - ? "$" + fmtnum(dn.mul(depositChange.parsed, collPrice)) - : "$0.00" - ), - end: depositMax && ( - { - depositChange.setValue(dn.toString(depositMax)); - }} - /> - ), - }} - /> - } - footer={[[ - , - initialLoanDetails.depositPreLeverage && newDepositPreLeverage && ( - - - {fmtnum(initialLoanDetails.depositPreLeverage)} - - } - after={ - -
- {fmtnum(newDepositPreLeverage)} {collateral.name} -
- -
- } - fontSize={14} - /> - - } - /> - ), - ]]} - /> - - } - footer={[ - [ - , - , - ], - [ - , - - {fmtnum(initialLoanDetails.deposit)} {collateral.name} - - )} - after={newDepositPreLeverage && ( -
- {fmtnum(newLoanDetails.deposit)} {collateral.name} -
- )} - />, - ], - [ - , - {fmtnum(initialLoanDetails.leverageFactor, "1z")}x} - after={newLoanDetails.isUnderwater - ? "N/A" - : <>{fmtnum(newLoanDetails.leverageFactor, "1z")}x} - />, - ], - [ - , - , - ], - ]} - /> - - - - -
Liquidation risk
- - - {formatRisk(initialLoanDetails.liquidationRisk)} -
- )} - after={newLoanDetails.liquidationRisk && ( - - - {formatRisk(newLoanDetails.liquidationRisk)} - - )} - /> - - -
- LTV -
- - {newLoanDetails.ltv && `${fmtnum(dn.mul(newLoanDetails.ltv, 100))}%`} - - } - /> -
-
- - {newLoanDetails.isUnderwater - ? ( - -
- Your position is currently underwater. You need to add at least{" "} - {fmtnum(newLoanDetails.requiredCollateralToRecover)} - {" "} - {collateral.name} to bring it back above water. -
-
- ) - : showAgreeToLiquidationRisk - ? ( - -
- The maximum LTV for the position is{" "} - {fmtnum(dn.mul(newLoanDetails.maxLtv, 100))}%. Your updated position may be liquidated immediately. -
- -
- ) - : null} -
-
- -
- -
- - ); -} - -function UpdateRatePanel({ loan }: { loan: PositionLoan }) { - const router = useRouter(); - const account = useAccount(); - - const collateral = TOKENS_BY_SYMBOL[loan.collateral]; - const collPrice = usePrice(collateral.symbol); - - const deposit = useInputFieldValue((value) => `${dn.format(value)} ${collateral.symbol}`, { - defaultValue: dn.toString(loan.deposit), - }); - const debt = useInputFieldValue((value) => `${dn.format(value)} BOLD`, { - defaultValue: dn.toString(loan.borrowed), - }); - const interestRate = useInputFieldValue((value) => `${dn.format(value)} %`, { - defaultValue: dn.toString(dn.mul(loan.interestRate, 100)), - }); - - const loanDetails = getLoanDetails( - loan.deposit, - loan.borrowed, - dn.div(loan.interestRate, 100), - collateral.collateralRatio, - collPrice, - ); - - const newLoanDetails = getLoanDetails( - deposit.isEmpty ? null : deposit.parsed, - debt.isEmpty ? null : debt.parsed, - interestRate.parsed && dn.div(interestRate.parsed, 100), - collateral.collateralRatio, - collPrice, - ); - - const boldInterestPerYear = interestRate.parsed - && debt.parsed - && dn.mul(debt.parsed, dn.div(interestRate.parsed, 100)); - - const boldRedeemableInFront = dn.format( - getDebtBeforeRateBucketIndex( - interestRate.parsed - ? Math.round((dn.toNumber(interestRate.parsed) - INTEREST_RATE_MIN) / INTEREST_RATE_INCREMENT) - : 0, - ), - { compact: true }, - ); - - const allowSubmit = account.isConnected - && deposit.parsed - && dn.gt(deposit.parsed, 0) - && debt.parsed - && dn.gt(debt.parsed, 0) - && interestRate.parsed - && dn.gt(interestRate.parsed, 0); - - return ( - <> - - { - interestRate.setValue( - String(Math.round(lerp(INTEREST_RATE_MIN, INTEREST_RATE_MAX, value) * 10) / 10), - ); - }} - value={norm( - interestRate.parsed ? dn.toNumber(interestRate.parsed) : 0, - INTEREST_RATE_MIN, - INTEREST_RATE_MAX, - )} - /> - - } - label={content.borrowScreen.interestRateField.label} - placeholder="0.00" - secondary={{ - start: ( - -
- {boldInterestPerYear - ? fmtnum(boldInterestPerYear, 2) - : "−"} BOLD / year -
- -
- ), - end: ( - - {"Before you "} - - - {boldRedeemableInFront} - - {" BOLD to redeem"} - - - ), - }} - {...interestRate.inputFieldProps} - valueUnfocused={(!interestRate.isEmpty && interestRate.parsed) - ? ( - - - {fmtnum(interestRate.parsed, "1z")} - - - % per year - - - ) - : null} - /> - } - /> - -
- - -
Redemption risk
- - - {formatRisk(loanDetails.redemptionRisk)} -
- )} - after={newLoanDetails.redemptionRisk && ( - - - {formatRisk(newLoanDetails.redemptionRisk)} - - )} - /> - - - -
Interest rate / day
- -
- {boldInterestPerYear && ( - - ~{fmtnum(dn.div(boldInterestPerYear, 365))} BOLD - - )} -
- -
Annual interest rate is charged daily on the debt
-
per day
-
-
-
- -
- -
- - ); -} - -function ClosePositionPanel({ loan }: { loan: PositionLoan }) { - const router = useRouter(); - const account = useAccount(); - const collPrice = usePrice(loan.collateral); - const boldPriceUsd = usePrice("BOLD"); - const [tokenIndex, setTokenIndex] = useState(0); - - const collateral = TOKENS_BY_SYMBOL[loan.collateral]; - - if (!collPrice || !boldPriceUsd) { - return null; - } - - const loanDetails = getLoanDetails( - loan.deposit, - loan.borrowed, - loan.interestRate, - collateral.collateralRatio, - collPrice, - ); - - const repayWith = tokenIndex === 0 ? "BOLD" : collateral.symbol; - - const amountToRepay = repayWith === "BOLD" - ? (loanDetails.debt ?? dn.from(0)) - : (dn.div(loanDetails.debt ?? dn.from(0), collPrice)); - - const collToReclaim = repayWith === "BOLD" - ? loan.deposit - : dn.sub(loan.deposit, amountToRepay); - - const allowSubmit = account.isConnected; - - return ( - <> - - -
-
{fmtnum(amountToRepay)}
-
- ({ - label: ( - <> - {TOKENS_BY_SYMBOL[(["BOLD", collateral.symbol] as const)[tokenIndex]].name} - - {TOKENS_BY_SYMBOL[(["BOLD", collateral.symbol] as const)[tokenIndex]].symbol === "BOLD" - ? " account" - : " loan"} - - - ), - icon: , - })} - items={(["BOLD", collateral.symbol] as const).map((symbol) => ({ - icon: , - label: ( - <> - {collateral.name} {symbol === "BOLD" ? "(account balance)" : "(loan collateral)"} - - ), - }))} - menuWidth={300} - onSelect={setTokenIndex} - selected={tokenIndex} - /> - - } - footer={[[ - , - null, - ]]} - label="You repay with" - /> - -
-
{fmtnum(collToReclaim)}
-
-
-
- -
{collateral.name}
-
-
- - } - label="You reclaim" - footer={[[ - , - null, - ]]} - /> -
-
- You are repaying your debt and closing the position. {repayWith === "BOLD" - ? `The deposit will be returned to your wallet.` - : `To close yor position, a part of your collateral will be sold to pay back the debt. The rest of your collateral will be returned to your wallet.`} -
-
- -
- - ); -} - -function InputTokenBadge({ - background = true, - icon, - label, -}: { - background?: boolean; - icon?: ReactNode; - label: ReactNode; -}) { - return ( -
- {icon} -
- {label} -
-
- ); -} - function useTrove(troveId: string | null) { return useMemo(() => { if (troveId === null) { @@ -1271,79 +106,3 @@ function useTrove(troveId: string | null) { return position as PositionLoan | null; }, [troveId]); } - -function ValueUpdate({ - after, - before, - fontSize = 16, - tabularNums = true, -}: { - after: ReactNode; - before: ReactNode; - fontSize?: number; - tabularNums?: boolean; -}) { - return ( - -
{before}
-
{ARROW_RIGHT}
-
{after}
-
- ); -} - -function InfoBox({ - children, - gap = 16, -}: { - children: ReactNode; - gap?: number; -}) { - return ( -
- {children} -
- ); -} - -function WarningBox({ - children, -}: { - children: ReactNode; -}) { - return ( -
- {children} -
- ); -} diff --git a/frontend/app/src/screens/LoanScreen/PanelClosePosition.tsx b/frontend/app/src/screens/LoanScreen/PanelClosePosition.tsx new file mode 100644 index 000000000..a81bbc9e2 --- /dev/null +++ b/frontend/app/src/screens/LoanScreen/PanelClosePosition.tsx @@ -0,0 +1,198 @@ +import type { PositionLoan } from "@/src/types"; + +import { ConnectWarningBox } from "@/src/comps/ConnectWarningBox/ConnectWarningBox"; +import { Field } from "@/src/comps/Field/Field"; +import { fmtnum } from "@/src/formatting"; +import { getLoanDetails } from "@/src/liquity-math"; +import { useAccount } from "@/src/services/Ethereum"; +import { usePrice } from "@/src/services/Prices"; +import { css } from "@/styled-system/css"; +import { Button, Dropdown, TokenIcon, TOKENS_BY_SYMBOL, VFlex } from "@liquity2/uikit"; +import * as dn from "dnum"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +export function PanelClosePosition({ loan }: { loan: PositionLoan }) { + const router = useRouter(); + const account = useAccount(); + const collPrice = usePrice(loan.collateral); + const boldPriceUsd = usePrice("BOLD"); + const [tokenIndex, setTokenIndex] = useState(0); + + const collateral = TOKENS_BY_SYMBOL[loan.collateral]; + + if (!collPrice || !boldPriceUsd) { + return null; + } + + const loanDetails = getLoanDetails( + loan.deposit, + loan.borrowed, + loan.interestRate, + collateral.collateralRatio, + collPrice, + ); + + const repayWith = tokenIndex === 0 ? "BOLD" : collateral.symbol; + + const amountToRepay = repayWith === "BOLD" + ? (loanDetails.debt ?? dn.from(0)) + : (dn.div(loanDetails.debt ?? dn.from(0), collPrice)); + + const collToReclaim = repayWith === "BOLD" + ? loan.deposit + : dn.sub(loan.deposit, amountToRepay); + + const allowSubmit = account.isConnected; + + return ( + <> + + +
+
{fmtnum(amountToRepay)}
+
+ ({ + label: ( + <> + {TOKENS_BY_SYMBOL[(["BOLD", collateral.symbol] as const)[tokenIndex]].name} + + {TOKENS_BY_SYMBOL[(["BOLD", collateral.symbol] as const)[tokenIndex]].symbol === "BOLD" + ? " account" + : " loan"} + + + ), + icon: , + })} + items={(["BOLD", collateral.symbol] as const).map((symbol) => ({ + icon: , + label: ( + <> + {collateral.name} {symbol === "BOLD" ? "(account balance)" : "(loan collateral)"} + + ), + }))} + menuWidth={300} + onSelect={setTokenIndex} + selected={tokenIndex} + /> + + } + footer={[[ + , + null, + ]]} + label="You repay with" + /> + +
+
{fmtnum(collToReclaim)}
+
+
+
+ +
{collateral.name}
+
+
+ + } + label="You reclaim" + footer={[[ + , + null, + ]]} + /> +
+
+ You are repaying your debt and closing the position. {repayWith === "BOLD" + ? `The deposit will be returned to your wallet.` + : `To close yor position, a part of your collateral will be sold to pay back the debt. The rest of your collateral will be returned to your wallet.`} +
+
+ +
+ + ); +} diff --git a/frontend/app/src/screens/LoanScreen/PanelUpdateBorrowPosition.tsx b/frontend/app/src/screens/LoanScreen/PanelUpdateBorrowPosition.tsx new file mode 100644 index 000000000..d55ac31ca --- /dev/null +++ b/frontend/app/src/screens/LoanScreen/PanelUpdateBorrowPosition.tsx @@ -0,0 +1,358 @@ +"use client"; + +import type { PositionLoan } from "@/src/types"; + +import { ConnectWarningBox } from "@/src/comps/ConnectWarningBox/ConnectWarningBox"; +import { Field } from "@/src/comps/Field/Field"; +import { InfoBox } from "@/src/comps/InfoBox/InfoBox"; +import { InputTokenBadge } from "@/src/comps/InputTokenBadge/InputTokenBadge"; +import { ValueUpdate } from "@/src/comps/ValueUpdate/ValueUpdate"; +import { ETH_MAX_RESERVE } from "@/src/constants"; +import { ACCOUNT_BALANCES } from "@/src/demo-mode"; +import { useInputFieldValue } from "@/src/form-utils"; +import { fmtnum, formatRisk } from "@/src/formatting"; +import { getLoanDetails } from "@/src/liquity-math"; +import { useAccount } from "@/src/services/Ethereum"; +import { usePrice } from "@/src/services/Prices"; +import { riskLevelToStatusMode } from "@/src/uikit-utils"; +import { css } from "@/styled-system/css"; +import { + Button, + HFlex, + InfoTooltip, + InputField, + StatusDot, + Tabs, + TextButton, + TokenIcon, + TOKENS_BY_SYMBOL, + VFlex, +} from "@liquity2/uikit"; + +import * as dn from "dnum"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +type RelativeFieldMode = "add" | "remove"; + +const ARROW_RIGHT = "→"; + +export function PanelUpdateBorrowPosition({ loan }: { loan: PositionLoan }) { + const router = useRouter(); + const account = useAccount(); + + const collateral = TOKENS_BY_SYMBOL[loan.collateral]; + const collPrice = usePrice(collateral.symbol); + const boldPriceUsd = usePrice("BOLD") ?? dn.from(0, 18); + + // deposit change + const [depositMode, setDepositMode] = useState("add"); + const depositChange = useInputFieldValue((value) => dn.format(value)); + + // deposit update + const newDeposit = depositChange.parsed && ( + depositMode === "remove" + ? dn.sub(loan.deposit, depositChange.parsed) + : dn.add(loan.deposit, depositChange.parsed) + ); + + const collMax = depositMode === "remove" ? loan.deposit : dn.sub( + ACCOUNT_BALANCES[collateral.symbol], + ETH_MAX_RESERVE, + ); + + // debt change + const [debtMode, setDebtMode] = useState("add"); + const debtChange = useInputFieldValue((value) => dn.format(value)); + const debtChangeUsd = debtChange.parsed && dn.mul(debtChange.parsed, boldPriceUsd); + + const newDebt = debtChange.parsed && ( + debtMode === "remove" + ? dn.sub(loan.borrowed, debtChange.parsed) + : dn.add(loan.borrowed, debtChange.parsed) + ); + + const boldMax = debtMode === "remove" ? ACCOUNT_BALANCES["BOLD"] : null; + + const loanDetails = getLoanDetails( + loan.deposit, + loan.borrowed, + loan.interestRate, + collateral.collateralRatio, + collPrice, + ); + + const newLoanDetails = getLoanDetails( + newDeposit, + newDebt, + loanDetails.interestRate, + collateral.collateralRatio, + collPrice, + ); + + const allowSubmit = account.isConnected && ( + !dn.eq(loanDetails.deposit ?? dn.from(0, 18), newLoanDetails.deposit ?? dn.from(0, 18)) + || !dn.eq(loanDetails.debt ?? dn.from(0, 18), newLoanDetails.debt ?? dn.from(0, 18)) + ); + + return ( + <> + + } + label={collateral.name} + /> + } + label={{ + start: depositMode === "remove" + ? "Decrease your collateral" + : "Increase your collateral", + end: ( + { + setDepositMode(index === 1 ? "remove" : "add"); + depositChange.setValue("0"); + }} + selected={depositMode === "remove" ? 1 : 0} + /> + ), + }} + labelHeight={32} + placeholder="0.00" + secondary={{ + start: (depositChange.parsed && collPrice) + ? "$" + fmtnum(dn.mul(depositChange.parsed, collPrice)) + : "$0.00", + end: ( + { + depositChange.setValue(dn.toString(collMax)); + }} + /> + ), + }} + /> + } + footer={[[ + , + loanDetails.deposit && newLoanDetails.deposit && ( + +
{fmtnum(loanDetails.deposit, 2)}
+
{ARROW_RIGHT}
+ + } + value={ + +
+ {fmtnum(newLoanDetails.deposit, 2)} {TOKENS_BY_SYMBOL[collateral.symbol].name} +
+ +
+ } + /> + ), + ]]} + /> + + } + label="BOLD" + /> + } + label={{ + start: debtMode === "remove" + ? "Decrease your debt" + : "Increase your debt", + end: ( + { + setDebtMode(index === 1 ? "remove" : "add"); + debtChange.setValue("0"); + }} + selected={debtMode === "remove" ? 1 : 0} + /> + ), + }} + labelHeight={32} + placeholder="0.00" + secondary={{ + start: debtChangeUsd + ? "$" + fmtnum(debtChangeUsd) + : "$0.00", + end: ( + boldMax && ( + { + debtChange.setValue(dn.toString(boldMax)); + }} + /> + ) + ), + }} + /> + } + footer={[ + [ + , + loanDetails.debt && newLoanDetails.debt && ( + +
{fmtnum(loanDetails.debt)}
+
{ARROW_RIGHT}
+ + } + value={ + +
{fmtnum(newLoanDetails.debt)} BOLD
+ +
+ } + /> + ), + ], + ]} + /> + +
+ + +
Liquidation risk
+ + + {formatRisk(loanDetails.liquidationRisk)} +
+ )} + after={newLoanDetails.liquidationRisk && ( + + + {formatRisk(newLoanDetails.liquidationRisk)} + + )} + /> + + +
+ LTV +
+ + {loanDetails.ltv && ( +
+ {fmtnum(dn.mul(loanDetails.ltv, 100))}% +
+ )} +
+ {ARROW_RIGHT} +
+ {newLoanDetails.ltv && ( +
+ {fmtnum(dn.mul(newLoanDetails.ltv, dn.lt(newLoanDetails.ltv, 0) ? 0 : 100))}% +
+ )} +
+
+ +
Liquidation price
+ + {loanDetails.liquidationPrice && ( +
+ ${fmtnum(loanDetails.liquidationPrice)} +
+ )} +
+ {ARROW_RIGHT} +
+ {newLoanDetails.liquidationPrice && ( +
+ ${fmtnum(newLoanDetails.liquidationPrice)} +
+ )} +
+
+
+
+
+ +
+ +
+ + ); +} diff --git a/frontend/app/src/screens/LoanScreen/PanelUpdateLeveragePosition.tsx b/frontend/app/src/screens/LoanScreen/PanelUpdateLeveragePosition.tsx new file mode 100644 index 000000000..76c3a61f3 --- /dev/null +++ b/frontend/app/src/screens/LoanScreen/PanelUpdateLeveragePosition.tsx @@ -0,0 +1,403 @@ +import type { PositionLoan } from "@/src/types"; + +import { ConnectWarningBox } from "@/src/comps/ConnectWarningBox/ConnectWarningBox"; +import { Field } from "@/src/comps/Field/Field"; +import { InfoBox } from "@/src/comps/InfoBox/InfoBox"; +import { InputTokenBadge } from "@/src/comps/InputTokenBadge/InputTokenBadge"; +import { LeverageField, useLeverageField } from "@/src/comps/LeverageField/LeverageField"; +import { ValueUpdate } from "@/src/comps/ValueUpdate/ValueUpdate"; +import { WarningBox } from "@/src/comps/WarningBox/WarningBox"; +import { ETH_MAX_RESERVE } from "@/src/constants"; +import { ACCOUNT_BALANCES } from "@/src/demo-mode"; +import { useInputFieldValue } from "@/src/form-utils"; +import { fmtnum, formatRisk } from "@/src/formatting"; +import { getLiquidationPriceFromLeverage, getLoanDetails } from "@/src/liquity-math"; +import { useAccount } from "@/src/services/Ethereum"; +import { usePrice } from "@/src/services/Prices"; +import { riskLevelToStatusMode } from "@/src/uikit-utils"; +import { css } from "@/styled-system/css"; +import { + Button, + Checkbox, + HFlex, + InfoTooltip, + InputField, + StatusDot, + Tabs, + TextButton, + TokenIcon, + TOKENS_BY_SYMBOL, + VFlex, +} from "@liquity2/uikit"; +import * as dn from "dnum"; +import { useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; + +type RelativeFieldMode = "add" | "remove"; + +export function PanelUpdateLeveragePosition({ loan }: { loan: PositionLoan }) { + const router = useRouter(); + const account = useAccount(); + + const collateral = TOKENS_BY_SYMBOL[loan.collateral]; + const collPrice = usePrice(collateral.symbol); + + // loan details before the update + const initialLoanDetails = getLoanDetails( + loan.deposit, + loan.borrowed, + loan.interestRate, + collateral.collateralRatio, + collPrice, + ); + + // deposit change + const [depositMode, setDepositMode] = useState("add"); + const depositChange = useInputFieldValue((value) => dn.format(value)); + const [userLeverageFactor, setUserLeverageFactor] = useState(initialLoanDetails.leverageFactor ?? 1); + + const newDepositPreLeverage = depositChange.parsed + ? (depositMode === "remove" + ? dn.sub(initialLoanDetails.depositPreLeverage ?? dn.from(0, 18), depositChange.parsed) + : dn.add(initialLoanDetails.depositPreLeverage ?? dn.from(0, 18), depositChange.parsed)) + : initialLoanDetails.depositPreLeverage; + + const newDeposit = dn.mul(newDepositPreLeverage ?? dn.from(0, 18), userLeverageFactor); + + const totalPositionValue = dn.mul(newDeposit, collPrice ?? dn.from(0, 18)); + + const newDebt = dn.sub( + totalPositionValue, + dn.mul(newDepositPreLeverage ?? dn.from(0, 18), collPrice ?? dn.from(0, 18)), + ); + + const newLoanDetails = getLoanDetails( + newDeposit, + newDebt, + initialLoanDetails.interestRate, + collateral.collateralRatio, + collPrice, + ); + + const liquidationPrice = getLiquidationPriceFromLeverage( + userLeverageFactor, + collPrice ?? dn.from(0, 18), + collateral.collateralRatio, + ); + + const newDepositUsd = collPrice && dn.mul(newDeposit, collPrice); + + const ltv = newDeposit && newLoanDetails.debt && newDepositUsd && dn.gt(newDepositUsd, 0) + ? dn.div(newLoanDetails.debt, dn.mul(newDeposit, newDepositUsd)) + : null; + + // leverage factor + const leverageField = useLeverageField({ + collPrice: collPrice ?? dn.from(0, 18), + collToken: collateral, + depositPreLeverage: newDepositPreLeverage, + maxLtvAllowedRatio: 1, // allow up to the max. LTV + }); + + useEffect(() => { + if (leverageField.leverageFactor !== userLeverageFactor) { + setUserLeverageFactor(leverageField.leverageFactor); + } + }, [leverageField.leverageFactor]); + + const initialLeverageFactorSet = useRef(false); + useEffect(() => { + if (initialLoanDetails.leverageFactor && !initialLeverageFactorSet.current) { + leverageField.updateLeverageFactor(initialLoanDetails.leverageFactor); + initialLeverageFactorSet.current = true; + } + }, [leverageField.updateLeverageFactor, initialLoanDetails.leverageFactor]); + + const depositMax = depositMode === "remove" + ? initialLoanDetails.depositPreLeverage + : dn.sub(ACCOUNT_BALANCES[collateral.symbol], ETH_MAX_RESERVE); + + const [agreeToLiquidationRisk, setAgreeToLiquidationRisk] = useState(false); + + const showAgreeToLiquidationRisk = ltv + ? dn.gt(ltv, newLoanDetails.maxLtvAllowed) + : false; + + const allowSubmit = account.isConnected && ( + !showAgreeToLiquidationRisk || agreeToLiquidationRisk + ) && ( + !dn.eq( + initialLoanDetails.deposit ?? dn.from(0, 18), + newLoanDetails.deposit ?? dn.from(0, 18), + ) || ( + initialLoanDetails.leverageFactor !== newLoanDetails.leverageFactor + ) + ); + + return ( + <> + + } + label={collateral.name} + /> + } + label={{ + start: depositMode === "remove" + ? "Decrease your deposit" + : "Increase your deposit", + end: ( + { + setDepositMode(index === 1 ? "remove" : "add"); + depositChange.setValue("0"); + }} + selected={depositMode === "remove" ? 1 : 0} + /> + ), + }} + labelHeight={32} + placeholder="0.00" + secondary={{ + start: collPrice && ( + depositChange.parsed + ? "$" + fmtnum(dn.mul(depositChange.parsed, collPrice)) + : "$0.00" + ), + end: depositMax && ( + { + depositChange.setValue(dn.toString(depositMax)); + }} + /> + ), + }} + /> + } + footer={[[ + , + initialLoanDetails.depositPreLeverage && newDepositPreLeverage && ( + + + {fmtnum(initialLoanDetails.depositPreLeverage)} + + } + after={ + +
+ {fmtnum(newDepositPreLeverage)} {collateral.name} +
+ +
+ } + fontSize={14} + /> + + } + /> + ), + ]]} + /> + + } + footer={[ + [ + , + , + ], + [ + , + + {fmtnum(initialLoanDetails.deposit)} {collateral.name} + + )} + after={newDepositPreLeverage && ( +
+ {fmtnum(newLoanDetails.deposit)} {collateral.name} +
+ )} + />, + ], + [ + , + {fmtnum(initialLoanDetails.leverageFactor, "1z")}x} + after={newLoanDetails.isUnderwater + ? "N/A" + : <>{fmtnum(newLoanDetails.leverageFactor, "1z")}x} + />, + ], + [ + , + , + ], + ]} + /> + + + + +
Liquidation risk
+ + + {formatRisk(initialLoanDetails.liquidationRisk)} +
+ )} + after={newLoanDetails.liquidationRisk && ( + + + {formatRisk(newLoanDetails.liquidationRisk)} + + )} + /> + + +
+ LTV +
+ + {newLoanDetails.ltv && `${fmtnum(dn.mul(newLoanDetails.ltv, 100))}%`} + + } + /> +
+
+ + {newLoanDetails.isUnderwater + ? ( + +
+ Your position is currently underwater. You need to add at least{" "} + {fmtnum(newLoanDetails.requiredCollateralToRecover)} + {" "} + {collateral.name} to bring it back above water. +
+
+ ) + : showAgreeToLiquidationRisk + ? ( + +
+ The maximum LTV for the position is{" "} + {fmtnum(dn.mul(newLoanDetails.maxLtv, 100))}%. Your updated position may be liquidated immediately. +
+ +
+ ) + : null} +
+
+ +
+ +
+ + ); +} diff --git a/frontend/app/src/screens/LoanScreen/PanelUpdateRate.tsx b/frontend/app/src/screens/LoanScreen/PanelUpdateRate.tsx new file mode 100644 index 000000000..9052168ed --- /dev/null +++ b/frontend/app/src/screens/LoanScreen/PanelUpdateRate.tsx @@ -0,0 +1,258 @@ +import type { PositionLoan } from "@/src/types"; + +import { ConnectWarningBox } from "@/src/comps/ConnectWarningBox/ConnectWarningBox"; +import { Field } from "@/src/comps/Field/Field"; +import { InfoBox } from "@/src/comps/InfoBox/InfoBox"; +import { ValueUpdate } from "@/src/comps/ValueUpdate/ValueUpdate"; +import { INTEREST_RATE_INCREMENT, INTEREST_RATE_MAX, INTEREST_RATE_MIN } from "@/src/constants"; +import content from "@/src/content"; +import { getDebtBeforeRateBucketIndex, INTEREST_CHART } from "@/src/demo-mode"; +import { useInputFieldValue } from "@/src/form-utils"; +import { fmtnum, formatRisk } from "@/src/formatting"; +import { getLoanDetails } from "@/src/liquity-math"; +import { useAccount } from "@/src/services/Ethereum"; +import { usePrice } from "@/src/services/Prices"; +import { infoTooltipProps, riskLevelToStatusMode } from "@/src/uikit-utils"; +import { css } from "@/styled-system/css"; +import { + Button, + HFlex, + InfoTooltip, + InputField, + lerp, + norm, + Slider, + StatusDot, + TOKENS_BY_SYMBOL, +} from "@liquity2/uikit"; +import * as dn from "dnum"; +import { useRouter } from "next/navigation"; + +export function PanelUpdateRate({ loan }: { loan: PositionLoan }) { + const router = useRouter(); + const account = useAccount(); + + const collateral = TOKENS_BY_SYMBOL[loan.collateral]; + const collPrice = usePrice(collateral.symbol); + + const deposit = useInputFieldValue((value) => `${dn.format(value)} ${collateral.symbol}`, { + defaultValue: dn.toString(loan.deposit), + }); + const debt = useInputFieldValue((value) => `${dn.format(value)} BOLD`, { + defaultValue: dn.toString(loan.borrowed), + }); + const interestRate = useInputFieldValue((value) => `${dn.format(value)} %`, { + defaultValue: dn.toString(dn.mul(loan.interestRate, 100)), + }); + + const loanDetails = getLoanDetails( + loan.deposit, + loan.borrowed, + dn.div(loan.interestRate, 100), + collateral.collateralRatio, + collPrice, + ); + + const newLoanDetails = getLoanDetails( + deposit.isEmpty ? null : deposit.parsed, + debt.isEmpty ? null : debt.parsed, + interestRate.parsed && dn.div(interestRate.parsed, 100), + collateral.collateralRatio, + collPrice, + ); + + const boldInterestPerYear = interestRate.parsed + && debt.parsed + && dn.mul(debt.parsed, dn.div(interestRate.parsed, 100)); + + const boldRedeemableInFront = dn.format( + getDebtBeforeRateBucketIndex( + interestRate.parsed + ? Math.round((dn.toNumber(interestRate.parsed) - INTEREST_RATE_MIN) / INTEREST_RATE_INCREMENT) + : 0, + ), + { compact: true }, + ); + + const allowSubmit = account.isConnected + && deposit.parsed + && dn.gt(deposit.parsed, 0) + && debt.parsed + && dn.gt(debt.parsed, 0) + && interestRate.parsed + && dn.gt(interestRate.parsed, 0); + + return ( + <> + + { + interestRate.setValue( + String(Math.round(lerp(INTEREST_RATE_MIN, INTEREST_RATE_MAX, value) * 10) / 10), + ); + }} + value={norm( + interestRate.parsed ? dn.toNumber(interestRate.parsed) : 0, + INTEREST_RATE_MIN, + INTEREST_RATE_MAX, + )} + /> + + } + label={content.borrowScreen.interestRateField.label} + placeholder="0.00" + secondary={{ + start: ( + +
+ {boldInterestPerYear + ? fmtnum(boldInterestPerYear, 2) + : "−"} BOLD / year +
+ +
+ ), + end: ( + + {"Before you "} + + + {boldRedeemableInFront} + + {" BOLD to redeem"} + + + ), + }} + {...interestRate.inputFieldProps} + valueUnfocused={(!interestRate.isEmpty && interestRate.parsed) + ? ( + + + {fmtnum(interestRate.parsed, "1z")} + + + % per year + + + ) + : null} + /> + } + /> + +
+ + +
Redemption risk
+ + + {formatRisk(loanDetails.redemptionRisk)} +
+ )} + after={newLoanDetails.redemptionRisk && ( + + + {formatRisk(newLoanDetails.redemptionRisk)} + + )} + /> + + + +
Interest rate / day
+ +
+ {boldInterestPerYear && ( + + ~{fmtnum(dn.div(boldInterestPerYear, 365))} BOLD + + )} +
+ +
Annual interest rate is charged daily on the debt
+
per day
+
+
+
+ +
+ +
+ + ); +}