Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(mfi-v2-ui): account management QA implementation #944

Merged
merged 11 commits into from
Nov 21, 2024
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from "react";

import Image from "next/image";
import { IconAlertTriangle } from "@tabler/icons-react";
import { IconAlertTriangle, IconFolderShare } from "@tabler/icons-react";

import { usdFormatter, dynamicNumeralFormatter } from "@mrgnlabs/mrgn-common";
import { ActiveBankInfo, ActionType, ExtendedBankInfo } from "@mrgnlabs/marginfi-v2-ui-state";
Expand All @@ -26,15 +26,23 @@ interface PortfolioAssetCardProps {

export const PortfolioAssetCard = ({ bank, isInLendingMode, isBorrower = true }: PortfolioAssetCardProps) => {
const { rateAP } = useAssetItemData({ bank, isInLendingMode });
const [selectedAccount, marginfiAccounts, marginfiClient, fetchMrgnlendState, extendedBankInfos, nativeSolBalance] =
useMrgnlendStore((state) => [
state.selectedAccount,
state.marginfiAccounts,
state.marginfiClient,
state.fetchMrgnlendState,
state.extendedBankInfos,
state.nativeSolBalance,
]);
const [
selectedAccount,
marginfiAccounts,
marginfiClient,
fetchMrgnlendState,
extendedBankInfos,
nativeSolBalance,
accountSummary,
] = useMrgnlendStore((state) => [
state.selectedAccount,
state.marginfiAccounts,
state.marginfiClient,
state.fetchMrgnlendState,
state.extendedBankInfos,
state.nativeSolBalance,
state.accountSummary,
]);
const isIsolated = React.useMemo(() => bank.info.state.isIsolated, [bank]);

const isUserPositionPoorHealth = React.useMemo(() => {
Expand All @@ -52,8 +60,10 @@ export const PortfolioAssetCard = ({ bank, isInLendingMode, isBorrower = true }:
}, [bank]);

const [isMovePositionDialogOpen, setIsMovePositionDialogOpen] = React.useState<boolean>(false);
// const postionMovingPossible = React.useMemo(() => marginfiAccounts.length > 1, [marginfiAccounts.length]);
const postionMovingPossible = false;
const postionMovingPossible = React.useMemo(
() => marginfiAccounts.length > 1 && bank.position.isLending,
[marginfiAccounts.length, bank]
);

return (
<Accordion type="single" collapsible>
Expand Down Expand Up @@ -173,15 +183,14 @@ export const PortfolioAssetCard = ({ bank, isInLendingMode, isBorrower = true }:
</div>

{postionMovingPossible && (
<Button
<button
onClick={() => {
setIsMovePositionDialogOpen(true);
}}
variant={"ghost"}
className="w-max self-center underline"
className="my-2 w-max self-center text-muted-foreground/75 font-normal text-xs flex items-center gap-1 transition-opacity hover:text-muted-foreground/100"
>
Move position to another account
</Button>
<IconFolderShare size={14} /> Move position
</button>
)}

<MovePositionDialog
Expand All @@ -194,6 +203,7 @@ export const PortfolioAssetCard = ({ bank, isInLendingMode, isBorrower = true }:
fetchMrgnlendState={fetchMrgnlendState}
extendedBankInfos={extendedBankInfos}
nativeSolBalance={nativeSolBalance}
accountSummary={accountSummary}
/>
</AccordionContent>
</AccordionItem>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
import React from "react";

import Image from "next/image";
import { TransactionMessage, VersionedTransaction } from "@solana/web3.js";
import { Transaction, VersionedTransaction } from "@solana/web3.js";

import { usdFormatter, numeralFormatter, shortenAddress } from "@mrgnlabs/mrgn-common";
import { ActiveBankInfo, ActionType, ExtendedBankInfo } from "@mrgnlabs/marginfi-v2-ui-state";
import { usdFormatter, numeralFormatter, shortenAddress, percentFormatter } from "@mrgnlabs/mrgn-common";
import { AccountSummary, ActiveBankInfo, ActionType, ExtendedBankInfo } from "@mrgnlabs/marginfi-v2-ui-state";
import {
ActionMessageType,
captureSentryException,
checkLendActionAvailable,
extractErrorString,
MultiStepToastHandle,
} from "@mrgnlabs/mrgn-utils";
import { makeBundleTipIx, MarginfiAccountWrapper, MarginfiClient } from "@mrgnlabs/marginfi-client-v2";
import { MarginfiAccountWrapper, MarginfiClient } from "@mrgnlabs/marginfi-client-v2";

import { Button } from "~/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "~/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger } from "~/components/ui/select";
import { IconLoader } from "~/components/ui/icons";
import { useMoveSimulation } from "../../hooks";
import { ActionMessage } from "~/components";
import { IconArrowRight } from "@tabler/icons-react";

interface MovePositionDialogProps {
selectedAccount: MarginfiAccountWrapper | null;
Expand All @@ -31,6 +31,7 @@ interface MovePositionDialogProps {
fetchMrgnlendState: () => Promise<void>;
extendedBankInfos: ExtendedBankInfo[];
nativeSolBalance: number;
accountSummary: AccountSummary | null;
}

export const MovePositionDialog = ({
Expand All @@ -43,25 +44,62 @@ export const MovePositionDialog = ({
fetchMrgnlendState,
extendedBankInfos,
nativeSolBalance,
accountSummary,
}: MovePositionDialogProps) => {
const [accountToMoveTo, setAccountToMoveTo] = React.useState<MarginfiAccountWrapper | null>(null);
const [actionTxns, setActionTxns] = React.useState<VersionedTransaction[]>([]);
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const [actionTxns, setActionTxns] = React.useState<(Transaction | VersionedTransaction)[]>([]);
const [isExecutionLoading, setIsExecutionLoading] = React.useState<boolean>(false);
const [isSimulationLoading, setIsSimulationLoading] = React.useState<boolean>(false);
const [errorMessage, setErrorMessage] = React.useState<ActionMessageType | null>(null);
const [additionalActionMessages, setAdditionalActionMessages] = React.useState<ActionMessageType[]>([]);
const { handleSimulateTxns } = useMoveSimulation({
const { handleSimulateTxns, actionSummary, setActionSummary } = useMoveSimulation({
actionTxns,
marginfiClient,
accountToMoveTo,
selectedAccount,
activeBank: bank,
extendedBankInfos,
accountSummary,
setActionTxns,
setIsLoading,
setIsLoading: setIsSimulationLoading,
setErrorMessage,
});

const [accountLabels, setAccountLabels] = React.useState<Record<string, string>>({});
const [actionBlocked, setActionBlocked] = React.useState<boolean>(false);

const fetchAccountLabels = React.useCallback(async () => {
const fetchAccountLabel = async (account: MarginfiAccountWrapper) => {
const accountLabelReq = await fetch(`/api/user/account-label?account=${account.address.toBase58()}`);

if (!accountLabelReq.ok) {
console.error("Error fetching account labels");
return;
}

const accountLabelData = await accountLabelReq.json();
let accountLabel = `Account ${marginfiAccounts.findIndex((acc) => acc.address.equals(account.address)) + 1}`;

setAccountLabels((prev) => ({
...prev,
[account.address.toBase58()]: accountLabelData.data.label || accountLabel,
}));
};

marginfiAccounts.forEach(fetchAccountLabel);
}, [marginfiAccounts, setAccountLabels]);

const actionMessages = React.useMemo(() => {
if (bank.userInfo.maxWithdraw < bank.position.amount) {
setErrorMessage({
isEnabled: true,
actionMethod: "ERROR",
description: "Moving this position is blocked to prevent poor account health.",
});
setActionBlocked(true);
return [];
}

setAdditionalActionMessages([]);
const withdrawActionResult = checkLendActionAvailable({
amount: bank.position.amount,
Expand All @@ -81,7 +119,8 @@ export const MovePositionDialog = ({
banks: extendedBankInfos,
lendMode: ActionType.Deposit,
marginfiAccount: accountToMoveTo!,
});
}).filter((result) => !/^Insufficient .* in wallet\.$/.test(result.description ?? ""));
// filtering out insufficient balance messages since the user will always have enough balance after withdrawing

return [...withdrawActionResult, ...depositActionResult];
}, [bank, selectedAccount, extendedBankInfos, accountToMoveTo, nativeSolBalance]);
Expand All @@ -95,9 +134,12 @@ export const MovePositionDialog = ({
const isButtonDisabled = React.useMemo(() => {
if (!accountToMoveTo) return true;
if (actionMessages && actionMessages.filter((value) => value.isEnabled === false).length > 0) return true;
if (isLoading) return true;
if (isSimulationLoading) return true;
if (isExecutionLoading) return true;
if (errorMessage?.isEnabled) return true;
if (actionMessages.some((actionMessage) => actionMessage.actionMethod === "ERROR")) return true;
return false;
}, [accountToMoveTo, actionMessages, isLoading]);
}, [accountToMoveTo, actionMessages, isSimulationLoading, isExecutionLoading, errorMessage]);

const handleMovePosition = React.useCallback(async () => {
if (!marginfiClient || !accountToMoveTo || !actionTxns) {
Expand All @@ -110,7 +152,7 @@ export const MovePositionDialog = ({
},
]);
multiStepToast.start();
setIsLoading(true);
setIsExecutionLoading(true);
try {
await marginfiClient.processTransactions(actionTxns);
await fetchMrgnlendState();
Expand All @@ -125,25 +167,40 @@ export const MovePositionDialog = ({
wallet: marginfiClient?.wallet.publicKey.toBase58(),
});
} finally {
setIsLoading(false);
setIsExecutionLoading(false);
}
}, [marginfiClient, accountToMoveTo, actionTxns, fetchMrgnlendState, setIsOpen]);

React.useEffect(() => {
if (!accountToMoveTo) return;
handleSimulateTxns();
}, [accountToMoveTo, handleSimulateTxns]);
}, [accountToMoveTo]);

React.useEffect(() => {
if (marginfiAccounts.length > 0) {
fetchAccountLabels();
}
}, [marginfiAccounts, fetchAccountLabels]);

return (
<Dialog
open={isOpen}
onOpenChange={(value) => {
setIsOpen(value);
if (!value) {
setTimeout(() => {
setAccountToMoveTo(null);
setActionTxns([]);
setErrorMessage(null);
setAdditionalActionMessages([]);
setActionSummary(null);
}, 100);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Move position to another account</DialogTitle>
<DialogTitle>Move position</DialogTitle>
<DialogDescription>Move your position to another account</DialogDescription>
</DialogHeader>

Expand All @@ -159,65 +216,80 @@ export const MovePositionDialog = ({
{bank.position.usdValue < 0.01 ? "< $0.01" : usdFormatter.format(bank.position.usdValue)}
</dd>
</dl>
<div className="flex justify-between w-full items-center">
<span className="text-muted-foreground">Select account to move position to:</span>
<Select
onValueChange={(value) => {
setAccountToMoveTo(marginfiAccounts.find((account) => account.address.toBase58() === value) || null);
}}
>
<SelectTrigger className="w-max">
{accountToMoveTo
? `Account
${
marginfiAccounts.findIndex(
(account) => account.address.toBase58() === accountToMoveTo?.address.toBase58()
) + 1
}`
: "Select account"}
</SelectTrigger>
<SelectContent>
{marginfiAccounts
?.filter((acc) => acc.address.toBase58() !== selectedAccount?.address.toBase58())
.map((account, i) => (
<SelectItem key={i} value={account.address.toBase58()}>
Account{" "}
{marginfiAccounts.findIndex((_acc) => _acc.address.toBase58() === account?.address.toBase58()) +
1}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{accountToMoveTo && (
{!actionBlocked && (
<div className="flex justify-between w-full items-center">
<span className="text-muted-foreground">Account address:</span>
<div className="flex gap-1 items-center">
<span className="text-muted-foreground ">
{`${accountToMoveTo?.address.toBase58().slice(0, 8)}
...${accountToMoveTo?.address.toBase58().slice(-8)}`}
</span>
</div>
<span className="text-muted-foreground">Select account to move position to:</span>
<Select
onValueChange={(value) => {
setAccountToMoveTo(marginfiAccounts.find((account) => account.address.toBase58() === value) || null);
}}
>
<SelectTrigger className="w-max">
{accountToMoveTo
? accountLabels[accountToMoveTo?.address.toBase58()]
? accountLabels[accountToMoveTo?.address.toBase58()]
: `Account ${
marginfiAccounts.findIndex(
(acc) => acc.address.toBase58() === accountToMoveTo?.address.toBase58()
) + 1
}`
: "Select account"}
</SelectTrigger>
<SelectContent>
{marginfiAccounts
?.filter((acc) => acc.address.toBase58() !== selectedAccount?.address.toBase58())
.map((account, i) => (
<SelectItem key={i} value={account.address.toBase58()}>
{accountLabels[account.address.toBase58()]
? accountLabels[account.address.toBase58()]
: `Account ${
marginfiAccounts.findIndex(
(_acc) => _acc.address.toBase58() === account?.address.toBase58()
) + 1
}`}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{actionSummary && (
<dl className="grid grid-cols-2 gap-y-2 ">
<dt className="text-muted-foreground">Health:</dt>
<dd
className={`flex justify-end text-right items-center gap-2 text-right ${
actionSummary.health >= 0.5
? "text-success"
: actionSummary.health >= 0.25
? "text-alert-foreground"
: "text-destructive-foreground"
}`}
>
<>
{accountSummary?.healthFactor && percentFormatter.format(accountSummary?.healthFactor)}
<IconArrowRight width={12} height={12} />
{percentFormatter.format(actionSummary.health)}
</>
</dd>
</dl>
)}
</div>

{additionalActionMessages.concat(actionMessages).map(
(actionMessage, idx) =>
actionMessage.description && (
<div className="pb-6" key={idx}>
<div key={idx}>
<ActionMessage _actionMessage={actionMessage} />
</div>
)
)}

<Button className="w-full" onClick={handleMovePosition} disabled={isButtonDisabled}>
{isLoading ? <IconLoader /> : "Move position"}
{isExecutionLoading || isSimulationLoading ? <IconLoader /> : "Move position"}
</Button>

<div className=" text-xs text-muted-foreground text-center">
The transaction will look like there are no balance changes for this position. The position/funds will be
moved between marginfi accounts, but will remain on the same wallet.
The transaction will show no balance changes. The position will be moved between marginfi accounts owned by
the same wallet.
</div>
</DialogContent>
</Dialog>
Expand Down
Loading
Loading