From 1ab37e16d2a3ac67bf641bc3d069f487b102ae44 Mon Sep 17 00:00:00 2001 From: Kobe Leenders Date: Thu, 7 Dec 2023 04:55:15 +0100 Subject: [PATCH 1/2] feat: improved action box --- .../components/common/ActionBox/ActionBox.tsx | 202 +++++++++++- .../common/ActionBox/ActionBoxActions.tsx | 67 ++++ .../common/ActionBox/ActionBoxTokens.tsx | 288 ++++++++++++------ .../src/components/ui/select.tsx | 10 +- 4 files changed, 456 insertions(+), 111 deletions(-) create mode 100644 apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxActions.tsx diff --git a/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBox.tsx b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBox.tsx index f8d2ec8cdf..befe03660b 100644 --- a/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBox.tsx +++ b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBox.tsx @@ -1,27 +1,83 @@ import React from "react"; import { usdFormatter } from "@mrgnlabs/mrgn-common"; -import { useUiStore } from "~/store"; +import { ActionType } from "@mrgnlabs/marginfi-v2-ui-state"; +import { WSOL_MINT, numeralFormatter } from "@mrgnlabs/mrgn-common"; +import { ChevronDownIcon } from "@radix-ui/react-icons"; +import { useMrgnlendStore, useUiStore } from "~/store"; +import { MarginfiActionParams, closeBalance, executeLendingAction } from "~/utils"; +import { LendingModes } from "~/types"; +import { useWalletContext } from "~/hooks/useWalletContext"; import { MrgnLabeledSwitch } from "~/components/common/MrgnLabeledSwitch"; import { ActionBoxTokens } from "~/components/common/ActionBox/ActionBoxTokens"; import { Input } from "~/components/ui/input"; -import { Button } from "~/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; +import { IconWallet } from "~/components/ui/icons"; -import { LendingModes } from "~/types"; +import { ActionBoxActions } from "./ActionBoxActions"; export const ActionBox = () => { + const [mfiClient, nativeSolBalance, setIsRefreshingStore, fetchMrgnlendState, selectedAccount] = useMrgnlendStore( + (state) => [ + state.marginfiClient, + state.nativeSolBalance, + state.setIsRefreshingStore, + state.fetchMrgnlendState, + state.selectedAccount, + ] + ); const [lendingMode, setLendingMode, selectedToken, setSelectedToken] = useUiStore((state) => [ state.lendingMode, state.setLendingMode, state.selectedToken, state.setSelectedToken, ]); + const { walletContextState } = useWalletContext(); + const [selectedMode, setSelectMode] = React.useState(); const [preview, setPreview] = React.useState<{ key: string; value: string }[]>([]); const [amount, setAmount] = React.useState(null); const amountInputRef = React.useRef(null); + const isDust = React.useMemo(() => selectedToken?.isActive && selectedToken?.position.isDust, [selectedToken]); + const showCloseBalance = React.useMemo(() => selectedMode === ActionType.Withdraw && isDust, [selectedMode, isDust]); + const maxAmount = React.useMemo(() => { + switch (selectedMode) { + case ActionType.Deposit: + return selectedToken?.userInfo.maxDeposit; + case ActionType.Withdraw: + return selectedToken?.userInfo.maxWithdraw; + case ActionType.Borrow: + return selectedToken?.userInfo.maxBorrow; + case ActionType.Repay: + return selectedToken?.userInfo.maxRepay; + } + }, [selectedToken, selectedMode]); + const isInputDisabled = React.useMemo(() => maxAmount === 0 && !showCloseBalance, [maxAmount, showCloseBalance]); + const walletAmount = React.useMemo( + () => + selectedToken?.info.state.mint.equals(WSOL_MINT) + ? selectedToken?.userInfo.tokenAccount.balance + nativeSolBalance + : selectedToken?.userInfo.tokenAccount.balance, + [selectedToken] + ); + const hasActivePosition = React.useMemo( + () => + selectedToken?.isActive && + ((!selectedToken.isLending && lendingMode === LendingModes.LEND) || + (selectedToken.isLending && lendingMode === LendingModes.BORROW)), + [selectedToken, lendingMode] + ); + + React.useEffect(() => { + if (lendingMode === LendingModes.LEND) { + setSelectMode(ActionType.Deposit); + } else if (lendingMode === LendingModes.BORROW) { + setSelectMode(ActionType.Borrow); + } + }, [lendingMode, setSelectMode, selectedToken]); + React.useEffect(() => { if (!selectedToken || !amount) { setPreview([]); @@ -38,11 +94,11 @@ export const ActionBox = () => { value: usdFormatter.format(amount), }, { - key: "Some property", + key: "Some propertya", value: "--", }, { - key: "Some property", + key: "Some propertyb", value: "--", }, ]); @@ -50,9 +106,85 @@ export const ActionBox = () => { React.useEffect(() => { if (!selectedToken || !amountInputRef.current) return; + setAmount(0); amountInputRef.current.focus(); }, [selectedToken]); + const executeLendingActionCb = React.useCallback( + async ({ + mfiClient, + actionType: currentAction, + bank, + amount: borrowOrLendAmount, + nativeSolBalance, + marginfiAccount, + walletContextState, + }: MarginfiActionParams) => { + await executeLendingAction({ + mfiClient, + actionType: currentAction, + bank, + amount: borrowOrLendAmount, + nativeSolBalance, + marginfiAccount, + walletContextState, + }); + + setAmount(0); + + // -------- Refresh state + try { + setIsRefreshingStore(true); + await fetchMrgnlendState(); + } catch (error: any) { + console.log("Error while reloading state"); + console.log(error); + } + }, + [fetchMrgnlendState, setIsRefreshingStore] + ); + + const handleCloseBalance = React.useCallback(async () => { + try { + await closeBalance({ marginfiAccount: selectedAccount, bank: selectedToken }); + } catch (error) { + return; + } + + setAmount(0); + + try { + setIsRefreshingStore(true); + await fetchMrgnlendState(); + } catch (error: any) { + console.log("Error while reloading state"); + console.log(error); + } + }, [selectedToken, selectedAccount, fetchMrgnlendState, setIsRefreshingStore]); + + const handleLendingAction = React.useCallback(async () => { + // TODO implement LST dialog + + await executeLendingActionCb({ + mfiClient, + actionType: selectedMode, + bank: selectedToken, + amount: amount, + nativeSolBalance, + marginfiAccount: selectedAccount, + walletContextState, + }); + }, [ + selectedMode, + selectedToken, + executeLendingActionCb, + mfiClient, + amount, + nativeSolBalance, + selectedAccount, + walletContextState, + ]); + return (
@@ -69,19 +201,75 @@ export const ActionBox = () => {

Supply. Earn interest. Borrow. Repeat.

-

You {lendingMode === LendingModes.LEND ? "supply" : "borrow"}

+
+ {hasActivePosition ? ( + + ) : ( +

You {lendingMode === LendingModes.LEND ? "supply" : "borrow"}

+ )} + {selectedToken && ( +
+
+ +
+ + {(walletAmount > 0.01 ? numeralFormatter(walletAmount) : "< 0.01").concat( + " ", + selectedToken?.meta.tokenSymbol + )} + +
setAmount(maxAmount)} className="text-base font-bold cursor-pointer"> + MAX +
+
+ )} +
setAmount(Number(e.target.value))} placeholder="0" className="bg-transparent w-full text-right outline-none focus-visible:outline-none focus-visible:ring-0 border-none text-3xl font-medium" />
- + (showCloseBalance ? handleCloseBalance() : handleLendingAction())} + /> {selectedToken !== null && amount !== null && preview.length > 0 && (
{preview.map((item) => ( diff --git a/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxActions.tsx b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxActions.tsx new file mode 100644 index 0000000000..ff19e707e1 --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxActions.tsx @@ -0,0 +1,67 @@ +import React from "react"; + +import { ActionType } from "@mrgnlabs/marginfi-v2-ui-state"; + +import { useUiStore } from "~/store"; + +import { Button } from "~/components/ui/button"; + +type ActionBoxActionsProps = { + selectedMode: ActionType; + amount: number; + maxAmount: number; + showCloseBalance: boolean; + handleAction: () => void; +}; + +export const ActionBoxActions = ({ + selectedMode, + amount, + maxAmount, + showCloseBalance, + handleAction, +}: ActionBoxActionsProps) => { + const [selectedToken] = useUiStore((state) => [state.selectedToken]); + + const isActionDisabled = React.useMemo(() => { + const isValidInput = amount > 0; + return (maxAmount === 0 || !isValidInput) && !showCloseBalance; + }, [amount, showCloseBalance, maxAmount]); + + const actionText = React.useMemo(() => { + if (!selectedToken) { + return "Select token and amount"; + } + + if (showCloseBalance) { + return "Close account"; + } + + if (maxAmount === 0) { + switch (selectedMode) { + case ActionType.Deposit: + return `Insufficient ${selectedToken.meta.tokenSymbol} in wallet`; + case ActionType.Withdraw: + return "Nothing to withdraw"; + case ActionType.Borrow: + return "Deposit a collateral first (lend)"; + case ActionType.Repay: + return `Insufficient ${selectedToken.meta.tokenSymbol} in wallet for loan repayment`; + default: + return "Invalid action"; + } + } + + if (amount <= 0) { + return "Add an amount"; + } + + return selectedMode; + }, [selectedMode, amount, selectedToken, maxAmount, showCloseBalance]); + + return ( + + ); +}; diff --git a/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxTokens.tsx b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxTokens.tsx index 2f49077d7d..588dd56265 100644 --- a/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxTokens.tsx +++ b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxTokens.tsx @@ -35,16 +35,42 @@ export const ActionBoxTokens = ({ currentToken, setCurrentToken }: ActionBoxToke const [searchQuery, setSearchQuery] = React.useState(""); const [isTokenPopoverOpen, setIsTokenPopoverOpen] = React.useState(false); + const calculateRate = React.useCallback( + (bank: ExtendedBankInfo) => + percentFormatter.format( + (lendingMode === LendingModes.LEND ? bank.info.state.lendingRate : bank.info.state.borrowingRate) + + (lendingMode === LendingModes.LEND && bank.info.state.emissions == Emissions.Lending + ? bank.info.state.emissionsRate + : 0) + + (lendingMode !== LendingModes.LEND && bank.info.state.emissions == Emissions.Borrowing + ? bank.info.state.emissionsRate + : 0) + ), + [lendingMode] + ); + const filteredBanks = React.useMemo(() => { const lowerCaseSearchQuery = searchQuery.toLowerCase(); return extendedBankInfos - .filter((bankInfo) => { - return bankInfo.meta.tokenSymbol.toLowerCase().includes(lowerCaseSearchQuery); - }) - .filter((bankInfo) => { - return lendingMode === LendingModes.LEND ? bankInfo.userInfo.tokenAccount.balance === 0 : true; - }); + .filter((bankInfo) => bankInfo.meta.tokenSymbol.toLowerCase().includes(lowerCaseSearchQuery)) + .filter((bankInfo) => (lendingMode === LendingModes.LEND ? bankInfo.userInfo.tokenAccount.balance === 0 : true)); + }, [extendedBankInfos, searchQuery]); + + const filteredBanksActiveLending = React.useMemo(() => { + const lowerCaseSearchQuery = searchQuery.toLowerCase(); + return extendedBankInfos + .filter((bankInfo) => bankInfo.meta.tokenSymbol.toLowerCase().includes(lowerCaseSearchQuery)) + .filter((bankInfo) => bankInfo.isActive && bankInfo.position?.isLending) + .sort((a, b) => b.userInfo.position?.amount - a.userInfo.position?.amount); + }, [extendedBankInfos, searchQuery]); + + const filteredBanksActiveBorrowing = React.useMemo(() => { + const lowerCaseSearchQuery = searchQuery.toLowerCase(); + return extendedBankInfos + .filter((bankInfo) => bankInfo.meta.tokenSymbol.toLowerCase().includes(lowerCaseSearchQuery)) + .filter((bankInfo) => bankInfo.isActive && !bankInfo.position?.isLending) + .sort((a, b) => b.userInfo.position?.amount - a.userInfo.position?.amount); }, [extendedBankInfos, searchQuery]); const filteredBanksUserOwns = React.useMemo(() => { @@ -59,7 +85,16 @@ export const ActionBoxTokens = ({ currentToken, setCurrentToken }: ActionBoxToke return balance > 0 && bankInfo.meta.tokenSymbol.toLowerCase().includes(lowerCaseSearchQuery); }) .sort((a, b) => { - return b.userInfo.tokenAccount.balance - a.userInfo.tokenAccount.balance; + const isFirstWSOL = a.info.state.mint?.equals ? a.info.state.mint.equals(WSOL_MINT) : false; + const isSecondWSOL = b.info.state.mint?.equals ? b.info.state.mint.equals(WSOL_MINT) : false; + const firstBalance = + (isFirstWSOL ? a.userInfo.tokenAccount.balance + nativeSolBalance : a.userInfo.tokenAccount.balance) * + a.info.state.price; + const secondBalance = + (isSecondWSOL ? b.userInfo.tokenAccount.balance + nativeSolBalance : b.userInfo.tokenAccount.balance) * + b.info.state.price; + + return secondBalance - firstBalance; }); }, [extendedBankInfos, searchQuery]); @@ -74,7 +109,7 @@ export const ActionBoxTokens = ({ currentToken, setCurrentToken }: ActionBoxToke
- {(walletAmount > 0.01 ? numeralFormatter(walletAmount) : "< 0.01").concat( + {(walletAmount && walletAmount > 0.01 ? numeralFormatter(walletAmount) : "< 0.01").concat( " ", selectedToken?.meta.tokenSymbol )} @@ -265,9 +274,9 @@ export const ActionBox = () => {
(showCloseBalance ? handleCloseBalance() : handleLendingAction())} /> {selectedToken !== null && amount !== null && preview.length > 0 && ( diff --git a/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxActions.tsx b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxActions.tsx index ff19e707e1..a6b57396b7 100644 --- a/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxActions.tsx +++ b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxActions.tsx @@ -7,7 +7,7 @@ import { useUiStore } from "~/store"; import { Button } from "~/components/ui/button"; type ActionBoxActionsProps = { - selectedMode: ActionType; + selectedMode?: ActionType; amount: number; maxAmount: number; showCloseBalance: boolean; diff --git a/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxTokens.tsx b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxTokens.tsx index 588dd56265..c05179a481 100644 --- a/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxTokens.tsx +++ b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxTokens.tsx @@ -27,11 +27,7 @@ export const ActionBoxTokens = ({ currentToken, setCurrentToken }: ActionBoxToke state.extendedBankInfos, state.nativeSolBalance, ]); - const [lendingMode, selectedToken, setSelectedToken] = useUiStore((state) => [ - state.lendingMode, - state.selectedToken, - state.setSelectedToken, - ]); + const [lendingMode] = useUiStore((state) => [state.lendingMode]); const [searchQuery, setSearchQuery] = React.useState(""); const [isTokenPopoverOpen, setIsTokenPopoverOpen] = React.useState(false); @@ -62,7 +58,7 @@ export const ActionBoxTokens = ({ currentToken, setCurrentToken }: ActionBoxToke return extendedBankInfos .filter((bankInfo) => bankInfo.meta.tokenSymbol.toLowerCase().includes(lowerCaseSearchQuery)) .filter((bankInfo) => bankInfo.isActive && bankInfo.position?.isLending) - .sort((a, b) => b.userInfo.position?.amount - a.userInfo.position?.amount); + .sort((a, b) => (b.isActive ? b?.position?.amount : 0) - (a.isActive ? a?.position?.amount : 0)); }, [extendedBankInfos, searchQuery]); const filteredBanksActiveBorrowing = React.useMemo(() => { @@ -70,7 +66,7 @@ export const ActionBoxTokens = ({ currentToken, setCurrentToken }: ActionBoxToke return extendedBankInfos .filter((bankInfo) => bankInfo.meta.tokenSymbol.toLowerCase().includes(lowerCaseSearchQuery)) .filter((bankInfo) => bankInfo.isActive && !bankInfo.position?.isLending) - .sort((a, b) => b.userInfo.position?.amount - a.userInfo.position?.amount); + .sort((a, b) => (b.isActive ? b?.position?.amount : 0) - (a.isActive ? a?.position?.amount : 0)); }, [extendedBankInfos, searchQuery]); const filteredBanksUserOwns = React.useMemo(() => { @@ -131,7 +127,7 @@ export const ActionBoxTokens = ({ currentToken, setCurrentToken }: ActionBoxToke lendingMode === LendingModes.BORROW && "text-error" )} > - {calculateRate(selectedToken) + ` ${lendingMode === LendingModes.LEND ? "APY" : "APR"}`} + {calculateRate(currentToken) + ` ${lendingMode === LendingModes.LEND ? "APY" : "APR"}`}

@@ -150,9 +146,9 @@ export const ActionBoxTokens = ({ currentToken, setCurrentToken }: ActionBoxToke - setSelectedToken(extendedBankInfos.find((bank) => bank.address.toString() === value) || selectedToken) + setCurrentToken(extendedBankInfos.find((bank) => bank.address.toString() === value) || currentToken) } >