diff --git a/apps/alpha-liquidator/src/config.ts b/apps/alpha-liquidator/src/config.ts index c66e5cf6f5..8d108be0e1 100644 --- a/apps/alpha-liquidator/src/config.ts +++ b/apps/alpha-liquidator/src/config.ts @@ -30,10 +30,20 @@ if (!process.env.RPC_ENDPOINT) { /*eslint sort-keys: "error"*/ let envSchema = z.object({ - ACCOUNT_COOL_DOWN_SECONDS: z.string().default("120").transform((s) => parseInt(s, 10)), + ACCOUNT_COOL_DOWN_SECONDS: z + .string() + .default("120") + .transform((s) => parseInt(s, 10)), /// 30 minutes - ACCOUNT_REFRESH_INTERVAL_SECONDS: z.string().default("1800").transform((s) => parseInt(s, 10)), - EXCLUDE_ISOLATED_BANKS: z.string().optional().default("false").transform((s) => s === "true" || s === "1"), + ACCOUNT_REFRESH_INTERVAL_SECONDS: z + .string() + .default("1800") + .transform((s) => parseInt(s, 10)), + EXCLUDE_ISOLATED_BANKS: z + .string() + .optional() + .default("false") + .transform((s) => s === "true" || s === "1"), IS_DEV: z .string() .optional() @@ -52,7 +62,11 @@ let envSchema = z.object({ return pkArrayStr.split(",").map((pkStr) => new PublicKey(pkStr)); }) .optional(), - MAX_SLIPPAGE_BPS: z.string().optional().default("250").transform((s) => Number.parseInt(s, 10)), + MAX_SLIPPAGE_BPS: z + .string() + .optional() + .default("250") + .transform((s) => Number.parseInt(s, 10)), MIN_LIQUIDATION_AMOUNT_USD_UI: z .string() .default("0.1") @@ -86,7 +100,11 @@ let envSchema = z.object({ } }), WS_ENDPOINT: z.string().url().optional(), - WS_RESET_INTERVAL_SECONDS: z.string().optional().default("300").transform((s) => parseInt(s, 10)), + WS_RESET_INTERVAL_SECONDS: z + .string() + .optional() + .default("300") + .transform((s) => parseInt(s, 10)), }); type EnvSchema = z.infer; diff --git a/apps/alpha-liquidator/src/liquidator.ts b/apps/alpha-liquidator/src/liquidator.ts index 6dbf7b1819..a45802f5ff 100644 --- a/apps/alpha-liquidator/src/liquidator.ts +++ b/apps/alpha-liquidator/src/liquidator.ts @@ -1,21 +1,20 @@ -import { AccountInfo, Connection, LAMPORTS_PER_SOL, PublicKey, VersionedTransaction } from "@solana/web3.js"; +import { Connection, LAMPORTS_PER_SOL, PublicKey, VersionedTransaction } from "@solana/web3.js"; import { MarginRequirementType, - MarginfiAccount, MarginfiAccountWrapper, MarginfiClient, PriceBias, USDC_DECIMALS, } from "@mrgnlabs/marginfi-client-v2"; -import { nativeToUi, NodeWallet, shortenAddress, sleep, toBigNumber, uiToNative, uiToNativeBigNumber } from "@mrgnlabs/mrgn-common"; +import { nativeToUi, NodeWallet, shortenAddress, sleep, uiToNative } from "@mrgnlabs/mrgn-common"; import BigNumber from "bignumber.js"; import { associatedAddress } from "@project-serum/anchor/dist/cjs/utils/token"; import { NATIVE_MINT } from "@solana/spl-token"; import { captureException, captureMessage, env_config } from "./config"; -import BN, { min } from "bn.js"; +import BN from "bn.js"; import { BankMetadataMap, loadBankMetadatas } from "./utils/bankMetadata"; import { Bank } from "@mrgnlabs/marginfi-client-v2/dist/models/bank"; -import { chunkedGetRawMultipleAccountInfos, convertBase64StringArrayToBuffer } from "./utils/chunks"; +import { chunkedGetRawMultipleAccountInfos } from "./utils/chunks"; const DUST_THRESHOLD = new BigNumber(10).pow(USDC_DECIMALS - 2); const DUST_THRESHOLD_UI = new BigNumber(0.01); @@ -93,7 +92,9 @@ class Liquidator { private async printAccountValue() { try { - const { assets, liabilities } = await this.account.computeHealthComponentsWithoutBias(MarginRequirementType.Equity); + const { assets, liabilities } = await this.account.computeHealthComponentsWithoutBias( + MarginRequirementType.Equity + ); const accountValue = assets.minus(liabilities); console.log("Account Value: $%s", accountValue); } catch (e) { @@ -152,34 +153,26 @@ class Liquidator { const mintInSymbol = this.getTokenSymbol(mintInBank); const mintOutSymbol = this.getTokenSymbol(mintOutBank); const amountScaled = nativeToUi(amount, mintInBank.mintDecimals); - console.log("Swapping %s %s to %s", - amountScaled, - mintInSymbol, - mintOutSymbol - ); + console.log("Swapping %s %s to %s", amountScaled, mintInSymbol, mintOutSymbol); } else { const mintInBank = this.client.getBankByMint(mintIn)!; const mintOutBank = this.client.getBankByMint(mintOut)!; const mintInSymbol = this.getTokenSymbol(mintInBank); const mintOutSymbol = this.getTokenSymbol(mintOutBank); const amountScaled = nativeToUi(amount, mintOutBank.mintDecimals); - console.log("Swapping %s to %s %s", - mintInSymbol, - amountScaled, - mintOutSymbol, - ); + console.log("Swapping %s to %s %s", mintInSymbol, amountScaled, mintOutSymbol); } - const swapMode = swapModeExactOut ? 'ExactOut' : 'ExactIn'; + const swapMode = swapModeExactOut ? "ExactOut" : "ExactIn"; const swapUrl = `https://quote-api.jup.ag/v6/quote?inputMint=${mintIn.toBase58()}&outputMint=${mintOut.toBase58()}&amount=${amount.toString()}&slippageBps=${SLIPPAGE_BPS}&swapMode=${swapMode}`; const quoteApiResponse = await fetch(swapUrl); const data = await quoteApiResponse.json(); const transactionResponse = await ( - await fetch('https://quote-api.jup.ag/v6/swap', { - method: 'POST', + await fetch("https://quote-api.jup.ag/v6/swap", { + method: "POST", headers: { - 'Content-Type': 'application/json' + "Content-Type": "application/json", }, body: JSON.stringify({ // quoteResponse from /quote api @@ -191,23 +184,23 @@ class Liquidator { // feeAccount is optional. Use if you want to charge a fee. feeBps must have been passed in /quote API. // feeAccount: "fee_account_public_key" computeUnitPriceMicroLamports: 50000, - }) + }), }) ).json(); const { swapTransaction } = transactionResponse; - const swapTransactionBuf = Buffer.from(swapTransaction, 'base64'); + const swapTransactionBuf = Buffer.from(swapTransaction, "base64"); const transaction = VersionedTransaction.deserialize(swapTransactionBuf); transaction.sign([this.wallet.payer]); - const rawTransaction = transaction.serialize() + const rawTransaction = transaction.serialize(); const txid = await this.connection.sendRawTransaction(rawTransaction, { maxRetries: 2, }); - await this.connection.confirmTransaction(txid, 'confirmed'); + await this.connection.confirmTransaction(txid, "confirmed"); debug("Swap transaction sent: %s", txid); } @@ -230,7 +223,7 @@ class Liquidator { } private async loadAllMarginfiAccounts() { - console.log("Loading data, this may take a moment...") + console.log("Loading data, this may take a moment..."); const debug = getDebugLogger("load-all-marginfi-accounts"); debug("Loading all Marginfi accounts"); let allKeys = []; @@ -239,18 +232,22 @@ class Liquidator { if (env_config.MARGINFI_ACCOUNT_WHITELIST) { allKeys = env_config.MARGINFI_ACCOUNT_WHITELIST; } else { - allKeys = (await this.client.getAllMarginfiAccountAddresses()); + allKeys = await this.client.getAllMarginfiAccountAddresses(); } debug("Retrieved all Marginfi account addresses, found: %d", allKeys.length); - const [slot, ais] = await chunkedGetRawMultipleAccountInfos(this.connection, allKeys.map((k) => k.toBase58()), 16 * 64, 64); + const [slot, ais] = await chunkedGetRawMultipleAccountInfos( + this.connection, + allKeys.map((k) => k.toBase58()), + 16 * 64, + 64 + ); debug("Received account information for slot %d, got: %d accounts", slot, ais.size); this.accountKeys = allKeys; const totalAccounts = ais.size; let processedAccounts = 0; for (const [key, accountInfo] of ais) { - const pubkey = new PublicKey(key); const account = MarginfiAccountWrapper.fromAccountDataRaw(pubkey, this.client, accountInfo.data); this.accountInfos.set(pubkey, account); @@ -293,10 +290,10 @@ class Liquidator { debug("Failed to decode Marginfi account for public key: %s, Error: %s", pubkey.toBase58(), error); } }); - } + }; setInterval(() => fn, env_config.WS_RESET_INTERVAL_SECONDS * 1000); - fn() + fn(); } /** @@ -321,7 +318,10 @@ class Liquidator { for (let { bank } of balancesWithNonUsdcDeposits) { const maxWithdrawAmount = this.account.computeMaxWithdrawForBank(bank.address); - const balanceAssetAmount = nativeToUi(this.account.getBalance(bank.address).computeQuantity(bank).assets, bank.mintDecimals); + const balanceAssetAmount = nativeToUi( + this.account.getBalance(bank.address).computeQuantity(bank).assets, + bank.mintDecimals + ); const withdrawAmount = BigNumber.min(maxWithdrawAmount, balanceAssetAmount); debug("Balance: %d, max withdraw: %d", balanceAssetAmount, withdrawAmount); @@ -338,7 +338,7 @@ class Liquidator { withdrawAmount.gte(balanceAssetAmount * 0.95) ); - debug("Withdraw tx: %s", withdrawSig) + debug("Withdraw tx: %s", withdrawSig); this.reload(); } @@ -413,11 +413,7 @@ class Liquidator { debug("Swapping %d USDC to %s", usdcBuyingPower, this.getTokenSymbol(bank)); - await this.swap( - USDC_MINT, - bank.mint, - uiToNative(usdcBuyingPower, usdcBank.mintDecimals), - ); + await this.swap(USDC_MINT, bank.mint, uiToNative(usdcBuyingPower, usdcBank.mintDecimals)); const liabsUi = new BigNumber(nativeToUi(liabilities, bank.mintDecimals)); const liabsTokenAccountUi = await this.getTokenAccountBalance(bank.mint, false); @@ -426,7 +422,11 @@ class Liquidator { debug("Got %d %s (debt: %d), depositing to marginfi", liabsUiAmountToRepay, this.getTokenSymbol(bank), liabsUi); debug("Paying off %d %s liabilities", liabsUiAmountToRepay, this.getTokenSymbol(bank)); - const depositSig = await this.account.repay(liabsUiAmountToRepay, bank.address, liabsUiAmountToRepay.gte(liabsUi)); + const depositSig = await this.account.repay( + liabsUiAmountToRepay, + bank.address, + liabsUiAmountToRepay.gte(liabsUi) + ); debug("Deposit tx: %s", depositSig); await this.reload(); @@ -764,11 +764,7 @@ class Liquidator { liabBank.mint ); - debug( - "Collateral amount to liquidate: %d for bank %s", - maxCollateralAmountToLiquidate, - collateralBank.mint - ) + debug("Collateral amount to liquidate: %d for bank %s", maxCollateralAmountToLiquidate, collateralBank.mint); const collateralAmountToLiquidate = BigNumber.min( maxCollateralAmountToLiquidate, @@ -780,7 +776,7 @@ class Liquidator { const collateralUsdValue = collateralBank.computeUsdValue( collateralPriceInfo, new BigNumber(uiToNative(slippageAdjustedCollateralAmountToLiquidate, collateralBank.mintDecimals).toNumber()), - PriceBias.None, + PriceBias.None ); if (collateralUsdValue.lt(MIN_LIQUIDATION_AMOUNT_USD_UI)) { @@ -806,7 +802,6 @@ class Liquidator { ); console.log("Liquidation tx: %s", sig); - } catch (e) { console.error("Failed to liquidate account %s", marginfiAccount.address.toBase58()); console.error(e); @@ -836,11 +831,15 @@ class Liquidator { sortByLiquidationAmount(accounts: MarginfiAccountWrapper[]): MarginfiAccountWrapper[] { return accounts - .filter(a => a.canBeLiquidated()) - .filter(a => !this.isAccountInCoolDown(a.address)) + .filter((a) => a.canBeLiquidated()) + .filter((a) => !this.isAccountInCoolDown(a.address)) .sort((a, b) => { - const { assets: aAssets, liabilities: aLiabilities } = a.computeHealthComponents(MarginRequirementType.Maintenance); - const { assets: bAssets, liabilities: bLiabilities } = b.computeHealthComponents(MarginRequirementType.Maintenance); + const { assets: aAssets, liabilities: aLiabilities } = a.computeHealthComponents( + MarginRequirementType.Maintenance + ); + const { assets: bAssets, liabilities: bLiabilities } = b.computeHealthComponents( + MarginRequirementType.Maintenance + ); const aMaxLiabilityPaydown = aAssets.minus(aLiabilities); const bMaxLiabilityPaydown = bAssets.minus(bLiabilities); @@ -887,4 +886,4 @@ function drawSpinner(message: string) { function nativeToBigNumber(amount: BN): BigNumber { return new BigNumber(amount.toString()); -} \ No newline at end of file +} diff --git a/apps/alpha-liquidator/src/runLiquidatorJupApi.ts b/apps/alpha-liquidator/src/runLiquidatorJupApi.ts index ebb85d3b20..db2e2dfa9c 100644 --- a/apps/alpha-liquidator/src/runLiquidatorJupApi.ts +++ b/apps/alpha-liquidator/src/runLiquidatorJupApi.ts @@ -36,4 +36,3 @@ async function startWithRestart() { } startWithRestart(); - diff --git a/apps/alpha-liquidator/src/utils/chunks.ts b/apps/alpha-liquidator/src/utils/chunks.ts index 48aa1e8044..43547c6f8b 100644 --- a/apps/alpha-liquidator/src/utils/chunks.ts +++ b/apps/alpha-liquidator/src/utils/chunks.ts @@ -83,7 +83,7 @@ export async function chunkedGetRawMultipleAccountInfos( } export function convertBase64StringArrayToBuffer(stringArray: string[]): Buffer { - return Buffer.concat(stringArray.map(s => Buffer.from(s, 'base64'))); + return Buffer.concat(stringArray.map((s) => Buffer.from(s, "base64"))); } export function chunkArray(array: T[], chunkSize: number): T[][] { @@ -93,5 +93,3 @@ export function chunkArray(array: T[], chunkSize: number): T[][] { } return chunks; } - - diff --git a/apps/marginfi-v2-ui/package.json b/apps/marginfi-v2-ui/package.json index 20f209473d..d8c50d3f22 100644 --- a/apps/marginfi-v2-ui/package.json +++ b/apps/marginfi-v2-ui/package.json @@ -26,6 +26,7 @@ "@mui/material": "^5.11.2", "@next/bundle-analyzer": "^13.4.19", "@next/font": "13.1.1", + "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-icons": "^1.3.0", diff --git a/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBox.tsx b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBox.tsx new file mode 100644 index 0000000000..2fc74a5e6f --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBox.tsx @@ -0,0 +1,585 @@ +import React, { FC, useEffect, useState } from "react"; + +import { usdFormatterDyn, WSOL_MINT } from "@mrgnlabs/mrgn-common"; +import { ActionType } from "@mrgnlabs/marginfi-v2-ui-state"; +import { PublicKey } from "@solana/web3.js"; +import { useMrgnlendStore, useUiStore } from "~/store"; +import { + MarginfiActionParams, + clampedNumeralFormatter, + closeBalance, + executeLendingAction, + getMaintHealthColor, + isWholePosition, + usePrevious, +} from "~/utils"; +import { LendingModes } from "~/types"; +import { useWalletContext } from "~/hooks/useWalletContext"; +import { useDebounce } from "~/hooks/useDebounce"; + +import { MrgnLabeledSwitch } from "~/components/common/MrgnLabeledSwitch"; +import { ActionBoxTokens } from "~/components/common/ActionBox/ActionBoxTokens"; +import { LSTDialog, LSTDialogVariants } from "~/components/common/AssetList"; +import { Input } from "~/components/ui/input"; +import { IconAlertTriangle, IconInfoCircle, IconWallet } from "~/components/ui/icons"; + +import { ActionBoxPreview } from "./ActionBoxPreview"; +import { checkActionAvailable } from "./ActionBox.utils"; +import { MrgnTooltip } from "../MrgnTooltip"; +import { MarginfiAccountWrapper, MarginRequirementType, SimulationResult } from "@mrgnlabs/marginfi-client-v2"; +import { ActionBoxActions } from "./ActionBoxActions"; +import { Skeleton } from "~/components/ui/skeleton"; + +export interface ActionPreview { + health: number; + liquidationPrice: number | null; + depositRate: number; + borrowRate: number; + positionAmount: number; + availableCollateral: { + ratio: number; + amount: number; + }; +} + +type ActionBoxProps = { + requestedAction?: ActionType; + requestedToken?: PublicKey; + isDialog?: boolean; +}; + +export const ActionBox = ({ requestedAction, requestedToken, isDialog }: ActionBoxProps) => { + const [ + mfiClient, + nativeSolBalance, + setIsRefreshingStore, + fetchMrgnlendState, + selectedAccount, + extendedBankInfos, + isInitialized, + ] = useMrgnlendStore((state) => [ + state.marginfiClient, + state.nativeSolBalance, + state.setIsRefreshingStore, + state.fetchMrgnlendState, + state.selectedAccount, + state.extendedBankInfos, + state.initialized, + ]); + const [lendingMode, setLendingMode] = useUiStore((state) => [state.lendingMode, state.setLendingMode]); + const { walletContextState, connected } = useWalletContext(); + + const [amount, setAmount] = React.useState(null); + const debouncedAmount = useDebounce(amount, 500) + const [isAmountLoading, setIsAmountLoading] = React.useState(false) + + const [actionMode, setActionMode] = React.useState(ActionType.Deposit); + const [selectedTokenBank, setSelectedTokenBank] = React.useState(null); + const [preview, setPreview] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + const [isLSTDialogOpen, setIsLSTDialogOpen] = React.useState(false); + const [lstDialogVariant, setLSTDialogVariant] = React.useState(null); + const [hasLSTDialogShown, setHasLSTDialogShown] = React.useState([]); + const [lstDialogCallback, setLSTDialogCallback] = React.useState<(() => void) | null>(null); + + const amountInputRef = React.useRef(null); + + const hasActivePositions = React.useMemo(() => extendedBankInfos.find((bank) => bank.isActive), [extendedBankInfos]); + const selectedBank = React.useMemo( + () => + selectedTokenBank + ? extendedBankInfos.find((bank) => bank?.address?.equals && bank?.address?.equals(selectedTokenBank)) ?? null + : null, + [extendedBankInfos, selectedTokenBank] + ); + const isDust = React.useMemo(() => selectedBank?.isActive && selectedBank?.position.isDust, [selectedBank]); + const showCloseBalance = React.useMemo(() => actionMode === ActionType.Withdraw && isDust, [actionMode, isDust]); + const maxAmount = React.useMemo(() => { + if (!selectedBank || !isInitialized) { + return 0; + } + + switch (actionMode) { + case ActionType.Deposit: + return selectedBank.userInfo.maxDeposit; + case ActionType.Withdraw: + return selectedBank.userInfo.maxWithdraw; + case ActionType.Borrow: + return selectedBank.userInfo.maxBorrow; + case ActionType.Repay: + return selectedBank.userInfo.maxRepay; + default: + return 0; + } + }, [selectedBank, actionMode, isInitialized]); + const isInputDisabled = React.useMemo(() => maxAmount === 0 && !showCloseBalance, [maxAmount, showCloseBalance]); + const walletAmount = React.useMemo( + () => + selectedBank?.info.state.mint?.equals && selectedBank?.info.state.mint?.equals(WSOL_MINT) + ? selectedBank?.userInfo.tokenAccount.balance + nativeSolBalance + : selectedBank?.userInfo.tokenAccount.balance, + [nativeSolBalance, selectedBank] + ); + + const actionMethod = React.useMemo( + () => + checkActionAvailable({ + amount, + connected, + showCloseBalance, + selectedBank, + extendedBankInfos, + marginfiAccount: selectedAccount, + nativeSolBalance, + actionMode, + }), + [ + amount, + connected, + showCloseBalance, + selectedBank, + extendedBankInfos, + selectedAccount, + nativeSolBalance, + actionMode, + ] + ); + + const actionModePrev = usePrevious(actionMode); + React.useEffect(() => { + if (actionModePrev !== null && actionModePrev !== actionMode) { + setAmount(0); + } + }, [actionModePrev, actionMode]); + + React.useEffect(() => { + setAmount(0); + }, [lendingMode, selectedTokenBank]); + + React.useEffect(() => { + if (requestedToken) { + setSelectedTokenBank(requestedToken); + } + }, [requestedToken, setSelectedTokenBank]); + + React.useEffect(() => { + if (!requestedAction) { + if (lendingMode === LendingModes.LEND) { + setActionMode(ActionType.Deposit); + } else { + setActionMode(ActionType.Borrow); + } + } + }, [lendingMode, setActionMode]); + + React.useEffect(() => { + if (requestedAction) { + setActionMode(requestedAction); + } + }, [requestedAction, setActionMode]); + + React.useEffect(() => { + if (amount) { + setIsAmountLoading(true) + setIsLoading(true) + } else { + setIsAmountLoading(false) + setIsLoading(false) + } + }, [setIsAmountLoading, setIsLoading, amount]) + + React.useEffect(() => { + if (amount && amount > maxAmount) { + setAmount(maxAmount); + } + }, [maxAmount, amount]); + + React.useEffect(() => { + if ( + actionMode === ActionType.Withdraw && + !(selectedBank?.isActive && selectedBank?.position?.isLending && lendingMode === LendingModes.LEND) + ) { + setSelectedTokenBank(null); + } else if ( + actionMode === ActionType.Repay && + !(selectedBank?.isActive && !selectedBank?.position?.isLending && lendingMode === LendingModes.BORROW) + ) { + setSelectedTokenBank(null); + } + }, [selectedBank, actionMode, setActionMode, lendingMode]); + + const computePreview = React.useCallback(async () => { + if (!selectedAccount || !selectedBank || debouncedAmount === null) { + return; + } + + try { + let simulationResult: SimulationResult; + + if (debouncedAmount === 0) { + setPreview(null); + return; + } + + if (actionMode === ActionType.Deposit) { + simulationResult = await selectedAccount.simulateDeposit(debouncedAmount, selectedBank.address); + } else if (actionMode === ActionType.Withdraw) { + simulationResult = await selectedAccount.simulateWithdraw( + debouncedAmount, + selectedBank.address, + selectedBank.isActive && isWholePosition(selectedBank, debouncedAmount) + ); + } else if (actionMode === ActionType.Borrow) { + simulationResult = await selectedAccount.simulateBorrow(debouncedAmount, selectedBank.address); + } else if (actionMode === ActionType.Repay) { + simulationResult = await selectedAccount.simulateRepay( + debouncedAmount, + selectedBank.address, + selectedBank.isActive && isWholePosition(selectedBank, debouncedAmount) + ); + } else { + throw new Error("Unknown action mode"); + } + + const { assets, liabilities } = simulationResult.marginfiAccount.computeHealthComponents( + MarginRequirementType.Maintenance + ); + const { assets: assetsInit } = simulationResult.marginfiAccount.computeHealthComponents( + MarginRequirementType.Initial + ); + const health = assets.minus(liabilities).dividedBy(assets).toNumber(); + + const liquidationPrice = simulationResult.marginfiAccount.computeLiquidationPriceForBank(selectedBank.address); + + const { lendingRate, borrowingRate } = simulationResult.banks + .get(selectedBank.address.toBase58())! + .computeInterestRates(); + + const position = simulationResult.marginfiAccount.activeBalances.find( + (b) => b.active && b.bankPk.equals(selectedBank.address) + ); + let positionAmount = 0; + if (position && position.liabilityShares.gt(0)) { + positionAmount = position.computeQuantityUi(selectedBank.info.rawBank).liabilities.toNumber(); + } else if (position && position.assetShares.gt(0)) { + positionAmount = position.computeQuantityUi(selectedBank.info.rawBank).assets.toNumber(); + } + const availableCollateral = simulationResult.marginfiAccount.computeFreeCollateral().toNumber(); + + setPreview({ + health, + liquidationPrice, + depositRate: lendingRate.toNumber(), + borrowRate: borrowingRate.toNumber(), + positionAmount, + availableCollateral: { + amount: availableCollateral, + ratio: availableCollateral / assetsInit.toNumber(), + }, + }); + } catch (error) { + setPreview(null); + console.log("Error computing action preview", error); + } finally { + setIsAmountLoading(false) + setIsLoading(false) + } + }, [actionMode, debouncedAmount, selectedAccount, selectedBank]); + + React.useEffect(() => { + computePreview(); + }, [computePreview]); + + const executeLendingActionCb = React.useCallback( + async ({ + mfiClient, + actionType: currentAction, + bank, + amount: borrowOrLendAmount, + nativeSolBalance, + marginfiAccount, + walletContextState, + }: MarginfiActionParams) => { + setIsLoading(true); + await executeLendingAction({ + mfiClient, + actionType: currentAction, + bank, + amount: borrowOrLendAmount, + nativeSolBalance, + marginfiAccount, + walletContextState, + }); + + setIsLoading(false); + 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 { + if (!selectedBank || !selectedAccount) { + throw new Error(); + } + await closeBalance({ marginfiAccount: selectedAccount, bank: selectedBank }); + } catch (error) { + return; + } + + setAmount(0); + + try { + setIsRefreshingStore(true); + await fetchMrgnlendState(); + } catch (error: any) { + console.log("Error while reloading state"); + console.log(error); + } + }, [selectedBank, selectedAccount, fetchMrgnlendState, setIsRefreshingStore]); + + const handleLendingAction = React.useCallback(async () => { + if (!actionMode || !selectedBank || !selectedAccount || !amount) { + return; + } + + const action = async () => { + executeLendingActionCb({ + mfiClient, + actionType: actionMode, + bank: selectedBank, + amount: amount, + nativeSolBalance, + marginfiAccount: selectedAccount, + walletContextState, + }); + }; + + if ( + actionMode === ActionType.Deposit && + (selectedBank.meta.tokenSymbol === "SOL" || selectedBank.meta.tokenSymbol === "stSOL") && + !hasLSTDialogShown.includes(selectedBank.meta.tokenSymbol as LSTDialogVariants) + ) { + setHasLSTDialogShown((prev) => [...prev, selectedBank.meta.tokenSymbol as LSTDialogVariants]); + setLSTDialogVariant(selectedBank.meta.tokenSymbol as LSTDialogVariants); + setIsLSTDialogOpen(true); + setLSTDialogCallback(() => action); + + return; + } + + await action(); + + if ( + actionMode === ActionType.Withdraw && + (selectedBank.meta.tokenSymbol === "SOL" || selectedBank.meta.tokenSymbol === "stSOL") && + !hasLSTDialogShown.includes(selectedBank.meta.tokenSymbol as LSTDialogVariants) + ) { + setHasLSTDialogShown((prev) => [...prev, selectedBank.meta.tokenSymbol as LSTDialogVariants]); + setLSTDialogVariant(selectedBank.meta.tokenSymbol as LSTDialogVariants); + return; + } + }, [ + actionMode, + selectedBank, + selectedAccount, + amount, + hasLSTDialogShown, + executeLendingActionCb, + mfiClient, + nativeSolBalance, + walletContextState, + ]); + + const handleInputChange = React.useCallback( + (newAmount: number) => { + if (newAmount > maxAmount) { + setAmount(maxAmount); + } else { + setAmount(newAmount); + } + }, + [maxAmount] + ); + + return ( + <> +
+
+ {!isDialog && ( + <> +
+ { + setSelectedTokenBank(null); + setLendingMode(lendingMode === LendingModes.LEND ? LendingModes.BORROW : LendingModes.LEND); + }} + /> +
+ +

Supply. Earn interest. Borrow. Repeat.

+ + )} +
+
+
+ {hasActivePositions && !isDialog ? ( +
+ {lendingMode === LendingModes.LEND ? "You supply" : "You borrow"} +
+ ) : ( +
+ )} + {selectedBank && ( +
+
+ +
+ + {walletAmount !== undefined + ? clampedNumeralFormatter(walletAmount).concat(" ", selectedBank.meta.tokenSymbol) + : "-"} + + +
+ )} +
+
+ + handleInputChange(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-base font-medium" + /> +
+ + {actionMethod.description && ( +
+
+ +

{actionMethod.description}

+
+
+ )} + + {selectedAccount && } + + (showCloseBalance ? handleCloseBalance() : handleLendingAction())} + isLoading={isLoading} + isEnabled={actionMethod.isEnabled} + actionMode={actionMode} + disabled={amount === 0} + /> + + {selectedBank && actionMethod.isEnabled && ( + + )} +
+
+ { + setIsLSTDialogOpen(false); + setLSTDialogVariant(null); + if (lstDialogCallback) { + lstDialogCallback(); + setLSTDialogCallback(null); + } + }} + /> + + ); +}; + +const ActionBoxAvailableCollateral: FC<{ + isLoading: boolean + marginfiAccount: MarginfiAccountWrapper; + preview: ActionPreview | null; +}> = ({ isLoading, marginfiAccount, preview }) => { + const [availableRatio, setAvailableRatio] = React.useState(0) + const [availableAmount, setAvailableAmount] = React.useState(0) + + const healthColor = React.useMemo(() => getMaintHealthColor(preview?.availableCollateral.ratio ?? availableRatio), [preview?.health, availableRatio]) + + useEffect(() => { + const currentAvailableCollateralAmount = marginfiAccount.computeFreeCollateral().toNumber(); + const currentAvailableCollateralRatio = + currentAvailableCollateralAmount / + marginfiAccount.computeHealthComponents(MarginRequirementType.Initial).assets.toNumber(); + setAvailableAmount(currentAvailableCollateralAmount) + setAvailableRatio(currentAvailableCollateralRatio) + }, [marginfiAccount]) + + + + return ( +
+
+
+ Available collateral + +
+

Available collateral is the USD value of your collateral not actively backing a loan.

+

It can be used to open additional borrows or withdraw part of your collateral.

+
+ + } + placement="top" + > + +
+
+
+ {isLoading ? : (usdFormatterDyn.format(preview?.availableCollateral.amount ?? availableAmount))} +
+
+
+
+
+
+ ); +}; diff --git a/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBox.utils.ts b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBox.utils.ts new file mode 100644 index 0000000000..43cac279b5 --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBox.utils.ts @@ -0,0 +1,311 @@ +import { floor, WSOL_MINT } from "@mrgnlabs/mrgn-common"; +import { ActionType, ActiveBankInfo, ExtendedBankInfo, FEE_MARGIN } from "@mrgnlabs/marginfi-v2-ui-state"; +import { + MarginfiAccountWrapper, + MarginRequirementType, + OperationalState, + RiskTier, +} from "@mrgnlabs/marginfi-client-v2"; + +interface props { + amount: number | null; + connected: boolean; + nativeSolBalance: number; + showCloseBalance?: boolean; + selectedBank: ExtendedBankInfo | null; + extendedBankInfos: ExtendedBankInfo[]; + marginfiAccount: MarginfiAccountWrapper | null; + actionMode: ActionType; +} + +export interface ActionMethod { + isEnabled: boolean; + description?: string; +} + +export function checkActionAvailable({ + amount, + nativeSolBalance, + connected, + showCloseBalance, + selectedBank, + extendedBankInfos, + marginfiAccount, + actionMode, +}: props): ActionMethod { + let check: ActionMethod | null = null; + + check = generalChecks(connected, selectedBank, showCloseBalance); + if (check) return check; + + if (!selectedBank) { + // this shouldn't happen + return { description: "Something went wrong", isEnabled: false }; + } + + switch (actionMode) { + case ActionType.Deposit: + check = canBeLent(selectedBank, nativeSolBalance); + if (check) return check; + break; + case ActionType.Withdraw: + check = canBeWithdrawn(selectedBank, marginfiAccount); + if (check) return check; + break; + case ActionType.Borrow: + check = canBeBorrowed(selectedBank, extendedBankInfos, marginfiAccount); + if (check) return check; + break; + case ActionType.Repay: + check = canBeRepaid(selectedBank); + if (check) return check; + break; + } + + if (amount && amount <= 0) { + return { + description: "Add an amount", + isEnabled: false, + }; + } + + return { + isEnabled: true, + }; +} + +function generalChecks( + connected: boolean, + selectedBank: ExtendedBankInfo | null, + showCloseBalance?: boolean +): ActionMethod | null { + if (!connected) { + return { isEnabled: false }; + } + if (!selectedBank) { + return { isEnabled: false }; + } + + if (showCloseBalance) { + return { description: "Close account.", isEnabled: true }; + } + + return null; +} + +function canBeWithdrawn( + targetBankInfo: ExtendedBankInfo, + marginfiAccount: MarginfiAccountWrapper | null +): ActionMethod | null { + const isPaused = targetBankInfo.info.rawBank.config.operationalState === OperationalState.Paused; + if (isPaused) { + return { + description: `The ${targetBankInfo.info.rawBank.tokenSymbol} bank is paused at this time.`, + isEnabled: false, + }; + } + + if (!targetBankInfo.isActive) { + return { + description: "No position found.", + isEnabled: false, + }; + } + + if (!targetBankInfo.position.isLending) { + return { + description: `You're not lending ${targetBankInfo.meta.tokenSymbol}.`, + isEnabled: false, + }; + } + + const noFreeCollateral = marginfiAccount && marginfiAccount.computeFreeCollateral().isZero(); + if (noFreeCollateral) { + return { + description: "No available collateral.", + isEnabled: false, + }; + } + + return null; +} + +function canBeRepaid(targetBankInfo: ExtendedBankInfo): ActionMethod | null { + const isPaused = targetBankInfo.info.rawBank.config.operationalState === OperationalState.Paused; + if (isPaused) { + return { + description: `The ${targetBankInfo.info.rawBank.tokenSymbol} bank is paused at this time.`, + isEnabled: false, + }; + } + + if (!targetBankInfo.isActive) { + return { + description: "No position found.", + isEnabled: false, + }; + } + + if (targetBankInfo.position.isLending) { + return { + description: `You are not borrowing ${targetBankInfo.meta.tokenSymbol}.`, + isEnabled: false, + }; + } + + if (targetBankInfo.userInfo.maxRepay === 0) { + return { + description: `Insufficient ${targetBankInfo.meta.tokenSymbol} in wallet for loan repayment.`, + isEnabled: false, + }; + } + + return null; +} + +function canBeBorrowed( + targetBankInfo: ExtendedBankInfo, + extendedBankInfos: ExtendedBankInfo[], + marginfiAccount: MarginfiAccountWrapper | null +): ActionMethod | null { + const isPaused = targetBankInfo.info.rawBank.config.operationalState === OperationalState.Paused; + if (isPaused) { + return { + description: `The ${targetBankInfo.info.rawBank.tokenSymbol} bank is paused at this time.`, + isEnabled: false, + }; + } + + const isReduceOnly = targetBankInfo.info.rawBank.config.operationalState === OperationalState.ReduceOnly; + if (isReduceOnly) { + return { + description: `The ${targetBankInfo.info.rawBank.tokenSymbol} bank is in reduce-only mode. You may only withdraw a deposit or repay a loan.`, + isEnabled: false, + }; + } + + const isBeingRetired = + targetBankInfo.info.rawBank + .getAssetWeight(MarginRequirementType.Initial, targetBankInfo.info.oraclePrice, true) + .eq(0) && + targetBankInfo.info.rawBank + .getAssetWeight(MarginRequirementType.Maintenance, targetBankInfo.info.oraclePrice) + .gt(0); + if (isBeingRetired) { + return { + description: `The ${targetBankInfo.info.rawBank.tokenSymbol} bank is being retired. You may only withdraw a deposit or repay a loan.`, + isEnabled: false, + }; + } + + const isFull = targetBankInfo.info.rawBank.computeRemainingCapacity().borrowCapacity.lte(0); + if (isFull) { + return { + description: `The ${targetBankInfo.info.rawBank.tokenSymbol} bank is at borrow capacity.`, + isEnabled: false, + }; + } + + const alreadyLending = targetBankInfo.isActive && targetBankInfo.position.isLending; + if (alreadyLending) { + return { + description: "You are already lending this asset, you need to close that position first to start borrowing.", + isEnabled: false, + }; + } + + const freeCollateral = marginfiAccount && marginfiAccount.computeFreeCollateral(); + if (freeCollateral && freeCollateral.eq(0)) { + return { + description: "You don't have any available collateral.", + isEnabled: false, + }; + } + + const existingLiabilityBanks = extendedBankInfos.filter((b) => b.isActive) as ActiveBankInfo[]; + const existingIsolatedBorrow = existingLiabilityBanks.find( + (b) => b.info.rawBank.config.riskTier === RiskTier.Isolated && !b.address.equals(targetBankInfo.address) + ); + if (existingIsolatedBorrow) { + return { + description: `You have an active isolated borrow (${existingIsolatedBorrow.meta.tokenSymbol}). You cannot borrow another asset while you do.`, + isEnabled: false, + }; + } + + const attemptingToBorrowIsolatedAssetWithActiveDebt = + targetBankInfo.info.rawBank.config.riskTier === RiskTier.Isolated && + !marginfiAccount + ?.computeHealthComponents(MarginRequirementType.Equity, [targetBankInfo.address]) + .liabilities.isZero(); + if (attemptingToBorrowIsolatedAssetWithActiveDebt) { + return { + description: "You cannot borrow an isolated asset with existing borrows.", + isEnabled: false, + }; + } + + return null; +} + +function canBeLent(targetBankInfo: ExtendedBankInfo, nativeSolBalance: number): ActionMethod | null { + const isPaused = targetBankInfo.info.rawBank.config.operationalState === OperationalState.Paused; + if (isPaused) { + return { + description: `The ${targetBankInfo.info.rawBank.tokenSymbol} bank is paused at this time.`, + isEnabled: false, + }; + } + + const isReduceOnly = targetBankInfo.info.rawBank.config.operationalState === OperationalState.ReduceOnly; + if (isReduceOnly) { + return { + description: `The ${targetBankInfo.info.rawBank.tokenSymbol} bank is in reduce-only mode. You may only withdraw a deposit or repay a loan.`, + isEnabled: false, + }; + } + + const isBeingRetired = + targetBankInfo.info.rawBank + .getAssetWeight(MarginRequirementType.Initial, targetBankInfo.info.oraclePrice, true) + .eq(0) && + targetBankInfo.info.rawBank + .getAssetWeight(MarginRequirementType.Maintenance, targetBankInfo.info.oraclePrice) + .gt(0); + if (isBeingRetired) { + return { + description: `The ${targetBankInfo.info.rawBank.tokenSymbol} bank is being retired. You may only withdraw a deposit or repay a loan.`, + isEnabled: false, + }; + } + + const alreadyBorrowing = targetBankInfo.isActive && !targetBankInfo.position.isLending; + if (alreadyBorrowing) { + return { + description: "You are already borrowing this asset, you need to repay that position first to start lending.", + isEnabled: false, + }; + } + + const isFull = targetBankInfo.info.rawBank.computeRemainingCapacity().depositCapacity.lte(0); + if (isFull) { + return { + description: `The ${targetBankInfo.info.rawBank.tokenSymbol} bank is at deposit capacity.`, + isEnabled: false, + }; + } + + const isWrappedSol = targetBankInfo.info.state.mint.equals(WSOL_MINT); + const walletBalance = floor( + isWrappedSol + ? Math.max(targetBankInfo.userInfo.tokenAccount.balance + nativeSolBalance - FEE_MARGIN, 0) + : targetBankInfo.userInfo.tokenAccount.balance, + targetBankInfo.info.state.mintDecimals + ); + + if (walletBalance === 0) { + return { description: `Insufficient ${targetBankInfo.meta.tokenSymbol} in wallet.`, isEnabled: false }; + } + + return null; +} 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..1594dd0fb4 --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxActions.tsx @@ -0,0 +1,23 @@ +import React from "react"; + +import { Button } from "~/components/ui/button"; +import { IconLoader } from "~/components/ui/icons"; + +import { ActionMethod } from "./ActionBox.utils"; +import { ActionType } from "@mrgnlabs/marginfi-v2-ui-state"; + +type ActionBoxActionsProps = { + isLoading: boolean; + isEnabled: boolean; + actionMode: ActionType; + handleAction: () => void; + disabled?: boolean; +}; + +export const ActionBoxActions = ({ isLoading, isEnabled, actionMode, handleAction, disabled }: ActionBoxActionsProps) => { + return ( + + ); +}; diff --git a/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxDialog.tsx b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxDialog.tsx new file mode 100644 index 0000000000..0a3bc6d36e --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxDialog.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { ActionType } from "@mrgnlabs/marginfi-v2-ui-state"; +import { PublicKey } from "@solana/web3.js"; + +import { ActionBox } from "~/components/common/ActionBox"; + +import { Dialog, DialogTrigger, DialogContent } from "~/components/ui/dialog"; + +type ActionBoxDialogProps = { + requestedAction?: ActionType; + requestedToken?: PublicKey; + children: React.ReactNode; +}; + +export const ActionBoxDialog = ({ requestedAction, requestedToken, children }: ActionBoxDialogProps) => { + const [isDialogOpen, setIsDialogOpen] = React.useState(false); + + return ( + setIsDialogOpen(open)}> + {children} + + + + + ); +}; diff --git a/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxPreview.tsx b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxPreview.tsx new file mode 100644 index 0000000000..283ce7b99b --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxPreview.tsx @@ -0,0 +1,162 @@ +import React, { FC } from "react"; +import { MarginfiAccountWrapper } from "@mrgnlabs/marginfi-client-v2"; +import { percentFormatter, numeralFormatter } from "@mrgnlabs/mrgn-common"; +import { ActionType, ExtendedBankInfo } from "@mrgnlabs/marginfi-v2-ui-state"; +import Image from "next/image"; + +import { useMrgnlendStore } from "~/store"; +import { clampedNumeralFormatter, cn, getLiquidationPriceColor, getMaintHealthColor } from "~/utils"; +import { IconArrowRight } from "~/components/ui/icons"; +import { Badge, Typography } from "@mui/material"; +import { MrgnTooltip } from "../MrgnTooltip"; +import { REDUCE_ONLY_BANKS } from "~/components/desktop/AssetsList/AssetRow"; +import { useAssetItemData } from "~/hooks/useAssetItemData"; +import { ActionPreview } from "./ActionBox"; +import { Skeleton } from "~/components/ui/skeleton"; + +interface ActionBoxPreviewProps { + marginfiAccount: MarginfiAccountWrapper | null; + selectedBank: ExtendedBankInfo; + actionMode: ActionType; + isLoading: boolean; + preview: ActionPreview | null; +} + +export const ActionBoxPreview: FC = ({ marginfiAccount, selectedBank, actionMode, isLoading, preview }) => { + const showLending = React.useMemo( + () => actionMode === ActionType.Deposit || actionMode === ActionType.Withdraw, + [actionMode] + ); + + const { isBankFilled, isBankHigh, bankCap } = useAssetItemData({ + bank: selectedBank, + isInLendingMode: showLending, + }); + + const [accountSummary] = useMrgnlendStore((state) => [state.accountSummary]); + + const isReduceOnly = React.useMemo( + () => (selectedBank?.meta?.tokenSymbol ? REDUCE_ONLY_BANKS.includes(selectedBank?.meta.tokenSymbol) : false), + [selectedBank] + ); + + const isBorrowing = marginfiAccount?.activeBalances.find((b) => b.active && b.liabilityShares.gt(0)) !== undefined; + + const currentPositionAmount = selectedBank.isActive ? selectedBank.position.amount : 0; + + + const liquidationColor = React.useMemo(() => preview && preview.liquidationPrice ? getMaintHealthColor(preview.liquidationPrice / selectedBank.info.oraclePrice.price.toNumber()) : "white", [preview, selectedBank]) + const healthColor = React.useMemo(() => getMaintHealthColor(preview?.health ?? accountSummary.healthFactor), [preview?.health, accountSummary.healthFactor]) + + return ( +
+ + {clampedNumeralFormatter(currentPositionAmount)} + {preview && } + {preview && clampedNumeralFormatter(preview.positionAmount)} + + + {selectedBank.info.state.isIsolated ? ( + <> + Isolated pool{" "} + + + Isolated pools are risky ⚠️ + + Assets in isolated pools cannot be used as collateral. When you borrow an isolated asset, you cannot + borrow other assets. Isolated pools should be considered particularly risky. As always, remember that + marginfi is a decentralized protocol and all deposited funds are at risk. + + } + placement="top" + > + info + {" "} + + ) : ( + <>Global pool + )} + + + + {accountSummary.healthFactor && percentFormatter.format(accountSummary.healthFactor)} + {accountSummary.healthFactor && } + {isLoading ? : (preview?.health ? percentFormatter.format(preview.health) : "-")} + + {(actionMode === ActionType.Borrow || isBorrowing) && ( + + {selectedBank.isActive && + selectedBank.position.liquidationPrice && + selectedBank.position.liquidationPrice > 0.01 && + numeralFormatter(selectedBank.position.liquidationPrice)} + {selectedBank.isActive && selectedBank.position.liquidationPrice && } + {isLoading ? : (preview?.liquidationPrice ? numeralFormatter(preview.liquidationPrice) : "-")} + + )} + + + + {isReduceOnly ? "Reduce Only" : isBankHigh && (isBankFilled ? "Limit Reached" : "Approaching Limit")} + + + {isReduceOnly + ? "stSOL is being discontinued." + : `${selectedBank.meta.tokenSymbol} ${showLending ? "deposits" : "borrows" + } are at ${percentFormatter.format( + (showLending ? selectedBank.info.state.totalDeposits : selectedBank.info.state.totalBorrows) / + bankCap + )} capacity.`} +
+ + Learn more. + + + } + placement="right" + className={``} + > + + {numeralFormatter( + showLending + ? selectedBank.info.state.totalDeposits + : Math.max( + 0, + Math.min( + selectedBank.info.state.totalDeposits, + selectedBank.info.rawBank.config.borrowLimit.toNumber() + ) - selectedBank.info.state.totalBorrows + ) + )} + +
+
+
+ ); +}; + +interface StatProps { + label: string; + classNames?: string; + children: React.ReactNode; +} +const Stat = ({ label, classNames, children }: StatProps) => { + return ( + <> +
{label}
+
{children}
+ + ); +}; diff --git a/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxTokens.tsx b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxTokens.tsx new file mode 100644 index 0000000000..6eec788866 --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxTokens.tsx @@ -0,0 +1,466 @@ +import React from "react"; + +import Image from "next/image"; + +import { numeralFormatter, usdFormatter, percentFormatter, WSOL_MINT } from "@mrgnlabs/mrgn-common"; +import { ExtendedBankInfo, Emissions, ActionType } from "@mrgnlabs/marginfi-v2-ui-state"; + +import { useMrgnlendStore, useUiStore } from "~/store"; + +import { cn } from "~/utils"; + +import { useWalletContext } from "~/hooks/useWalletContext"; +import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "~/components/ui/command"; +import { Button } from "~/components/ui/button"; + +import { IconChevronDown, IconMoonPay, IconX } from "~/components/ui/icons"; + +import { LendingModes } from "~/types"; +import { PublicKey } from "@solana/web3.js"; + +type ActionBoxTokensProps = { + currentTokenBank: PublicKey | null; + setCurrentTokenBank: (selectedTokenBank: PublicKey | null) => void; + isDialog?: boolean; +}; + +export const ActionBoxTokens = ({ currentTokenBank, isDialog, setCurrentTokenBank }: ActionBoxTokensProps) => { + const [extendedBankInfos, nativeSolBalance] = useMrgnlendStore((state) => [ + state.extendedBankInfos, + state.nativeSolBalance, + ]); + const [lendingMode, setIsWalletOpen] = useUiStore((state) => [state.lendingMode, state.setIsWalletOpen]); + const [searchQuery, setSearchQuery] = React.useState(""); + const [isTokenPopoverOpen, setIsTokenPopoverOpen] = React.useState(false); + const { connected } = useWalletContext(); + + const selectedBank = React.useMemo( + () => + currentTokenBank + ? extendedBankInfos.find((bank) => bank?.address?.equals && bank?.address?.equals(currentTokenBank)) + : null, + [extendedBankInfos, currentTokenBank] + ); + + 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 hasTokens = React.useMemo(() => { + const hasBankTokens = !!extendedBankInfos.filter( + (bank) => bank.userInfo.tokenAccount.balance !== 0 || bank.meta.tokenSymbol === "SOL" + ); + + return hasBankTokens; + }, [extendedBankInfos, nativeSolBalance]); + + /////// FILTERS + + // filter on balance + const balanceFilter = (bankInfo: ExtendedBankInfo) => { + const isWSOL = bankInfo.info.state.mint?.equals ? bankInfo.info.state.mint.equals(WSOL_MINT) : false; + const balance = isWSOL + ? bankInfo.userInfo.tokenAccount.balance + nativeSolBalance + : bankInfo.userInfo.tokenAccount.balance; + return balance > 0; + }; + + // filter on search + const searchFilter = React.useCallback( + (bankInfo: ExtendedBankInfo) => { + const lowerCaseSearchQuery = searchQuery.toLowerCase(); + return bankInfo.meta.tokenSymbol.toLowerCase().includes(lowerCaseSearchQuery); + }, + [searchQuery] + ); + + // filter on positions + const positionFilter = React.useCallback( + (bankInfo: ExtendedBankInfo, filterActive?: boolean) => + bankInfo.isActive ? lendingMode === LendingModes.LEND && bankInfo.position.isLending : filterActive, + [lendingMode] + ); + + /////// BANKS + + // wallet banks + const filteredBanksUserOwns = React.useMemo(() => { + return ( + extendedBankInfos + .filter(balanceFilter) + .filter(searchFilter) + // .filter((bank) => positionFilter(bank, true)) + .sort((a, b) => { + 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, searchFilter]); + + // const ownedBanksPk = React.useMemo(() => filteredBanksUserOwns.map((bank) => bank.address), [filteredBanksUserOwns]); + + // active position banks + const filteredBanksActive = React.useMemo(() => { + return extendedBankInfos + .filter(searchFilter) + .filter((bankInfo) => positionFilter(bankInfo, false)) + .sort((a, b) => (b.isActive ? b?.position?.amount : 0) - (a.isActive ? a?.position?.amount : 0)); + }, [extendedBankInfos, searchFilter, positionFilter]); + + // other banks without positions + const filteredBanks = React.useMemo(() => { + return extendedBankInfos.filter(searchFilter); + }, [extendedBankInfos, lendingMode, searchFilter]); + + const globalBanks = React.useMemo(() => filteredBanks.filter((bank) => !bank.info.state.isIsolated), [filteredBanks]); + const isolatedBanks = React.useMemo( + () => filteredBanks.filter((bank) => bank.info.state.isIsolated), + [filteredBanks] + ); + + React.useEffect(() => { + if (!isTokenPopoverOpen) { + setSearchQuery(""); + } + }, [isTokenPopoverOpen]); + + return ( + <> + {isDialog ? ( +
+ {selectedBank && ( + + )} +
+ ) : ( + setIsTokenPopoverOpen(open)}> + + + + + + setCurrentTokenBank( + extendedBankInfos.find((bank) => bank.address.toString() === value)?.address || + selectedBank?.address || + null + ) + } + > + setSearchQuery(value)} + /> + {!hasTokens && ( + <> +
+ You don't own any supported tokens in marginfi. Check out what marginfi supports. +
+ + + )} + + No tokens found. + + {/* LENDING */} +
+ {lendingMode === LendingModes.LEND && connected && filteredBanksUserOwns.length > 0 && ( + + {filteredBanksUserOwns + .slice(0, searchQuery.length === 0 ? filteredBanksUserOwns.length : 3) + .map((bank, index) => { + return ( + { + setCurrentTokenBank( + extendedBankInfos.find( + (bankInfo) => bankInfo.address.toString().toLowerCase() === currentValue + )?.address ?? null + ); + setIsTokenPopoverOpen(false); + }} + className="cursor-pointer h-[60px] px-3 font-medium flex items-center justify-between gap-2 data-[selected=true]:bg-background-gray-light data-[selected=true]:text-white" + > + + + ); + })} + + )} + {lendingMode === LendingModes.LEND && filteredBanksActive.length > 0 && ( + + {filteredBanksActive.map((bank, index) => ( + { + setCurrentTokenBank( + extendedBankInfos.find( + (bankInfo) => bankInfo.address.toString().toLowerCase() === currentValue + )?.address ?? null + ); + setIsTokenPopoverOpen(false); + }} + className={cn( + "cursor-pointer font-medium flex items-center justify-between gap-2 data-[selected=true]:bg-background-gray-light data-[selected=true]:text-white py-2" + )} + > + + + ))} + + )} + + {/* BORROWING */} + {lendingMode === LendingModes.BORROW && filteredBanksActive.length > 0 && ( + + {filteredBanksActive.map((bank, index) => ( + { + setCurrentTokenBank( + extendedBankInfos.find( + (bankInfo) => bankInfo.address.toString().toLowerCase() === currentValue + )?.address ?? null + ); + setIsTokenPopoverOpen(false); + }} + className={cn( + "cursor-pointer font-medium flex items-center justify-between gap-2 data-[selected=true]:bg-background-gray-light data-[selected=true]:text-white" + )} + > + + + ))} + + )} + + {/* GLOBAL & ISOLATED */} + {globalBanks.length > 0 && ( + + {globalBanks.map((bank, index) => { + return ( + { + setCurrentTokenBank( + extendedBankInfos.find( + (bankInfo) => bankInfo.address.toString().toLowerCase() === currentValue + )?.address ?? null + ); + setIsTokenPopoverOpen(false); + }} + className={cn( + "cursor-pointer font-medium flex items-center justify-between gap-2 data-[selected=true]:bg-background-gray-light data-[selected=true]:text-white", + lendingMode === LendingModes.LEND && "py-2", + lendingMode === LendingModes.BORROW && "h-[60px]" + )} + > + + + ); + })} + + )} + {isolatedBanks.length > 0 && ( + + {isolatedBanks.map((bank, index) => { + return ( + { + setCurrentTokenBank( + extendedBankInfos.find( + (bankInfo) => bankInfo.address.toString().toLowerCase() === currentValue + )?.address ?? null + ); + setIsTokenPopoverOpen(false); + }} + className={cn( + "cursor-pointer font-medium flex items-center justify-between gap-2 data-[selected=true]:bg-background-gray-light data-[selected=true]:text-white", + lendingMode === LendingModes.LEND && "py-2", + lendingMode === LendingModes.BORROW && "h-[60px]" + )} + > + + + ); + })} + + )} +
+
+
+
+ )} + + ); +}; + +type SelectedBankItemProps = { + bank: ExtendedBankInfo; + lendingMode: LendingModes; + rate: string; +}; + +const SelectedBankItem = ({ rate, bank, lendingMode }: SelectedBankItemProps) => { + return ( + <> + {bank.meta.tokenName} +
+

{bank.meta.tokenSymbol}

+

+ {rate + ` ${lendingMode === LendingModes.LEND ? "APY" : "APR"}`} +

+
+ + ); +}; + +type ActionBoxItemProps = { + rate: string; + lendingMode: LendingModes; + bank: ExtendedBankInfo; + nativeSolBalance: number; + showBalanceOverride: boolean; +}; + +const ActionBoxItem = ({ rate, lendingMode, bank, nativeSolBalance, showBalanceOverride }: ActionBoxItemProps) => { + const balance = React.useMemo(() => { + const isWSOL = bank.info.state.mint?.equals ? bank.info.state.mint.equals(WSOL_MINT) : false; + + return isWSOL ? bank.userInfo.tokenAccount.balance + nativeSolBalance : bank.userInfo.tokenAccount.balance; + }, [bank, nativeSolBalance]); + + const balancePrice = React.useMemo( + () => + balance * bank.info.state.price > 0.01 + ? usdFormatter.format(balance * bank.info.state.price) + : `$${(balance * bank.info.state.price).toExponential(2)}`, + [bank, balance] + ); + + return ( + <> +
+ {bank.meta.tokenLogoUri && ( + {bank.meta.tokenName} + )} +
+

{bank.meta.tokenSymbol}

+

+ {rate} +

+
+
+ + {((lendingMode === LendingModes.BORROW && balance > 0) || showBalanceOverride) && ( +
+

{balance > 0.01 ? numeralFormatter(balance) : "< 0.01"}

+

{balancePrice}

+
+ )} + + ); +}; diff --git a/apps/marginfi-v2-ui/src/components/common/ActionBox/index.tsx b/apps/marginfi-v2-ui/src/components/common/ActionBox/index.tsx new file mode 100644 index 0000000000..eb9703867c --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/common/ActionBox/index.tsx @@ -0,0 +1,3 @@ +export * from "./ActionBox"; +export * from "./ActionBoxDialog"; +export * from "./ActionBoxTokens"; diff --git a/apps/marginfi-v2-ui/src/components/common/AssetList/AssetListFilters.tsx b/apps/marginfi-v2-ui/src/components/common/AssetList/AssetListFilters.tsx index 8fecb65070..54ed90cd75 100644 --- a/apps/marginfi-v2-ui/src/components/common/AssetList/AssetListFilters.tsx +++ b/apps/marginfi-v2-ui/src/components/common/AssetList/AssetListFilters.tsx @@ -3,19 +3,21 @@ import React from "react"; import { useUiStore, SORT_OPTIONS_MAP } from "~/store"; import { cn } from "~/utils"; import { useWalletContext } from "~/hooks/useWalletContext"; +import { useIsMobile } from "~/hooks/useIsMobile"; import { MrgnLabeledSwitch } from "~/components/common/MrgnLabeledSwitch"; import { MrgnContainedSwitch } from "~/components/common/MrgnContainedSwitch"; -import { NewAssetBanner } from "~/components/common/AssetList"; - import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; import { IconFilter, IconSortAscending, IconSortDescending } from "~/components/ui/icons"; -import { LendingModes, PoolTypes, SortType, sortDirection, SortAssetOption } from "~/types"; +import { LendingModes, PoolTypes, SortType, sortDirection, SortAssetOption, UserMode } from "~/types"; export const AssetListFilters = () => { const { connected } = useWalletContext(); + const isMobile = useIsMobile(); const [ + userMode, + setUserMode, lendingMode, setLendingMode, poolFilter, @@ -26,6 +28,8 @@ export const AssetListFilters = () => { sortOption, setSortOption, ] = useUiStore((state) => [ + state.userMode, + state.setUserMode, state.lendingMode, state.setLendingMode, state.poolFilter, @@ -39,92 +43,96 @@ export const AssetListFilters = () => { return (
-
-
- setLendingMode(lendingMode === LendingModes.LEND ? LendingModes.BORROW : LendingModes.LEND)} - /> +
+
+ + setLendingMode(lendingMode === LendingModes.LEND ? LendingModes.BORROW : LendingModes.LEND) + } + /> +
-
{ - e.stopPropagation(); - if (connected) return; - setIsWalletAuthDialogOpen(true); - }} - > - { - setIsFilteredUserPositions(!isFilteredUserPositions); - setPoolFilter(PoolTypes.ALL); + {(userMode === UserMode.PRO || isMobile) && ( +
{ + e.stopPropagation(); + if (connected) return; + setIsWalletAuthDialogOpen(true); }} - inputProps={{ "aria-label": "controlled" }} - className={cn(!connected && "pointer-events-none")} - /> -
Filter my positions
-
-
-
- + inputProps={{ "aria-label": "controlled" }} + className={cn(!connected && "pointer-events-none")} + /> +
Filter my positions
-
- + )} + {(userMode === UserMode.PRO || isMobile) && ( +
+
+ +
+
+ +
-
+ )}
); diff --git a/apps/marginfi-v2-ui/src/components/common/AssetList/AssetRowAction.tsx b/apps/marginfi-v2-ui/src/components/common/AssetList/AssetRowAction.tsx index 7dc50d4290..b5a9becafd 100644 --- a/apps/marginfi-v2-ui/src/components/common/AssetList/AssetRowAction.tsx +++ b/apps/marginfi-v2-ui/src/components/common/AssetList/AssetRowAction.tsx @@ -1,14 +1,15 @@ import { FC, ReactNode } from "react"; import { Button, ButtonProps } from "@mui/material"; +import { cn } from "~/utils"; interface AssetRowActionProps extends ButtonProps { children?: ReactNode; bgColor?: string; } -const AssetRowAction: FC = ({ children, disabled, bgColor, ...otherProps }) => ( +const AssetRowAction: FC = ({ children, disabled, bgColor, className, ...otherProps }) => ( - -
  • - + {/* {userMode === UserMode.LITE ? ( + + + + ) : ( + + )} */}
  • + {/*
  • + {userMode === UserMode.LITE ? ( + + + + ) : ( + + )} +
  • */}
    @@ -100,6 +140,7 @@ export const NewAssetBanner = ({ asset, image }: NewAssetBannerProps) => { type NewAssetBannerListProps = { assets: { asset: string; + symbol: string; image: string; }[]; }; diff --git a/apps/marginfi-v2-ui/src/components/common/AssetList/index.ts b/apps/marginfi-v2-ui/src/components/common/AssetList/index.ts index c7537f16a9..f10590fe38 100644 --- a/apps/marginfi-v2-ui/src/components/common/AssetList/index.ts +++ b/apps/marginfi-v2-ui/src/components/common/AssetList/index.ts @@ -5,3 +5,4 @@ export * from "./NewAsssetBanner"; export * from "./AssetListFilters"; export * from "./AssetListFilters.utils"; export * from "./AssetList.utils"; +export * from "./AssetRowHeader"; diff --git a/apps/marginfi-v2-ui/src/components/common/Portfolio/AssetCard/AssetCard.tsx b/apps/marginfi-v2-ui/src/components/common/Portfolio/AssetCard/AssetCard.tsx new file mode 100644 index 0000000000..ef94d0adea --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/common/Portfolio/AssetCard/AssetCard.tsx @@ -0,0 +1,162 @@ +import Image from "next/image"; +import React, { FC } from "react"; +import { PublicKey } from "@solana/web3.js"; +import { usdFormatter, numeralFormatter } from "@mrgnlabs/mrgn-common"; +import { ActiveBankInfo, ActionType } from "@mrgnlabs/marginfi-v2-ui-state"; + +import { IconAlertTriangle } from "~/components/ui/icons"; +import { ActionBoxDialog } from "~/components/common/ActionBox"; +import { Button } from "~/components/ui/button"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "~/components/ui/accordion"; +import { useAssetItemData } from "~/hooks/useAssetItemData"; +import { Skeleton } from "~/components/ui/skeleton"; +import { useUiStore } from "~/store"; +import { LendingModes } from "~/types"; + +interface props { + bank: ActiveBankInfo; + isInLendingMode: boolean; +} + +export const AssetCard: FC = ({ bank, isInLendingMode }) => { + const [setLendingMode] = useUiStore((state) => [state.setLendingMode]); + const { rateAP } = useAssetItemData({ bank, isInLendingMode }); + + const [requestedAction, setRequestedAction] = React.useState(); + const [requestedToken, setRequestedToken] = React.useState(); + + const isIsolated = React.useMemo(() => bank.info.state.isIsolated, [bank]); + + const isUserPositionPoorHealth = React.useMemo(() => { + if (!bank || !bank?.position?.liquidationPrice) { + return false; + } + + const alertRange = 0.05; + + if (bank.position.isLending) { + return bank.info.state.price < bank.position.liquidationPrice + bank.position.liquidationPrice * alertRange; + } else { + return bank.info.state.price > bank.position.liquidationPrice - bank.position.liquidationPrice * alertRange; + } + }, [bank]); + + return ( + + + +
    +
    +
    + {bank.meta.tokenLogoUri && ( + {bank.meta.tokenSymbol} + )} +
    +
    +
    {bank.meta.tokenSymbol}
    +
    + {rateAP.concat(...[" ", isInLendingMode ? "APY" : "APR"])} +
    +
    +
    +
    + {bank.position.amount < 0.01 ? "< $0.01" : numeralFormatter(bank.position.amount)} + {" " + bank.meta.tokenSymbol} +
    +
    +
    + + {isUserPositionPoorHealth && ( +
    + + Liquidation risk +
    + )} + + {isIsolated && ( +
    + Isolated pool +
    + )} + +
    +
    +
    USD value
    +
    + {bank.position.usdValue < 0.01 ? "< $0.01" : usdFormatter.format(bank.position.usdValue)} +
    +
    +
    +
    Current price
    +
    {usdFormatter.format(bank.info.state.price)}
    +
    +
    +
    Liquidation price
    +
    + {isUserPositionPoorHealth && } + {bank?.position?.liquidationPrice && + (bank.position.liquidationPrice > 0.01 + ? usdFormatter.format(bank.position.liquidationPrice) + : `$${bank.position.liquidationPrice.toExponential(2)}`)} +
    +
    +
    + +
    + + +
    +
    +
    +
    +
    + ); +}; + +export const AssetCardSkeleton = () => { + return ( +
    +
    + +
    + + +
    +
    + +
    + ); +}; diff --git a/apps/marginfi-v2-ui/src/components/common/Portfolio/AssetCard/index.ts b/apps/marginfi-v2-ui/src/components/common/Portfolio/AssetCard/index.ts new file mode 100644 index 0000000000..bc24b443ef --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/common/Portfolio/AssetCard/index.ts @@ -0,0 +1 @@ +export * from "./AssetCard"; diff --git a/apps/marginfi-v2-ui/src/components/common/Portfolio/Portfolio.tsx b/apps/marginfi-v2-ui/src/components/common/Portfolio/Portfolio.tsx new file mode 100644 index 0000000000..429b4c5184 --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/common/Portfolio/Portfolio.tsx @@ -0,0 +1,188 @@ +import React from "react"; + +import { useMrgnlendStore, useUserProfileStore } from "~/store"; +import { numeralFormatter } from "@mrgnlabs/mrgn-common"; +import { usdFormatter, usdFormatterDyn } from "@mrgnlabs/mrgn-common"; +import { ActiveBankInfo } from "@mrgnlabs/marginfi-v2-ui-state"; + +import { MrgnTooltip } from "~/components/common/MrgnTooltip"; + +import { IconInfoCircle, IconAlertTriangle } from "~/components/ui/icons"; +import { UserStats } from "./UserStats"; +import { AssetCard, AssetCardSkeleton } from "./AssetCard"; + +export const Portfolio = () => { + const [isStoreInitialized, sortedBanks, accountSummary] = useMrgnlendStore((state) => [ + state.initialized, + state.extendedBankInfos, + state.accountSummary, + ]); + + const [userPointsData] = useUserProfileStore((state) => [state.userPointsData]); + + const lendingBanks = React.useMemo( + () => + sortedBanks && isStoreInitialized + ? (sortedBanks.filter((b) => b.isActive && b.position.isLending) as ActiveBankInfo[]).sort( + (a, b) => b.position.usdValue - a.position.usdValue + ) + : [], + [sortedBanks, isStoreInitialized] + ) as ActiveBankInfo[]; + + const borrowingBanks = React.useMemo( + () => + sortedBanks && isStoreInitialized + ? (sortedBanks.filter((b) => b.isActive && !b.position.isLending) as ActiveBankInfo[]).sort( + (a, b) => b.position.usdValue - a.position.usdValue + ) + : [], + [sortedBanks, isStoreInitialized] + ) as ActiveBankInfo[]; + + const accountSupplied = React.useMemo( + () => + accountSummary + ? Math.round(accountSummary.lendingAmountUnbiased) > 10000 + ? usdFormatterDyn.format(Math.round(accountSummary.lendingAmountUnbiased)) + : usdFormatter.format(accountSummary.lendingAmountUnbiased) + : "-", + [accountSummary.lendingAmountUnbiased] + ); + const accountBorrowed = React.useMemo( + () => + accountSummary + ? Math.round(accountSummary.borrowingAmountUnbiased) > 10000 + ? usdFormatterDyn.format(Math.round(accountSummary.borrowingAmountUnbiased)) + : usdFormatter.format(accountSummary.borrowingAmountUnbiased) + : "-", + [accountSummary.borrowingAmountUnbiased] + ); + const accountNetValue = React.useMemo( + () => + accountSummary + ? Math.round(accountSummary.balanceUnbiased) > 10000 + ? usdFormatterDyn.format(Math.round(accountSummary.balanceUnbiased)) + : usdFormatter.format(accountSummary.balanceUnbiased) + : "-", + [accountSummary.balanceUnbiased] + ); + + const healthColor = React.useMemo(() => { + if (accountSummary.healthFactor) { + let color: string; + + if (accountSummary.healthFactor >= 0.5) { + color = "#75BA80"; // green color " : "#", + } else if (accountSummary.healthFactor >= 0.25) { + color = "#B8B45F"; // yellow color + } else { + color = "#CF6F6F"; // red color + } + + return color; + } else { + return "#fff"; + } + }, [accountSummary.healthFactor]); + return ( +
    +

    Portfolio

    +
    +
    +
    + Health factor + +
    +

    + Health factor is based off of price biased and weighted asset and liability values. +

    +
    + When your account health + reaches 0% or below, you are exposed to liquidation. +
    +

    The formula is:

    +

    {"(assets - liabilities) / (assets)"}

    +

    Your math is:

    +

    {`(${usdFormatter.format( + accountSummary.lendingAmountWithBiasAndWeighted + )} - ${usdFormatter.format( + accountSummary.borrowingAmountWithBiasAndWeighted + )}) / (${usdFormatter.format(accountSummary.lendingAmountWithBiasAndWeighted)})`}

    +
    + + } + placement="top" + > + +
    +
    +
    + {numeralFormatter(accountSummary.healthFactor * 100)}% +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    Supplied
    +
    {accountSupplied}
    +
    + {isStoreInitialized ? ( + lendingBanks.length > 0 ? ( +
    + {lendingBanks.map((bank) => ( + + ))} +
    + ) : ( +
    + No lending positions found. +
    + ) + ) : ( + + )} +
    +
    +
    +
    Borrowed
    +
    {accountBorrowed}
    +
    + {isStoreInitialized ? ( + borrowingBanks.length > 0 ? ( +
    + {borrowingBanks.map((bank) => ( + + ))} +
    + ) : ( +
    + No borrow positions found. +
    + ) + ) : ( + + )} +
    +
    +
    + ); +}; diff --git a/apps/marginfi-v2-ui/src/components/common/Portfolio/UserStats/UserStats.tsx b/apps/marginfi-v2-ui/src/components/common/Portfolio/UserStats/UserStats.tsx new file mode 100644 index 0000000000..4970a62d92 --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/common/Portfolio/UserStats/UserStats.tsx @@ -0,0 +1,26 @@ +import React, { FC } from "react"; + +interface props { + supplied: string; + borrowed: string; + netValue: string; + points: string; +} + +export const UserStats: FC = ({ supplied, borrowed, netValue, points }) => { + return ( +
    + + + + +
    + ); +}; + +const Stat = ({ label, value }: { label: string; value: string }) => ( +
    +
    {label}
    +
    {value}
    +
    +); diff --git a/apps/marginfi-v2-ui/src/components/common/Portfolio/UserStats/index.ts b/apps/marginfi-v2-ui/src/components/common/Portfolio/UserStats/index.ts new file mode 100644 index 0000000000..da0f07fcfa --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/common/Portfolio/UserStats/index.ts @@ -0,0 +1 @@ +export * from "./UserStats"; diff --git a/apps/marginfi-v2-ui/src/components/common/Portfolio/index.tsx b/apps/marginfi-v2-ui/src/components/common/Portfolio/index.tsx new file mode 100644 index 0000000000..7aeddff605 --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/common/Portfolio/index.tsx @@ -0,0 +1 @@ +export * from "./Portfolio"; diff --git a/apps/marginfi-v2-ui/src/components/common/Stats/Stats.tsx b/apps/marginfi-v2-ui/src/components/common/Stats/Stats.tsx new file mode 100644 index 0000000000..d953483fef --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/common/Stats/Stats.tsx @@ -0,0 +1,41 @@ +import React from "react"; + +import { useMrgnlendStore } from "~/store"; + +import { cn } from "~/utils"; + +import { numeralFormatter, usdFormatterDyn } from "@mrgnlabs/mrgn-common"; + +export const Stats = () => { + const [protocolStats] = useMrgnlendStore((state) => [state.protocolStats]); + + const statsList = React.useMemo(() => { + return [ + { + label: "Total deposits", + value: `$${numeralFormatter(protocolStats?.deposits)}`, + }, + { + label: "Total borrows", + value: `$${numeralFormatter(protocolStats?.borrows)}`, + }, + { + label: "Total points", + value: numeralFormatter(protocolStats?.pointsTotal), + }, + ]; + }, [protocolStats]); + + return ( +
      + {statsList.map((stat, index) => ( +
    • +
      +
      {stat.label}
      +
      {stat.value}
      +
      +
    • + ))} +
    + ); +}; diff --git a/apps/marginfi-v2-ui/src/components/common/Stats/index.tsx b/apps/marginfi-v2-ui/src/components/common/Stats/index.tsx new file mode 100644 index 0000000000..30942e0346 --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/common/Stats/index.tsx @@ -0,0 +1 @@ +export * from "./Stats"; diff --git a/apps/marginfi-v2-ui/src/components/common/Wallet/Wallet.tsx b/apps/marginfi-v2-ui/src/components/common/Wallet/Wallet.tsx index fdcce99107..1b7883a5b1 100644 --- a/apps/marginfi-v2-ui/src/components/common/Wallet/Wallet.tsx +++ b/apps/marginfi-v2-ui/src/components/common/Wallet/Wallet.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect } from "react"; import { CopyToClipboard } from "react-copy-to-clipboard"; @@ -51,6 +51,26 @@ export const Wallet = () => { tokens: [], }); + useEffect(() => { + if (walletData && walletData.tokens.length === 0) highlightMoonPay(); + }, [walletData]); + + const highlightMoonPay = () => { + if (!document) return; + const moonpayButton = document.querySelector(`#moonpay-btn`); + const walletSheetItems = document.querySelectorAll(".wallet-sheet-item"); + if (!moonpayButton) return; + + walletSheetItems.forEach((item) => item.classList.add("opacity-30")); + moonpayButton.classList.remove("opacity-30"); + moonpayButton.classList.add("animate-pulse"); + + setTimeout(() => { + walletSheetItems.forEach((item) => item.classList.remove("opacity-30")); + moonpayButton.classList.remove("opacity-30", "animate-pulse"); + }, 2500); + }; + const address = React.useMemo(() => { if (!wallet?.publicKey) return ""; return shortenAddress(wallet?.publicKey?.toString()); @@ -145,7 +165,7 @@ export const Wallet = () => { {walletData ? (
    -
    +
    {
    -
    +

    {walletData.balanceUSD}

    ~{walletData.balanceSOL} SOL

    - +
    + +
    -
    +
    Transfer funds to your marginfi wallet {
    ); }; diff --git a/apps/marginfi-v2-ui/src/components/desktop/Footer/Footer.tsx b/apps/marginfi-v2-ui/src/components/desktop/Footer/Footer.tsx index a714a49894..59af12403a 100644 --- a/apps/marginfi-v2-ui/src/components/desktop/Footer/Footer.tsx +++ b/apps/marginfi-v2-ui/src/components/desktop/Footer/Footer.tsx @@ -1,5 +1,5 @@ import { FC, useEffect, useMemo, useState } from "react"; -import { useUserProfileStore } from "~/store"; +import { useUiStore, useUserProfileStore } from "~/store"; import Switch from "@mui/material/Switch"; import { useRouter } from "next/router"; import SvgIcon from "@mui/material/SvgIcon"; @@ -9,20 +9,22 @@ import AutoStoriesOutlinedIcon from "@mui/icons-material/AutoStoriesOutlined"; import InsightsIcon from "@mui/icons-material/Insights"; import Link from "next/link"; import { GitHub, QuestionMark } from "@mui/icons-material"; +import { UserMode } from "~/types"; -type FooterConfig = { hotkeys: boolean; zoom: boolean; unit: boolean; links: boolean }; +type FooterConfig = { hotkeys: boolean; zoom: boolean; unit: boolean; links: boolean; userMode: boolean }; const DISPLAY_TABLE: { [basePath: string]: FooterConfig } = { - "/swap": { hotkeys: true, zoom: false, unit: false, links: true }, - "/bridge": { hotkeys: true, zoom: false, unit: false, links: true }, - "/earn": { hotkeys: true, zoom: false, unit: false, links: true }, + "/swap": { hotkeys: true, zoom: false, unit: false, links: true, userMode: true }, + "/bridge": { hotkeys: true, zoom: false, unit: false, links: true, userMode: true }, + "/earn": { hotkeys: true, zoom: false, unit: false, links: true, userMode: true }, }; -const DEFAULT_FOOTER_CONFIG: FooterConfig = { hotkeys: true, zoom: false, unit: false, links: true }; -const ROOT_CONFIG: FooterConfig = { hotkeys: true, zoom: true, unit: true, links: true }; +const DEFAULT_FOOTER_CONFIG: FooterConfig = { hotkeys: true, zoom: false, unit: false, links: true, userMode: true }; +const ROOT_CONFIG: FooterConfig = { hotkeys: true, zoom: true, unit: true, links: true, userMode: true }; const Footer: FC = () => { const router = useRouter(); + const [userMode] = useUiStore((state) => [state.userMode]); const footerConfig = useMemo(() => { if (router.pathname === "/") return ROOT_CONFIG; @@ -39,10 +41,13 @@ const Footer: FC = () => { return (
    -
    - {footerConfig.hotkeys && } - {footerConfig.zoom && } - {footerConfig.unit && } +
    +
    + {footerConfig.userMode && } + {footerConfig.hotkeys && userMode === UserMode.PRO && } + {footerConfig.zoom && userMode === UserMode.PRO && } + {footerConfig.unit && userMode === UserMode.PRO && } +
    {footerConfig.links && }
    @@ -57,7 +62,7 @@ const HotkeysInfo: FC = () => { }, []); return ( -
    +
    {isMac ? "⌘" : "^"}+K to see hotkeys
    ); @@ -114,6 +119,35 @@ const LendZoomControl: FC = () => { ); }; +const UserModeControl: FC = () => { + const [userMode, setUserMode] = useUiStore((state) => [state.userMode, state.setUserMode]); + + return ( +
    +
    Lite
    + setUserMode(checked ? UserMode.PRO : UserMode.LITE)} + sx={{ + color: "#868E95", + "& .MuiSwitch-switchBase": { + "&.Mui-checked": { + "& .MuiSwitch-thumb": { + backgroundColor: "#DCE85D", + }, + "& + .MuiSwitch-track": { + backgroundColor: "#DCE85D", + color: "#DCE85D", + }, + }, + }, + }} + checked={userMode === UserMode.PRO} + /> +
    Pro
    +
    + ); +}; + const LendUnitControl: FC = () => { const [denomination, setDenominationUSD] = useUserProfileStore((state) => [ state.denominationUSD, @@ -146,7 +180,7 @@ const LendUnitControl: FC = () => { }; const QuickLinks: FC = () => ( -
    +
    = ({ userPointsData }) => { return ( - <> -
    +
    +
    = ({ userPointsData }) => {
    -
    +
    = ({ userPointsData }) => {
    - +
    ); }; diff --git a/apps/marginfi-v2-ui/src/components/mobile/MobileAssetsList/AssetCard/AssetCard.tsx b/apps/marginfi-v2-ui/src/components/mobile/MobileAssetsList/AssetCard/AssetCard.tsx index 1cac0eed97..ff9a4cc6e2 100644 --- a/apps/marginfi-v2-ui/src/components/mobile/MobileAssetsList/AssetCard/AssetCard.tsx +++ b/apps/marginfi-v2-ui/src/components/mobile/MobileAssetsList/AssetCard/AssetCard.tsx @@ -1,37 +1,28 @@ -import React, { FC, useCallback, useMemo, useState } from "react"; +import React, { FC, useMemo } from "react"; import { WSOL_MINT } from "@mrgnlabs/mrgn-common"; import { ExtendedBankInfo, ActiveBankInfo, ActionType, getCurrentAction } from "@mrgnlabs/marginfi-v2-ui-state"; -import { MarginfiAccountWrapper } from "@mrgnlabs/marginfi-client-v2"; -import { useMrgnlendStore, useUiStore } from "~/store"; -import { executeLendingAction, closeBalance, MarginfiActionParams } from "~/utils"; + +import { useUiStore } from "~/store"; +import { LendingModes } from "~/types"; + import { useAssetItemData } from "~/hooks/useAssetItemData"; -import { useWalletContext } from "~/hooks/useWalletContext"; -import { LSTDialogVariants } from "~/components/common/AssetList"; + import { AssetCardStats } from "./AssetCardStats"; import { AssetCardActions } from "./AssetCardActions"; import { AssetCardPosition } from "./AssetCardPosition"; import { AssetCardHeader } from "./AssetCardHeader"; -import { LendingModes } from "~/types"; export const AssetCard: FC<{ bank: ExtendedBankInfo; activeBank?: ActiveBankInfo; nativeSolBalance: number; isInLendingMode: boolean; - isConnected: boolean; - marginfiAccount: MarginfiAccountWrapper | null; - inputRefs?: React.MutableRefObject>; - showLSTDialog?: (variant: LSTDialogVariants, callback?: () => void) => void; -}> = ({ bank, activeBank, nativeSolBalance, isInLendingMode, marginfiAccount, inputRefs, showLSTDialog }) => { +}> = ({ bank, activeBank, nativeSolBalance, isInLendingMode }) => { const { rateAP, assetWeight, isBankFilled, isBankHigh, bankCap } = useAssetItemData({ bank, isInLendingMode }); - const [mfiClient, fetchMrgnlendState] = useMrgnlendStore((state) => [state.marginfiClient, state.fetchMrgnlendState]); - const setIsRefreshingStore = useMrgnlendStore((state) => state.setIsRefreshingStore); const [lendingMode, isFilteredUserPositions] = useUiStore((state) => [ state.lendingMode, state.isFilteredUserPositions, ]); - const [hasLSTDialogShown, setHasLSTDialogShown] = useState([]); - const { walletContextState } = useWalletContext(); const totalDepositsOrBorrows = useMemo( () => @@ -39,7 +30,8 @@ export const AssetCard: FC<{ ? bank.info.state.totalDeposits : Math.max( 0, - Math.min(bank.info.state.totalDeposits, bank.info.rawBank.config.borrowLimit.toNumber()) - bank.info.state.totalBorrows + Math.min(bank.info.state.totalDeposits, bank.info.rawBank.config.borrowLimit.toNumber()) - + bank.info.state.totalBorrows ), [isInLendingMode, bank.info] ); @@ -54,111 +46,6 @@ export const AssetCard: FC<{ const currentAction: ActionType = useMemo(() => getCurrentAction(isInLendingMode, bank), [isInLendingMode, bank]); - const handleCloseBalance = useCallback(async () => { - try { - await closeBalance({ marginfiAccount, bank }); - } catch (error) { - return; - } - - try { - setIsRefreshingStore(true); - await fetchMrgnlendState(); - } catch (error: any) { - console.log("Error while reloading state"); - console.log(error); - } - }, [bank, marginfiAccount, fetchMrgnlendState, setIsRefreshingStore]); - - const executeLendingActionCb = useCallback( - async ( - amount: number, - { - mfiClient, - actionType: currentAction, - bank, - nativeSolBalance, - marginfiAccount, - walletContextState, - }: Omit - ) => { - await executeLendingAction({ - mfiClient, - actionType: currentAction, - bank, - amount, - nativeSolBalance, - marginfiAccount, - walletContextState, - }); - - // -------- Refresh state - try { - setIsRefreshingStore(true); - await fetchMrgnlendState(); - } catch (error: any) { - console.log("Error while reloading state"); - console.log(error); - } - }, - [fetchMrgnlendState, setIsRefreshingStore] - ); - - const handleLendingAction = useCallback( - async (borrowOrLendAmount: number) => { - if ( - currentAction === ActionType.Deposit && - (bank.meta.tokenSymbol === "SOL" || bank.meta.tokenSymbol === "stSOL") && - !hasLSTDialogShown.includes(bank.meta.tokenSymbol as LSTDialogVariants) && - showLSTDialog - ) { - setHasLSTDialogShown((prev) => [...prev, bank.meta.tokenSymbol as LSTDialogVariants]); - showLSTDialog(bank.meta.tokenSymbol as LSTDialogVariants, async () => { - await executeLendingActionCb(borrowOrLendAmount, { - mfiClient, - actionType: currentAction, - bank, - nativeSolBalance, - marginfiAccount, - walletContextState, - }); - }); - return; - } - - await executeLendingActionCb(borrowOrLendAmount, { - mfiClient, - actionType: currentAction, - bank, - nativeSolBalance, - marginfiAccount, - walletContextState, - }); - - if ( - currentAction === ActionType.Withdraw && - (bank.meta.tokenSymbol === "SOL" || bank.meta.tokenSymbol === "stSOL") && - !hasLSTDialogShown.includes(bank.meta.tokenSymbol as LSTDialogVariants) && - showLSTDialog - ) { - setHasLSTDialogShown((prev) => [...prev, bank.meta.tokenSymbol as LSTDialogVariants]); - showLSTDialog(bank.meta.tokenSymbol as LSTDialogVariants); - return; - } - }, - [ - currentAction, - bank, - hasLSTDialogShown, - showLSTDialog, - executeLendingActionCb, - mfiClient, - nativeSolBalance, - marginfiAccount, - walletContextState, - ] - ); - return (
    )} - +
    ); }; diff --git a/apps/marginfi-v2-ui/src/components/mobile/MobileAssetsList/AssetCard/AssetCardActions.tsx b/apps/marginfi-v2-ui/src/components/mobile/MobileAssetsList/AssetCard/AssetCardActions.tsx index 1c9bf15421..053f08d621 100644 --- a/apps/marginfi-v2-ui/src/components/mobile/MobileAssetsList/AssetCard/AssetCardActions.tsx +++ b/apps/marginfi-v2-ui/src/components/mobile/MobileAssetsList/AssetCard/AssetCardActions.tsx @@ -1,64 +1,34 @@ -import React, { FC, useMemo, useState } from "react"; +import React, { FC } from "react"; import { ExtendedBankInfo, ActionType } from "@mrgnlabs/marginfi-v2-ui-state"; -import { uiToNative } from "@mrgnlabs/mrgn-common"; -import { AssetRowAction, AssetRowInputBox } from "~/components/common/AssetList"; + +import { AssetRowAction } from "~/components/common/AssetList"; +import { ActionBoxDialog } from "~/components/common/ActionBox"; export const AssetCardActions: FC<{ bank: ExtendedBankInfo; - isBankFilled: boolean; - isInLendingMode: boolean; currentAction: ActionType; - inputRefs?: React.MutableRefObject>; - onCloseBalance: () => void; - onBorrowOrLend: (amount: number) => void; -}> = ({ bank, inputRefs, currentAction, onCloseBalance, onBorrowOrLend }) => { - const [borrowOrLendAmount, setBorrowOrLendAmount] = useState(0); - - const maxAmount = useMemo(() => { - switch (currentAction) { - case ActionType.Deposit: - return bank.userInfo.maxDeposit; - case ActionType.Withdraw: - return bank.userInfo.maxWithdraw; - case ActionType.Borrow: - return bank.userInfo.maxBorrow; - case ActionType.Repay: - return bank.userInfo.maxRepay; - } - }, [bank.userInfo, currentAction]); - - const isDust = useMemo(() => bank.isActive && bank.position.isDust, [bank]); - const showCloseBalance = useMemo(() => currentAction === ActionType.Withdraw && isDust, [isDust, currentAction]); // Only case we should show close balance is when we are withdrawing a dust balance, since user receives 0 tokens back (vs repaying a dust balance where the input box will show the smallest unit of the token) - const isActionDisabled = useMemo(() => { - const isValidInput = borrowOrLendAmount > 0; - return (maxAmount === 0 || !isValidInput) && !showCloseBalance; - }, [borrowOrLendAmount, showCloseBalance, maxAmount]); - const isInputDisabled = useMemo(() => maxAmount === 0 && !showCloseBalance, [maxAmount, showCloseBalance]); +}> = ({ bank, currentAction }) => { + const isDust = React.useMemo(() => bank.isActive && bank.position.isDust, [bank]); + const showCloseBalance = React.useMemo( + () => currentAction === ActionType.Withdraw && isDust, + [currentAction, isDust] + ); // Only case we should show close balance is when we are withdrawing a dust balance, since user receives 0 tokens back (vs repaying a dust balance where the input box will show the smallest unit of the token) return ( <>
    - onBorrowOrLend(borrowOrLendAmount)} - /> - (showCloseBalance ? onCloseBalance() : onBorrowOrLend(borrowOrLendAmount))} - disabled={isActionDisabled} - > - {showCloseBalance ? "Close" : currentAction} - + + + {showCloseBalance ? "Close" : currentAction} + +
    ); diff --git a/apps/marginfi-v2-ui/src/components/mobile/MobileAssetsList/MobileAssetsList.tsx b/apps/marginfi-v2-ui/src/components/mobile/MobileAssetsList/MobileAssetsList.tsx index d6bffb3d5c..23005d1cd8 100644 --- a/apps/marginfi-v2-ui/src/components/mobile/MobileAssetsList/MobileAssetsList.tsx +++ b/apps/marginfi-v2-ui/src/components/mobile/MobileAssetsList/MobileAssetsList.tsx @@ -1,99 +1,23 @@ import React from "react"; -import Image from "next/image"; - -import { ExtendedBankInfo, ActiveBankInfo } from "@mrgnlabs/marginfi-v2-ui-state"; -import { Skeleton, Typography } from "@mui/material"; - -import { useMrgnlendStore, useUiStore } from "~/store"; -import { useWalletContext } from "~/hooks/useWalletContext"; - -import { MrgnTooltip } from "~/components/common"; -import { - LSTDialog, - LSTDialogVariants, - AssetListFilters, - sortApRate, - sortTvl, - STABLECOINS, - LSTS, -} from "~/components/common/AssetList"; -import { AssetCard } from "~/components/mobile/MobileAssetsList/AssetCard"; - +import { useUiStore } from "~/store"; import { LendingModes } from "~/types"; -export const MobileAssetsList = () => { - const { connected } = useWalletContext(); - - const [isStoreInitialized, extendedBankInfos, nativeSolBalance, selectedAccount] = useMrgnlendStore((state) => [ - state.initialized, - state.extendedBankInfos, - state.nativeSolBalance, - state.selectedAccount, - ]); +import { LSTDialog, LSTDialogVariants } from "~/components/common/AssetList"; - const [lendingMode, isFilteredUserPositions, sortOption, poolFilter] = useUiStore((state) => [ - state.lendingMode, - state.isFilteredUserPositions, - state.sortOption, - state.poolFilter, - ]); +export const MobileAssetsList = () => { + const [lendingMode] = useUiStore((state) => [state.lendingMode]); const isInLendingMode = React.useMemo(() => lendingMode === LendingModes.LEND, [lendingMode]); - const inputRefs = React.useRef>({}); const [isLSTDialogOpen, setIsLSTDialogOpen] = React.useState(false); const [lstDialogVariant, setLSTDialogVariant] = React.useState(null); const [lstDialogCallback, setLSTDialogCallback] = React.useState<(() => void) | null>(null); - const activeBankInfos = React.useMemo( - () => extendedBankInfos.filter((balance) => balance.isActive), - [extendedBankInfos] - ) as ActiveBankInfo[]; - - const sortBanks = React.useCallback( - (banks: ExtendedBankInfo[]) => { - if (sortOption.field === "APY") { - return sortApRate(banks, isInLendingMode, sortOption.direction); - } else if (sortOption.field === "TVL") { - return sortTvl(banks, sortOption.direction); - } else { - return banks; - } - }, - [isInLendingMode, sortOption] - ); - - const globalBanks = React.useMemo(() => { - const filteredBanks = - extendedBankInfos && - extendedBankInfos - .filter((b) => !b.info.state.isIsolated) - .filter((b) => (isFilteredUserPositions ? b.isActive : true)); - - if (isStoreInitialized && sortOption && filteredBanks) { - return sortBanks(filteredBanks); - } else { - return filteredBanks; - } - }, [isStoreInitialized, extendedBankInfos, sortOption, isFilteredUserPositions, sortBanks]); - - const isolatedBanks = React.useMemo(() => { - const filteredBanks = - extendedBankInfos && - extendedBankInfos - .filter((b) => b.info.state.isIsolated) - .filter((b) => (isFilteredUserPositions ? b.isActive : true)); - - if (isStoreInitialized && sortOption && filteredBanks) { - return sortBanks(filteredBanks); - } else { - return filteredBanks; - } - }, [isStoreInitialized, extendedBankInfos, sortOption, isFilteredUserPositions, sortBanks]); - return ( <> - + {/* {walletAddress && } */} + {/* +
    {poolFilter !== "isolated" && (
    @@ -117,17 +41,7 @@ export const MobileAssetsList = () => { nativeSolBalance={nativeSolBalance} bank={bank} isInLendingMode={isInLendingMode} - isConnected={connected} - marginfiAccount={selectedAccount} - inputRefs={inputRefs} activeBank={activeBank[0]} - showLSTDialog={(variant: LSTDialogVariants, onClose?: () => void) => { - setLSTDialogVariant(variant); - setIsLSTDialogOpen(true); - if (onClose) { - setLSTDialogCallback(() => onClose); - } - }} /> ); })} @@ -187,9 +101,6 @@ export const MobileAssetsList = () => { nativeSolBalance={nativeSolBalance} bank={bank} isInLendingMode={isInLendingMode} - isConnected={connected} - marginfiAccount={selectedAccount} - inputRefs={inputRefs} activeBank={activeBank[0]} /> ); @@ -215,7 +126,7 @@ export const MobileAssetsList = () => { )}
    )} -
    +
    */} = ({ userPointsData }) => { + return ( +
    +
    +
    +
    +
    +
    + + Total Points +
    + + + Points + + Points refresh every 24 hours.{" "} + + } + placement="top" + > +
    + info +
    +
    +
    +
    + + {userPointsData ? ( + `$${numeralFormatter(userPointsData.totalPoints)}` + ) : ( + + )} + +
    + +
    + + Global Rank {/* TODO: fix that with dedicated query */} + + + {userPointsData ? ( + userPointsData.userRank ? ( + `#${groupedNumberFormatterDyn.format(userPointsData.userRank)}` + ) : ( + "-" + ) + ) : ( + + )} + +
    + +
    + + Referral Points +
    + + + Earn more with friends + + Earn 10% of the points any user you refer earns. + + } + placement="top" + > +
    + info +
    +
    +
    +
    + + {userPointsData ? ( + numeralFormatter(userPointsData.referralPoints) + ) : ( + + )} + +
    +
    +
    +
    +
    +
    + ); +}; + +const DividerLine = () =>
    ; diff --git a/apps/marginfi-v2-ui/src/components/mobile/Points/index.ts b/apps/marginfi-v2-ui/src/components/mobile/Points/index.ts new file mode 100644 index 0000000000..8d6de91551 --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/mobile/Points/index.ts @@ -0,0 +1 @@ +export * from "./MobilePointsOverview"; diff --git a/apps/marginfi-v2-ui/src/components/mobile/Points/style.module.css b/apps/marginfi-v2-ui/src/components/mobile/Points/style.module.css new file mode 100644 index 0000000000..ee1506516a --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/mobile/Points/style.module.css @@ -0,0 +1,9 @@ +.hide-scrollbar { + -ms-overflow-style: none; /* for Internet Explorer, Edge */ + scrollbar-width: none; /* for Firefox */ + overflow-x: scroll; +} + +.hide-scrollbar::-webkit-scrollbar { + display: none; /* for Chrome, Safari, and Opera */ +} diff --git a/apps/marginfi-v2-ui/src/components/ui/accordion.tsx b/apps/marginfi-v2-ui/src/components/ui/accordion.tsx new file mode 100644 index 0000000000..bdb5b58335 --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/ui/accordion.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; + +import { cn } from "~/utils/themeUtils"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
    {children}
    +
    +)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/apps/marginfi-v2-ui/src/components/ui/button.tsx b/apps/marginfi-v2-ui/src/components/ui/button.tsx index 7f6fd3342e..f3e8c4f427 100644 --- a/apps/marginfi-v2-ui/src/components/ui/button.tsx +++ b/apps/marginfi-v2-ui/src/components/ui/button.tsx @@ -11,7 +11,7 @@ const buttonVariants = cva( variant: { default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", - outline: "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", + outline: "border border-input bg-transparent shadow-sm hover:bg-background-gray-hover", secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", diff --git a/apps/marginfi-v2-ui/src/components/ui/command.tsx b/apps/marginfi-v2-ui/src/components/ui/command.tsx index 94c2b0cfd1..a431ef0cb4 100644 --- a/apps/marginfi-v2-ui/src/components/ui/command.tsx +++ b/apps/marginfi-v2-ui/src/components/ui/command.tsx @@ -27,7 +27,7 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => { return ( - + {children} @@ -39,7 +39,7 @@ const CommandInput = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( -
    +
    ( ); +const IconMoonPay = ({ size = 80, className }: IconProps) => ( + + + +); + export { // tabler icons IconCaretUpDownFilled, @@ -523,6 +537,8 @@ export { IconFilter, IconSortAscending, IconSortDescending, + IconInfoCircle, + IconArrowRight, // customed icons IconBrandGoogle, @@ -545,4 +561,5 @@ export { IconCoins, IconBridge, IconMrgn, + IconMoonPay, }; diff --git a/apps/marginfi-v2-ui/src/components/ui/input.tsx b/apps/marginfi-v2-ui/src/components/ui/input.tsx index cf28588d24..0ac3c9495d 100644 --- a/apps/marginfi-v2-ui/src/components/ui/input.tsx +++ b/apps/marginfi-v2-ui/src/components/ui/input.tsx @@ -13,6 +13,7 @@ const Input = React.forwardRef(({ className, type, className )} ref={ref} + max={50} {...props} /> ); diff --git a/apps/marginfi-v2-ui/src/components/ui/popover.tsx b/apps/marginfi-v2-ui/src/components/ui/popover.tsx index 88387fdd52..f745aebdfb 100644 --- a/apps/marginfi-v2-ui/src/components/ui/popover.tsx +++ b/apps/marginfi-v2-ui/src/components/ui/popover.tsx @@ -1,11 +1,11 @@ -import * as React from "react" -import * as PopoverPrimitive from "@radix-ui/react-popover" +import * as React from "react"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; -import { cn } from "~/utils/themeUtils" +import { cn } from "~/utils/themeUtils"; -const Popover = PopoverPrimitive.Root +const Popover = PopoverPrimitive.Root; -const PopoverTrigger = PopoverPrimitive.Trigger +const PopoverTrigger = PopoverPrimitive.Trigger; const PopoverContent = React.forwardRef< React.ElementRef, @@ -23,7 +23,7 @@ const PopoverContent = React.forwardRef< {...props} /> -)) -PopoverContent.displayName = PopoverPrimitive.Content.displayName +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; -export { Popover, PopoverTrigger, PopoverContent } +export { Popover, PopoverTrigger, PopoverContent }; diff --git a/apps/marginfi-v2-ui/src/components/ui/select.tsx b/apps/marginfi-v2-ui/src/components/ui/select.tsx index 2ed74db199..3fa87a7a9b 100644 --- a/apps/marginfi-v2-ui/src/components/ui/select.tsx +++ b/apps/marginfi-v2-ui/src/components/ui/select.tsx @@ -12,8 +12,10 @@ const SelectValue = SelectPrimitive.Value; const SelectTrigger = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + icon?: React.ReactNode; + } +>(({ className, children, icon, ...props }, ref) => ( {children} - - - + {icon ?? } )); SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; diff --git a/apps/marginfi-v2-ui/src/components/ui/skeleton.tsx b/apps/marginfi-v2-ui/src/components/ui/skeleton.tsx new file mode 100644 index 0000000000..cbda66a73d --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/ui/skeleton.tsx @@ -0,0 +1,7 @@ +import { cn } from "~/utils/themeUtils"; + +function Skeleton({ className, ...props }: React.HTMLAttributes) { + return
    ; +} + +export { Skeleton }; diff --git a/apps/marginfi-v2-ui/src/hooks/useDebounce.tsx b/apps/marginfi-v2-ui/src/hooks/useDebounce.tsx new file mode 100644 index 0000000000..0021d39e13 --- /dev/null +++ b/apps/marginfi-v2-ui/src/hooks/useDebounce.tsx @@ -0,0 +1,15 @@ +import { useEffect, useState } from 'react' + +export function useDebounce(value: T, delay?: number): T { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay || 500) + + return () => { + clearTimeout(timer) + } + }, [value, delay]) + + return debouncedValue +} \ No newline at end of file diff --git a/apps/marginfi-v2-ui/src/pages/index.tsx b/apps/marginfi-v2-ui/src/pages/index.tsx index 5c4d66fcee..7772f7f9ce 100644 --- a/apps/marginfi-v2-ui/src/pages/index.tsx +++ b/apps/marginfi-v2-ui/src/pages/index.tsx @@ -7,17 +7,20 @@ import { shortenAddress } from "@mrgnlabs/mrgn-common"; import config from "~/config/marginfi"; import { Desktop, Mobile } from "~/mediaQueries"; -import { useMrgnlendStore } from "~/store"; +import { useMrgnlendStore, useUiStore } from "~/store"; import { useConnection } from "~/hooks/useConnection"; import { useWalletContext } from "~/hooks/useWalletContext"; import { Banner } from "~/components/desktop/Banner"; import { OverlaySpinner } from "~/components/desktop/OverlaySpinner"; import { PageHeader } from "~/components/common/PageHeader"; +import { ActionBox } from "~/components/common/ActionBox"; +import { Stats } from "~/components/common/Stats"; import { IconAlertTriangle } from "~/components/ui/icons"; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger } from "~/components/ui/select"; -import { showErrorToast, showWarningToast } from "~/utils/toastUtils"; +import { UserMode } from "~/types"; +import { showWarningToast } from "~/utils/toastUtils"; const DesktopAccountSummary = dynamic( async () => (await import("~/components/desktop/DesktopAccountSummary")).DesktopAccountSummary, @@ -35,6 +38,7 @@ const Home = () => { const { walletAddress, wallet, isOverride } = useWalletContext(); const { connection } = useConnection(); const debounceId = React.useRef(null); + const [userMode] = useUiStore((state) => [state.userMode]); const [ fetchMrgnlendState, isStoreInitialized, @@ -53,12 +57,6 @@ const Home = () => { state.emissionTokenMap, ]); - React.useEffect(() => { - if (emissionTokenMap === null) { - showWarningToast("Failed to fetch prices, emission APY may be incorrect."); - } - }, [emissionTokenMap]); - React.useEffect(() => { const fetchData = () => { setIsRefreshingStore(true); @@ -114,9 +112,10 @@ const Home = () => { setIsRefreshing={setIsRefreshingStore} /> )} - + + {userMode === UserMode.LITE && }
    -
    +
    @@ -133,7 +132,9 @@ const Home = () => { setIsRefreshing={setIsRefreshingStore} /> )} -
    + + +
    diff --git a/apps/marginfi-v2-ui/src/pages/portfolio.tsx b/apps/marginfi-v2-ui/src/pages/portfolio.tsx index c31a45c85e..3aec5b754b 100644 --- a/apps/marginfi-v2-ui/src/pages/portfolio.tsx +++ b/apps/marginfi-v2-ui/src/pages/portfolio.tsx @@ -3,72 +3,39 @@ import React from "react"; import { useRouter } from "next/router"; import Link from "next/link"; -import { Button, Skeleton, Typography } from "@mui/material"; import FileCopyIcon from "@mui/icons-material/FileCopy"; import CheckIcon from "@mui/icons-material/Check"; import { CopyToClipboard } from "react-copy-to-clipboard"; import config from "~/config/marginfi"; -import { useMrgnlendStore, useUserProfileStore } from "~/store"; +import { useMrgnlendStore, useUiStore, useUserProfileStore } from "~/store"; import { useConnection } from "~/hooks/useConnection"; import { useWalletContext } from "~/hooks/useWalletContext"; import { PageHeader } from "~/components/common/PageHeader"; -import { MobileAccountSummary } from "~/components/mobile/MobileAccountSummary"; -import { MobilePortfolioOverview } from "~/components/mobile/MobilePortfolioOverview"; -import { PointsOverview, PointsSignIn, PointsSignUp, PointsCheckingUser } from "~/components/desktop/Points"; -import { AssetCard } from "~/components/mobile/MobileAssetsList/AssetCard"; +import { PointsCheckingUser, PointsConnectWallet } from "~/components/desktop/Points"; import { EmissionsBanner } from "~/components/mobile/EmissionsBanner"; +import { Portfolio } from "~/components/common/Portfolio"; +import { Button } from "~/components/ui/button"; +import { MobilePointsOverview } from "~/components/mobile/Points/MobilePointsOverview"; const PortfolioPage = () => { const { connected, wallet, isOverride } = useWalletContext(); const { query: routerQuery } = useRouter(); const { connection } = useConnection(); - const [isStoreInitialized, sortedBanks, nativeSolBalance, selectedAccount, fetchMrgnlendState, setIsRefreshingStore] = - useMrgnlendStore((state) => [ - state.initialized, - state.extendedBankInfos, - state.nativeSolBalance, - state.selectedAccount, - state.fetchMrgnlendState, - state.setIsRefreshingStore, - ]); + const [fetchMrgnlendState, setIsRefreshingStore] = useMrgnlendStore((state) => [ + state.fetchMrgnlendState, + state.setIsRefreshingStore, + ]); const [currentFirebaseUser, hasUser, userPointsData] = useUserProfileStore((state) => [ state.currentFirebaseUser, state.hasUser, state.userPointsData, ]); + const [setIsWalletAuthDialogOpen] = useUiStore((state) => [state.setIsWalletAuthDialogOpen]); const referralCode = React.useMemo(() => routerQuery.referralCode as string | undefined, [routerQuery.referralCode]); const [isReferralCopied, setIsReferralCopied] = React.useState(false); - const lendingBanks = React.useMemo( - () => - sortedBanks && isStoreInitialized - ? sortedBanks - .filter((b) => b.info.rawBank.config.assetWeightInit.toNumber() > 0) - .filter((b) => b.isActive && b.position.isLending) - .sort( - (a, b) => - b.info.state.totalDeposits * b.info.state.price - a.info.state.totalDeposits * a.info.state.price - ) - : [], - [sortedBanks, isStoreInitialized] - ); - - const borrowingBanks = React.useMemo( - () => - sortedBanks && isStoreInitialized - ? sortedBanks - .filter((b) => b.info.rawBank.config.assetWeightInit.toNumber() > 0) - .filter((b) => b.isActive && !b.position.isLending) - .sort( - (a, b) => - b.info.state.totalDeposits * b.info.state.price - a.info.state.totalDeposits * a.info.state.price - ) - : [], - [sortedBanks, isStoreInitialized] - ); - React.useEffect(() => { setIsRefreshingStore(true); fetchMrgnlendState({ marginfiConfig: config.mfiConfig, connection, wallet, isOverride }).catch(console.error); @@ -85,66 +52,44 @@ const PortfolioPage = () => { <> portfolio
    - - - {!connected ? null : currentFirebaseUser ? ( - - ) : hasUser === null ? ( - - ) : hasUser ? ( - + {!connected ? ( + + ) : userPointsData ? ( + ) : ( - + )}
    - - {userPointsData.referralLink && userPointsData.referralLink.length > 0 && ( - { + + + { + if (userPointsData.referralLink && userPointsData.referralLink.length > 0) { setIsReferralCopied(true); setTimeout(() => setIsReferralCopied(false), 2000); - }} - > - - - )} + } else { + setIsWalletAuthDialogOpen(true); + } + }} + > + +
    We reserve the right to update point calculations at any time.
    @@ -154,65 +99,7 @@ const PortfolioPage = () => {
    - {sortedBanks && ( -
    -
    Lending positions
    - - {isStoreInitialized ? ( - lendingBanks.length > 0 ? ( -
    - {lendingBanks.map((bank) => ( - - ))} -
    - ) : ( - - No lending positions found. - - ) - ) : ( - - )} -
    - )} - - {sortedBanks && ( -
    -
    - Borrowing positions -
    - - {isStoreInitialized ? ( - borrowingBanks.length > 0 ? ( -
    - {borrowingBanks.map((bank) => ( - - ))} -
    - ) : ( - - No borrowing positions found. - - ) - ) : ( - - )} -
    - )} +
    ); diff --git a/apps/marginfi-v2-ui/src/store/uiStore.ts b/apps/marginfi-v2-ui/src/store/uiStore.ts index f57275c55e..9f8dd067ac 100644 --- a/apps/marginfi-v2-ui/src/store/uiStore.ts +++ b/apps/marginfi-v2-ui/src/store/uiStore.ts @@ -1,6 +1,9 @@ import { create, StateCreator } from "zustand"; -import { LendingModes, PoolTypes, SortType, sortDirection, SortAssetOption } from "~/types"; +import { ActionType } from "@mrgnlabs/marginfi-v2-ui-state"; + +import { LendingModes, PoolTypes, SortType, sortDirection, SortAssetOption, UserMode } from "~/types"; +import { PublicKey } from "@solana/web3.js"; const SORT_OPTIONS_MAP: { [key in SortType]: SortAssetOption } = { APY_DESC: { @@ -42,6 +45,7 @@ interface UiState { lendingMode: LendingModes; poolFilter: PoolTypes; sortOption: SortAssetOption; + userMode: UserMode; // Actions setIsMenuDrawerOpen: (isOpen: boolean) => void; @@ -53,6 +57,7 @@ interface UiState { setLendingMode: (lendingMode: LendingModes) => void; setPoolFilter: (poolType: PoolTypes) => void; setSortOption: (sortOption: SortAssetOption) => void; + setUserMode: (userMode: UserMode) => void; } function createUiStore() { @@ -68,8 +73,11 @@ const stateCreator: StateCreator = (set, get) => ({ isWalletOnrampActive: false, isFilteredUserPositions: false, lendingMode: LendingModes.LEND, + actionMode: ActionType.Deposit, poolFilter: PoolTypes.ALL, sortOption: SORT_OPTIONS_MAP[SortType.TVL_DESC], + userMode: UserMode.LITE, + selectedTokenBank: null, // Actions setIsMenuDrawerOpen: (isOpen: boolean) => set({ isMenuDrawerOpen: isOpen }), @@ -79,9 +87,13 @@ const stateCreator: StateCreator = (set, get) => ({ setIsOnrampActive: (isOnrampActive: boolean) => set({ isWalletOnrampActive: isOnrampActive }), setIsFilteredUserPositions: (isFilteredUserPositions: boolean) => set({ isFilteredUserPositions: isFilteredUserPositions }), - setLendingMode: (lendingMode: LendingModes) => set({ lendingMode: lendingMode }), + setLendingMode: (lendingMode: LendingModes) => + set({ + lendingMode: lendingMode, + }), setPoolFilter: (poolType: PoolTypes) => set({ poolFilter: poolType }), setSortOption: (sortOption: SortAssetOption) => set({ sortOption: sortOption }), + setUserMode: (userMode: UserMode) => set({ userMode: userMode }), }); export { createUiStore, SORT_OPTIONS_MAP }; diff --git a/apps/marginfi-v2-ui/src/styles/globals.css b/apps/marginfi-v2-ui/src/styles/globals.css index 9ce4222bb2..31f475bd4e 100644 --- a/apps/marginfi-v2-ui/src/styles/globals.css +++ b/apps/marginfi-v2-ui/src/styles/globals.css @@ -239,6 +239,12 @@ a { background: #555; } +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + @keyframes wavyGradientAnimation { 0% { background-position: 200% 0; diff --git a/apps/marginfi-v2-ui/src/types.ts b/apps/marginfi-v2-ui/src/types.ts index 473aa36c01..3583329315 100644 --- a/apps/marginfi-v2-ui/src/types.ts +++ b/apps/marginfi-v2-ui/src/types.ts @@ -110,3 +110,8 @@ export enum SortType { TVL_ASC = "TVL_ASC", TVL_DESC = "TVL_DESC", } + +export enum UserMode { + LITE = "lite", + PRO = "pro", +} diff --git a/apps/marginfi-v2-ui/src/utils/mrgnUtils.ts b/apps/marginfi-v2-ui/src/utils/mrgnUtils.ts index 4f25f86570..cb32da2c2a 100644 --- a/apps/marginfi-v2-ui/src/utils/mrgnUtils.ts +++ b/apps/marginfi-v2-ui/src/utils/mrgnUtils.ts @@ -3,6 +3,7 @@ import BN from "bn.js"; import { TOKEN_PROGRAM_ID, ceil, floor } from "@mrgnlabs/mrgn-common"; import { ActiveBankInfo } from "@mrgnlabs/marginfi-v2-ui-state"; import { useEffect, useRef } from "react"; +import numeral from "numeral"; // ================ development utils ================ @@ -50,3 +51,46 @@ export function usePrevious(value: T): T | undefined { }, [value]); return ref.current; } + +export function getInitHealthColor(health: number): string { + if (health >= 0.5) { + return "#75BA80"; // green color " : "#", + } else if (health >= 0.25) { + return "#b8b45f"; // yellow color + } else { + return "#CF6F6F"; // red color + } +} + +export function getMaintHealthColor(health: number): string { + if (health >= 0.5) { + return "#75BA80"; // green color " : "#", + } else if (health >= 0.25) { + return "#b8b45f"; // yellow color + } else { + return "#CF6F6F"; // red color + } +} + +export function getLiquidationPriceColor(currentPrice: number, liquidationPrice: number): string { + const safety = liquidationPrice / currentPrice; + let color: string; + if (safety >= 0.5) { + color = "#75BA80"; + } else if (safety >= 0.25) { + color = "#B8B45F"; + } else { + color = "#CF6F6F"; + } + return color; +} + +export const clampedNumeralFormatter = (value: number) => { + if (value === 0) { + return "0"; + } else if (value < 0.01) { + return "< 0.01"; + } else { + return numeral(value).format("0.00a"); + } +}; diff --git a/apps/marginfi-v2-ui/tailwind.config.js b/apps/marginfi-v2-ui/tailwind.config.js index 05ccc76ca2..c4d04c27bc 100644 --- a/apps/marginfi-v2-ui/tailwind.config.js +++ b/apps/marginfi-v2-ui/tailwind.config.js @@ -43,7 +43,10 @@ module.exports = { input: "hsl(var(--input))", ring: "hsl(var(--ring))", background: "hsl(var(--background))", - "background-gray": "#1A1F22", + "background-gray": "#1C2125", + "background-gray-hover": "#292F34", + "background-gray-light": "#303030", + "background-gray-dark": "#131618", foreground: "hsl(var(--foreground))", chartreuse: "#DCE85D", primary: { @@ -54,6 +57,10 @@ module.exports = { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))", }, + alert: { + DEFAULT: "hsl(35 14% 17%)", + foreground: "hsl(33 96% 61%)", + }, destructive: { DEFAULT: "var(--destructive)", foreground: "hsl(var(--destructive-foreground))", diff --git a/apps/marginfi-v2-xnft/src/components/Lend/PoolCard/PoolCardStats.tsx b/apps/marginfi-v2-xnft/src/components/Lend/PoolCard/PoolCardStats.tsx index b499f99510..abf709d52e 100644 --- a/apps/marginfi-v2-xnft/src/components/Lend/PoolCard/PoolCardStats.tsx +++ b/apps/marginfi-v2-xnft/src/components/Lend/PoolCard/PoolCardStats.tsx @@ -30,7 +30,8 @@ export function PoolCardStats({ bank, isInLendingMode, nativeSolBalance, bankFil numeralFormatter( isInLendingMode ? bank.info.state.totalDeposits - : Math.min(bank.info.state.totalDeposits, bank.info.rawBank.config.borrowLimit.toNumber()) - bank.info.state.totalBorrows + : Math.min(bank.info.state.totalDeposits, bank.info.rawBank.config.borrowLimit.toNumber()) - + bank.info.state.totalBorrows ), [isInLendingMode, bank] ); diff --git a/apps/marginfi-v2-xnft/src/styles/globals.css b/apps/marginfi-v2-xnft/src/styles/globals.css index 2eeaf8ac80..42ef0fec42 100644 --- a/apps/marginfi-v2-xnft/src/styles/globals.css +++ b/apps/marginfi-v2-xnft/src/styles/globals.css @@ -30,6 +30,9 @@ --accent: 210 40% 96.1%; --accent-foreground: 222.2 47.4% 11.2%; + --alert: 35 14% 17%; + --alert-foreground: 33 96% 61%; + --destructive: 0 100% 50%; --destructive-foreground: 210 40% 98%; diff --git a/packages/marginfi-client-v2/src/client.ts b/packages/marginfi-client-v2/src/client.ts index 6996b12a02..6900882d50 100644 --- a/packages/marginfi-client-v2/src/client.ts +++ b/packages/marginfi-client-v2/src/client.ts @@ -60,10 +60,7 @@ class MarginfiClient { // Factories // -------------------------------------------------------------------------- - /** - * @internal - */ - private constructor( + constructor( readonly config: MarginfiConfig, readonly program: MarginfiProgram, readonly wallet: Wallet, @@ -73,8 +70,8 @@ class MarginfiClient { priceInfos: OraclePriceMap, addressLookupTables?: AddressLookupTableAccount[], preloadedBankAddresses?: PublicKey[], - readonly bankMetadataMap?: BankMetadataMap, - ) { + readonly bankMetadataMap?: BankMetadataMap + ) { this.group = group; this.banks = banks; this.oraclePrices = priceInfos; @@ -151,7 +148,7 @@ class MarginfiClient { priceInfos, addressLookupTables, preloadedBankAddresses, - bankMetadataMap, + bankMetadataMap ); } @@ -241,7 +238,8 @@ class MarginfiClient { const banks = new Map( bankDatasKeyed.map(({ address, data }) => { const bankMetadata = bankMetadataMap ? bankMetadataMap[address.toBase58()] : undefined; - return [address.toBase58(), Bank.fromAccountParsed(address, data, bankMetadata)]}) + return [address.toBase58(), Bank.fromAccountParsed(address, data, bankMetadata)]; + }) ); debug("Decoded banks"); @@ -614,6 +612,48 @@ class MarginfiClient { throw new ProcessTransactionError(error.message, ProcessTransactionErrorType.FallthroughError); } } + + async simulateTransaction( + transaction: Transaction | VersionedTransaction, + accountsToInspect: PublicKey[] + ): Promise<(Buffer | null)[]> { + let versionedTransaction: VersionedTransaction; + const connection = new Connection(this.provider.connection.rpcEndpoint, this.provider.opts); + let blockhash: string; + + try { + const getLatestBlockhashAndContext = await connection.getLatestBlockhashAndContext(); + + blockhash = getLatestBlockhashAndContext.value.blockhash; + + if (transaction instanceof Transaction) { + const versionedMessage = new TransactionMessage({ + instructions: transaction.instructions, + payerKey: this.provider.publicKey, + recentBlockhash: blockhash, + }); + + versionedTransaction = new VersionedTransaction(versionedMessage.compileToV0Message(this.addressLookupTables)); + } else { + versionedTransaction = transaction; + } + } catch (error: any) { + console.log("Failed to build the transaction", error); + throw new ProcessTransactionError(error.message, ProcessTransactionErrorType.TransactionBuildingError); + } + + try { + const response = await connection.simulateTransaction(versionedTransaction, { + sigVerify: false, + accounts: { encoding: "base64", addresses: accountsToInspect.map((a) => a.toBase58()) }, + }); + if (response.value.err) throw new Error(JSON.stringify(response.value.err)); + return response.value.accounts?.map((a) => (a ? Buffer.from(a.data[0], "base64") : null)) ?? []; + } catch (error: any) { + console.log(error); + throw new Error("Failed to simulate transaction"); + } + } } export default MarginfiClient; diff --git a/packages/marginfi-client-v2/src/constants.ts b/packages/marginfi-client-v2/src/constants.ts index ee27e72296..7b85f41a85 100644 --- a/packages/marginfi-client-v2/src/constants.ts +++ b/packages/marginfi-client-v2/src/constants.ts @@ -14,5 +14,5 @@ export const SWB_PRICE_CONF_INTERVALS = new BigNumber(2.12); export const USDC_DECIMALS = 6; export const ADDRESS_LOOKUP_TABLE_FOR_GROUP: { [key: string]: [PublicKey] } = { - "4qp6Fx6tnZkY5Wropq9wUYgtFxXKwE6viZxFHg3rdAG8": [new PublicKey("2FyGQ8UZ6PegCSN2Lu7QD1U2UY28GpJdDfdwEfbwxN7p")], + "4qp6Fx6tnZkY5Wropq9wUYgtFxXKwE6viZxFHg3rdAG8": [new PublicKey("2FyGQ8UZ6PegCSN2Lu7QD1U2UY28GpJdDfdwEfbwxN7p")], }; diff --git a/packages/marginfi-client-v2/src/idl/marginfi-types.ts b/packages/marginfi-client-v2/src/idl/marginfi-types.ts index 11a39e5fbc..13d554ac0e 100644 --- a/packages/marginfi-client-v2/src/idl/marginfi-types.ts +++ b/packages/marginfi-client-v2/src/idl/marginfi-types.ts @@ -1626,14 +1626,14 @@ export type Marginfi = { }; }, { - name: "ignore2", + name: "ignore2"; type: { - array: ["u8", 7], - }, + array: ["u8", 7]; + }; }, { - name: "totalAssetValueInitLimit", - type: "u64", + name: "totalAssetValueInitLimit"; + type: "u64"; }, { name: "padding"; diff --git a/packages/marginfi-client-v2/src/idl/marginfi.json b/packages/marginfi-client-v2/src/idl/marginfi.json index 27b113c669..e6671b102e 100644 --- a/packages/marginfi-client-v2/src/idl/marginfi.json +++ b/packages/marginfi-client-v2/src/idl/marginfi.json @@ -1426,10 +1426,20 @@ "defined": "RiskTier" } }, + { + "name": "ignore2", + "type": { + "array": ["u8", 7] + } + }, + { + "name": "totalAssetValueInitLimit", + "type": "u64" + }, { "name": "padding", "type": { - "array": ["u64", 6] + "array": ["u8", 40] } } ] diff --git a/packages/marginfi-client-v2/src/models/account/pure.ts b/packages/marginfi-client-v2/src/models/account/pure.ts index 78bf7e1e58..eaf34a0487 100644 --- a/packages/marginfi-client-v2/src/models/account/pure.ts +++ b/packages/marginfi-client-v2/src/models/account/pure.ts @@ -88,7 +88,7 @@ class MarginfiAccount { liabilities: BigNumber; } { const filteredBalances = this.activeBalances.filter( - (accountBalance) => !excludedBanks.find(b => b.equals(accountBalance.bankPk)) + (accountBalance) => !excludedBanks.find((b) => b.equals(accountBalance.bankPk)) ); const [assets, liabilities] = filteredBalances .map((accountBalance) => { @@ -191,16 +191,44 @@ class MarginfiAccount { computeMaxBorrowForBank( banks: Map, oraclePrices: Map, - bankAddress: PublicKey + bankAddress: PublicKey, + opts?: { volatilityFactor?: number } ): BigNumber { const bank = banks.get(bankAddress.toBase58()); if (!bank) throw Error(`Bank ${bankAddress.toBase58()} not found`); const priceInfo = oraclePrices.get(bankAddress.toBase58()); if (!priceInfo) throw Error(`Price info for ${bankAddress.toBase58()} not found`); + // -------------------------- // + // isolated asset constraints // + // -------------------------- // + + const attemptingToBorrowIsolatedAssetWithActiveDebt = + bank.config.riskTier === RiskTier.Isolated && + !this.computeHealthComponents(banks, oraclePrices, MarginRequirementType.Equity, [ + bankAddress, + ]).liabilities.isZero(); + + const existingLiabilityBanks = this.activeBalances + .filter((b) => b.liabilityShares.gt(0)) + .map((b) => banks.get(b.bankPk.toBase58())!); + + const attemptingToBorrowNewAssetWithExistingIsolatedDebt = existingLiabilityBanks.some( + (b) => b.config.riskTier === RiskTier.Isolated && !b.address.equals(bankAddress) + ); + if (attemptingToBorrowIsolatedAssetWithActiveDebt || attemptingToBorrowNewAssetWithExistingIsolatedDebt) { + return BigNumber(0); + } + + // ------------- // + // FC-based calc // + // ------------- // + + const _volatilityFactor = opts?.volatilityFactor ?? 1; + const balance = this.getBalance(bankAddress); - const freeCollateral = this.computeFreeCollateral(banks, oraclePrices); + const freeCollateral = this.computeFreeCollateral(banks, oraclePrices).times(_volatilityFactor); const untiedCollateralForBank = BigNumber.min( bank.computeAssetUsdValue(priceInfo, balance.assetShares, MarginRequirementType.Initial, PriceBias.Lowest), freeCollateral @@ -322,7 +350,7 @@ class MarginfiAccount { public computeLiquidationPriceForBank( banks: Map, oraclePrices: Map, - bankAddress: PublicKey, + bankAddress: PublicKey ): number | null { const bank = banks.get(bankAddress.toBase58()); if (!bank) throw Error(`Bank ${bankAddress.toBase58()} not found`); @@ -334,22 +362,79 @@ class MarginfiAccount { if (!balance.active) return null; const isLending = balance.liabilityShares.isZero(); - const { assets, liabilities } = this.computeHealthComponents(banks, oraclePrices, MarginRequirementType.Maintenance, [bankAddress]); + const { assets, liabilities } = this.computeHealthComponents( + banks, + oraclePrices, + MarginRequirementType.Maintenance, + [bankAddress] + ); const { assets: assetQuantityUi, liabilities: liabQuantitiesUi } = balance.computeQuantityUi(bank); + let liquidationPrice: BigNumber; + if (isLending) { + if (liabilities.eq(0)) return null; + + const assetWeight = bank.getAssetWeight(MarginRequirementType.Maintenance, priceInfo); + const priceConfidence = bank + .getPrice(priceInfo, PriceBias.None) + .minus(bank.getPrice(priceInfo, PriceBias.Lowest)); + liquidationPrice = liabilities.minus(assets).div(assetQuantityUi.times(assetWeight)).plus(priceConfidence); + } else { + const liabWeight = bank.getLiabilityWeight(MarginRequirementType.Maintenance); + const priceConfidence = bank + .getPrice(priceInfo, PriceBias.Highest) + .minus(bank.getPrice(priceInfo, PriceBias.None)); + liquidationPrice = assets.minus(liabilities).div(liabQuantitiesUi.times(liabWeight)).minus(priceConfidence); + } + if (liquidationPrice.isNaN() || liquidationPrice.lt(0)) return null; + return liquidationPrice.toNumber(); + } + + /** + * Calculate the price at which the user position for the given bank and amount will lead to liquidation, all other prices constant. + */ + public computeLiquidationPriceForBankAmount( + banks: Map, + oraclePrices: Map, + bankAddress: PublicKey, + isLending: boolean, + amount: number + ): number | null { + const bank = banks.get(bankAddress.toBase58()); + if (!bank) throw Error(`Bank ${bankAddress.toBase58()} not found`); + const priceInfo = oraclePrices.get(bankAddress.toBase58()); + if (!priceInfo) throw Error(`Price info for ${bankAddress.toBase58()} not found`); + + const balance = this.getBalance(bankAddress); + + if (!balance.active) return null; + + const { assets, liabilities } = this.computeHealthComponents( + banks, + oraclePrices, + MarginRequirementType.Maintenance, + [bankAddress] + ); + const amountBn = new BigNumber(amount); + + let liquidationPrice: BigNumber; if (isLending) { if (liabilities.eq(0)) return null; const assetWeight = bank.getAssetWeight(MarginRequirementType.Maintenance, priceInfo); - const priceConfidence = bank.getPrice(priceInfo, PriceBias.None).minus(bank.getPrice(priceInfo, PriceBias.Lowest)); - const liquidationPrice = liabilities.minus(assets).div(assetQuantityUi.times(assetWeight)).plus(priceConfidence); - return liquidationPrice.toNumber(); + const priceConfidence = bank + .getPrice(priceInfo, PriceBias.None) + .minus(bank.getPrice(priceInfo, PriceBias.Lowest)); + liquidationPrice = liabilities.minus(assets).div(amountBn.times(assetWeight)).plus(priceConfidence); } else { const liabWeight = bank.getLiabilityWeight(MarginRequirementType.Maintenance); - const priceConfidence = bank.getPrice(priceInfo, PriceBias.Highest).minus(bank.getPrice(priceInfo, PriceBias.None)); - const liquidationPrice = assets.minus(liabilities).div(liabQuantitiesUi.times(liabWeight)).minus(priceConfidence); - return liquidationPrice.toNumber(); + const priceConfidence = bank + .getPrice(priceInfo, PriceBias.Highest) + .minus(bank.getPrice(priceInfo, PriceBias.None)); + liquidationPrice = assets.minus(liabilities).div(amountBn.times(liabWeight)).minus(priceConfidence); } + if (liquidationPrice.isNaN() || liquidationPrice.lt(0)) return null; + return liquidationPrice.toNumber(); } // Calculate the max amount of collateral to liquidate to bring an account maint health to 0 (assuming negative health). @@ -392,9 +477,17 @@ class MarginfiAccount { const priceLiabMarket = liabilityBank.getPrice(liabilityPriceInfo, PriceBias.None); const liabMaintWeight = liabilityBank.config.liabilityWeightMaint; - debug("h: %d, w_a: %d, w_l: %d, d: %d", currentHealth.toFixed(6), assetMaintWeight, liabMaintWeight, liquidationDiscount); + debug( + "h: %d, w_a: %d, w_l: %d, d: %d", + currentHealth.toFixed(6), + assetMaintWeight, + liabMaintWeight, + liquidationDiscount + ); - const underwaterMaintUsdValue = currentHealth.div(assetMaintWeight.minus(liabMaintWeight.times(liquidationDiscount))); + const underwaterMaintUsdValue = currentHealth.div( + assetMaintWeight.minus(liabMaintWeight.times(liquidationDiscount)) + ); debug("Underwater maint usd to adjust: $%d", underwaterMaintUsdValue.toFixed(6)); @@ -408,13 +501,15 @@ class MarginfiAccount { const liabilitiesAmountUi = liabilityBalance.computeQuantityUi(liabilityBank).liabilities; const liabUsdValue = liabilitiesAmountUi.times(liquidationDiscount).times(priceLiabHighest); - debug("Collateral amount: %d, price: %d, value: %d", + debug( + "Collateral amount: %d, price: %d, value: %d", assetsAmountUi.toFixed(6), priceAssetMarket.toFixed(6), assetsUsdValue.times(priceAssetMarket).toFixed(6) ); - debug("Liab amount: %d, price: %d, value: %d", + debug( + "Liab amount: %d, price: %d, value: %d", liabilitiesAmountUi.toFixed(6), priceLiabMarket.toFixed(6), liabUsdValue.toFixed(6) diff --git a/packages/marginfi-client-v2/src/models/account/wrapper.ts b/packages/marginfi-client-v2/src/models/account/wrapper.ts index 0251178524..5b346b7009 100644 --- a/packages/marginfi-client-v2/src/models/account/wrapper.ts +++ b/packages/marginfi-client-v2/src/models/account/wrapper.ts @@ -1,4 +1,4 @@ -import { Amount, DEFAULT_COMMITMENT, InstructionsWrapper, shortenAddress } from "@mrgnlabs/mrgn-common"; +import { Amount, DEFAULT_COMMITMENT, InstructionsWrapper, Wallet, shortenAddress } from "@mrgnlabs/mrgn-common"; import { Address, BorshCoder, translateAddress } from "@coral-xyz/anchor"; import { AccountMeta, Commitment, PublicKey, Transaction } from "@solana/web3.js"; import BigNumber from "bignumber.js"; @@ -9,6 +9,11 @@ import { MarginfiAccount, MarginRequirementType, MarginfiAccountRaw } from "./pu import { Bank } from "../bank"; import { Balance } from "../balance"; +export interface SimulationResult { + banks: Map; + marginfiAccount: MarginfiAccountWrapper; +} + class MarginfiAccountWrapper { public readonly address: PublicKey; @@ -112,14 +117,18 @@ class MarginfiAccountWrapper { return assets.lt(liabilities); } - public computeHealthComponents(marginRequirement: MarginRequirementType): { + public computeHealthComponents( + marginRequirement: MarginRequirementType, + excludedBanks: PublicKey[] = [] + ): { assets: BigNumber; liabilities: BigNumber; } { return this._marginfiAccount.computeHealthComponents( this.client.banks, this.client.oraclePrices, - marginRequirement + marginRequirement, + excludedBanks ); } @@ -138,8 +147,13 @@ class MarginfiAccountWrapper { ); } - public computeMaxBorrowForBank(bankAddress: PublicKey): BigNumber { - return this._marginfiAccount.computeMaxBorrowForBank(this.client.banks, this.client.oraclePrices, bankAddress); + public computeMaxBorrowForBank(bankAddress: PublicKey, opts?: { volatilityFactor?: number }): BigNumber { + return this._marginfiAccount.computeMaxBorrowForBank( + this.client.banks, + this.client.oraclePrices, + bankAddress, + opts + ); } public computeMaxWithdrawForBank(bankAddress: PublicKey, opts?: { volatilityFactor?: number }): BigNumber { @@ -160,10 +174,26 @@ class MarginfiAccountWrapper { ); } - public computeLiquidationPriceForBank( + public computeLiquidationPriceForBank(bankAddress: PublicKey): number | null { + return this._marginfiAccount.computeLiquidationPriceForBank( + this.client.banks, + this.client.oraclePrices, + bankAddress + ); + } + + public computeLiquidationPriceForBankAmount( bankAddress: PublicKey, + isLending: boolean, + amount: number ): number | null { - return this._marginfiAccount.computeLiquidationPriceForBank(this.client.banks, this.client.oraclePrices, bankAddress); + return this._marginfiAccount.computeLiquidationPriceForBankAmount( + this.client.banks, + this.client.oraclePrices, + bankAddress, + isLending, + amount + ); } public computeNetApy(): number { @@ -188,6 +218,33 @@ class MarginfiAccountWrapper { return sig; } + async simulateDeposit(amount: Amount, bankAddress: PublicKey): Promise { + const ixs = await this.makeDepositIx(amount, bankAddress); + const tx = new Transaction().add(...ixs.instructions); + const [mfiAccountData, bankData] = await this.client.simulateTransaction(tx, [this.address, bankAddress]); + if (!mfiAccountData || !bankData) throw new Error("Failed to simulate deposit"); + const previewBanks = this.client.banks; + previewBanks.set(bankAddress.toBase58(), Bank.fromBuffer(bankAddress, bankData)); + const previewClient = new MarginfiClient( + this._config, + this.client.program, + {} as Wallet, + true, + this.client.group, + this.client.banks, + this.client.oraclePrices + ); + const previewMarginfiAccount = MarginfiAccountWrapper.fromAccountDataRaw( + this.address, + previewClient, + mfiAccountData + ); + return { + banks: previewBanks, + marginfiAccount: previewMarginfiAccount, + }; + } + async makeRepayIx(amount: Amount, bankAddress: PublicKey, repayAll: boolean = false): Promise { return this._marginfiAccount.makeRepayIx(this._program, this.client.banks, amount, bankAddress, repayAll); } @@ -203,6 +260,33 @@ class MarginfiAccountWrapper { return sig; } + async simulateRepay(amount: Amount, bankAddress: PublicKey, repayAll: boolean = false): Promise { + const ixs = await this.makeRepayIx(amount, bankAddress, repayAll); + const tx = new Transaction().add(...ixs.instructions); + const [mfiAccountData, bankData] = await this.client.simulateTransaction(tx, [this.address, bankAddress]); + if (!mfiAccountData || !bankData) throw new Error("Failed to simulate repay"); + const previewBanks = this.client.banks; + previewBanks.set(bankAddress.toBase58(), Bank.fromBuffer(bankAddress, bankData)); + const previewClient = new MarginfiClient( + this._config, + this.client.program, + {} as Wallet, + true, + this.client.group, + this.client.banks, + this.client.oraclePrices + ); + const previewMarginfiAccount = MarginfiAccountWrapper.fromAccountDataRaw( + this.address, + previewClient, + mfiAccountData + ); + return { + banks: previewBanks, + marginfiAccount: previewMarginfiAccount, + }; + } + async makeWithdrawIx( amount: Amount, bankAddress: PublicKey, @@ -221,6 +305,37 @@ class MarginfiAccountWrapper { return sig; } + async simulateWithdraw( + amount: Amount, + bankAddress: PublicKey, + withdrawAll: boolean = false + ): Promise { + const ixs = await this.makeWithdrawIx(amount, bankAddress, withdrawAll); + const tx = new Transaction().add(...ixs.instructions); + const [mfiAccountData, bankData] = await this.client.simulateTransaction(tx, [this.address, bankAddress]); + if (!mfiAccountData || !bankData) throw new Error("Failed to simulate withdraw"); + const previewBanks = this.client.banks; + previewBanks.set(bankAddress.toBase58(), Bank.fromBuffer(bankAddress, bankData)); + const previewClient = new MarginfiClient( + this._config, + this.client.program, + {} as Wallet, + true, + this.client.group, + this.client.banks, + this.client.oraclePrices + ); + const previewMarginfiAccount = MarginfiAccountWrapper.fromAccountDataRaw( + this.address, + previewClient, + mfiAccountData + ); + return { + banks: previewBanks, + marginfiAccount: previewMarginfiAccount, + }; + } + async makeBorrowIx( amount: Amount, bankAddress: PublicKey, @@ -239,6 +354,33 @@ class MarginfiAccountWrapper { return sig; } + async simulateBorrow(amount: Amount, bankAddress: PublicKey): Promise { + const ixs = await this.makeBorrowIx(amount, bankAddress); + const tx = new Transaction().add(...ixs.instructions); + const [mfiAccountData, bankData] = await this.client.simulateTransaction(tx, [this.address, bankAddress]); + if (!mfiAccountData || !bankData) throw new Error("Failed to simulate borrow"); + const previewBanks = this.client.banks; + previewBanks.set(bankAddress.toBase58(), Bank.fromBuffer(bankAddress, bankData)); + const previewClient = new MarginfiClient( + this._config, + this.client.program, + {} as Wallet, + true, + this.client.group, + this.client.banks, + this.client.oraclePrices + ); + const previewMarginfiAccount = MarginfiAccountWrapper.fromAccountDataRaw( + this.address, + previewClient, + mfiAccountData + ); + return { + banks: previewBanks, + marginfiAccount: previewMarginfiAccount, + }; + } + async makeWithdrawEmissionsIx(bankAddress: PublicKey): Promise { return this._marginfiAccount.makeWithdrawEmissionsIx(this._program, this.client.banks, bankAddress); } diff --git a/packages/marginfi-client-v2/src/models/bank.ts b/packages/marginfi-client-v2/src/models/bank.ts index 966c2866f5..88189edb4e 100644 --- a/packages/marginfi-client-v2/src/models/bank.ts +++ b/packages/marginfi-client-v2/src/models/bank.ts @@ -64,6 +64,7 @@ interface BankConfigRaw { totalAssetValueInitLimit: BN; interestRateConfig: InterestRateConfigRaw; + operationalState: OperationalStateRaw; oracleSetup: OracleSetupRaw; oracleKeys: PublicKey[]; @@ -71,6 +72,8 @@ interface BankConfigRaw { type RiskTierRaw = { collateral: {} } | { isolated: {} }; +type OperationalStateRaw = number; + interface InterestRateConfigRaw { // Curve Params optimalUtilizationRate: WrappedI80F48; @@ -383,6 +386,29 @@ class Bank { } } + getEffectiveAssetWeight(marginRequirementType: MarginRequirementType, oraclePrice: OraclePrice): BigNumber { + switch (marginRequirementType) { + case MarginRequirementType.Initial: + const totalBankCollateralValue = this.computeAssetUsdValue( + oraclePrice, + this.totalAssetShares, + MarginRequirementType.Equity, + PriceBias.Lowest + ); + if (totalBankCollateralValue.isGreaterThan(this.config.totalAssetValueInitLimit)) { + return this.config.totalAssetValueInitLimit.div(totalBankCollateralValue).times(this.config.assetWeightInit); + } else { + return this.config.assetWeightInit; + } + case MarginRequirementType.Maintenance: + return this.config.assetWeightMaint; + case MarginRequirementType.Equity: + return new BigNumber(1); + default: + throw new Error("Invalid margin requirement type"); + } + } + getLiabilityWeight(marginRequirementType: MarginRequirementType): BigNumber { switch (marginRequirementType) { case MarginRequirementType.Initial: @@ -538,6 +564,7 @@ class BankConfig { public totalAssetValueInitLimit: BigNumber; public interestRateConfig: InterestRateConfig; + public operationalState: OperationalState; public oracleSetup: OracleSetup; public oracleKeys: PublicKey[]; @@ -553,7 +580,8 @@ class BankConfig { totalAssetValueInitLimit: BigNumber, oracleSetup: OracleSetup, oracleKeys: PublicKey[], - interestRateConfig: InterestRateConfig + interestRateConfig: InterestRateConfig, + operationalState: OperationalState ) { this.assetWeightInit = assetWeightInit; this.assetWeightMaint = assetWeightMaint; @@ -566,6 +594,7 @@ class BankConfig { this.oracleSetup = oracleSetup; this.oracleKeys = oracleKeys; this.interestRateConfig = interestRateConfig; + this.operationalState = operationalState; } static fromAccountParsed(bankConfigRaw: BankConfigRaw): BankConfig { @@ -576,6 +605,7 @@ class BankConfig { const depositLimit = BigNumber(bankConfigRaw.depositLimit.toString()); const borrowLimit = BigNumber(bankConfigRaw.borrowLimit.toString()); const riskTier = parseRiskTier(bankConfigRaw.riskTier); + const operationalState = parseOperationalState(bankConfigRaw.operationalState); const totalAssetValueInitLimit = BigNumber(bankConfigRaw.totalAssetValueInitLimit.toString()); const oracleSetup = parseOracleSetup(bankConfigRaw.oracleSetup); const oracleKeys = bankConfigRaw.oracleKeys; @@ -597,6 +627,7 @@ class BankConfig { depositLimit, borrowLimit, riskTier, + operationalState, totalAssetValueInitLimit, oracleSetup, oracleKeys, @@ -610,6 +641,12 @@ enum RiskTier { Isolated = "Isolated", } +enum OperationalState { + Paused = "Paused", + Operational = "Operational", + ReduceOnly = "ReduceOnly", +} + interface InterestRateConfig { // Curve Params optimalUtilizationRate: BigNumber; @@ -640,6 +677,19 @@ function parseRiskTier(riskTierRaw: RiskTierRaw): RiskTier { } } +function parseOperationalState(operationalStateRaw: OperationalStateRaw): OperationalState { + switch (Object.keys(operationalStateRaw)[0].toLowerCase()) { + case "paused": + return OperationalState.Paused; + case "operational": + return OperationalState.Operational; + case "reduceonly": + return OperationalState.ReduceOnly; + default: + throw new Error(`Invalid operational state "${operationalStateRaw}"`); + } +} + function parseOracleSetup(oracleSetupRaw: OracleSetupRaw): OracleSetup { switch (oracleSetupRaw) { case 0: @@ -654,7 +704,7 @@ function parseOracleSetup(oracleSetupRaw: OracleSetupRaw): OracleSetup { } export type { InterestRateConfig }; -export { Bank, BankConfig, RiskTier, OracleSetup, parseRiskTier, parseOracleSetup }; +export { Bank, BankConfig, RiskTier, OperationalState, OracleSetup, parseRiskTier, parseOracleSetup }; // ---------------------------------------------------------------------------- // Attributes diff --git a/packages/marginfi-v2-ui-state/src/lib/mrgnlend.ts b/packages/marginfi-v2-ui-state/src/lib/mrgnlend.ts index 825b6fe94d..b4c3fc7081 100644 --- a/packages/marginfi-v2-ui-state/src/lib/mrgnlend.ts +++ b/packages/marginfi-v2-ui-state/src/lib/mrgnlend.ts @@ -117,7 +117,9 @@ function makeBankInfo(bank: Bank, oraclePrice: OraclePrice, emissionTokenData?: emissionsRate, emissions, totalDeposits, + depositCap: nativeToUi(bank.config.depositLimit, bank.mintDecimals), totalBorrows, + borrowCap: nativeToUi(bank.config.borrowLimit, bank.mintDecimals), availableLiquidity: liquidity, utilizationRate, isIsolated: bank.config.riskTier === RiskTier.Isolated, @@ -317,7 +319,9 @@ function makeExtendedBankInfo( let maxBorrow = 0; if (userData.marginfiAccount) { - const borrowPower = userData.marginfiAccount.computeMaxBorrowForBank(bank.address).toNumber() * VOLATILITY_FACTOR; + const borrowPower = userData.marginfiAccount + .computeMaxBorrowForBank(bank.address, { volatilityFactor: VOLATILITY_FACTOR }) + .toNumber(); maxBorrow = floor( Math.max(0, Math.min(borrowPower, borrowCapacity, bankInfo.availableLiquidity)), bankInfo.mintDecimals @@ -396,8 +400,8 @@ function makeLendingPosition( const isLending = usdValues.liabilities.isZero(); const amount = isLending - ? nativeToUi(amounts.assets.toNumber(), bankInfo.mintDecimals) - : nativeToUi(amounts.liabilities.toNumber(), bankInfo.mintDecimals); + ? nativeToUi(amounts.assets.integerValue(BigNumber.ROUND_DOWN).toNumber(), bankInfo.mintDecimals) + : nativeToUi(amounts.liabilities.integerValue(BigNumber.ROUND_UP).toNumber(), bankInfo.mintDecimals); const isDust = uiToNative(amount, bankInfo.mintDecimals).isZero(); const weightedUSDValue = isLending ? weightedUSDValues.assets.toNumber() : weightedUSDValues.liabilities.toNumber(); const usdValue = isLending ? usdValues.assets.toNumber() : usdValues.liabilities.toNumber(); @@ -558,7 +562,9 @@ interface BankState { emissionsRate: number; emissions: Emissions; totalDeposits: number; + depositCap: number; totalBorrows: number; + borrowCap: number; availableLiquidity: number; utilizationRate: number; isIsolated: boolean; diff --git a/packages/marginfi-v2-ui-state/src/store/mrgnlendStore.ts b/packages/marginfi-v2-ui-state/src/store/mrgnlendStore.ts index d919bbc219..eaf82fe036 100644 --- a/packages/marginfi-v2-ui-state/src/store/mrgnlendStore.ts +++ b/packages/marginfi-v2-ui-state/src/store/mrgnlendStore.ts @@ -65,12 +65,11 @@ function createMrgnlendStore() { } function createPersistentMrgnlendStore() { - return create]]>( + return create]]>( persist(stateCreator, { name: "mrgnlend-peristent-store", partialize(state) { return { - extendedBankInfos: state.extendedBankInfos, protocolStats: state.protocolStats, }; }, diff --git a/yarn.lock b/yarn.lock index d7d45c7a8a..d7fbe0b957 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1530,6 +1530,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.23.2": + version "7.23.5" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.5.tgz#11edb98f8aeec529b82b211028177679144242db" + integrity sha512-NdUTHcPe4C99WxPub+K9l9tK5/lV4UXIoaHSYgzco9BCyjKAAwzdBI+wWtYqHt7LJdbo74ZjRPJgzVweq1sz0w== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.0.0", "@babel/template@^7.22.5", "@babel/template@^7.3.3": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.5.tgz#0c8c4d944509875849bd0344ff0050756eefc6ec" @@ -2029,18 +2036,18 @@ superstruct "^0.15.4" toml "^3.0.0" -"@coral-xyz/borsh@^0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@coral-xyz/borsh/-/borsh-0.26.0.tgz#d054f64536d824634969e74138f9f7c52bbbc0d5" - integrity sha512-uCZ0xus0CszQPHYfWAqKS5swS1UxvePu83oOF+TWpUkedsNlg6p2p4azxZNSSqwXb9uXMFgxhuMBX9r3Xoi0vQ== +"@coral-xyz/borsh@0.28.0", "@coral-xyz/borsh@^0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@coral-xyz/borsh/-/borsh-0.28.0.tgz#fa368a2f2475bbf6f828f4657f40a52102e02b6d" + integrity sha512-/u1VTzw7XooK7rqeD7JLUSwOyRSesPUk0U37BV9zK0axJc1q0nRbKFGFLYCQ16OtdOJTTwGfGp11Lx9B45bRCQ== dependencies: bn.js "^5.1.2" buffer-layout "^1.2.0" -"@coral-xyz/borsh@^0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@coral-xyz/borsh/-/borsh-0.28.0.tgz#fa368a2f2475bbf6f828f4657f40a52102e02b6d" - integrity sha512-/u1VTzw7XooK7rqeD7JLUSwOyRSesPUk0U37BV9zK0axJc1q0nRbKFGFLYCQ16OtdOJTTwGfGp11Lx9B45bRCQ== +"@coral-xyz/borsh@^0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@coral-xyz/borsh/-/borsh-0.26.0.tgz#d054f64536d824634969e74138f9f7c52bbbc0d5" + integrity sha512-uCZ0xus0CszQPHYfWAqKS5swS1UxvePu83oOF+TWpUkedsNlg6p2p4azxZNSSqwXb9uXMFgxhuMBX9r3Xoi0vQ== dependencies: bn.js "^5.1.2" buffer-layout "^1.2.0" @@ -2420,6 +2427,18 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz#8cfaf2ff603e9aabb910e9c0558c26cf32744061" integrity sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA== +"@eslint-community/eslint-utils@^4.2.0": + version "4.4.0" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" + integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA== + dependencies: + eslint-visitor-keys "^3.3.0" + +"@eslint-community/regexpp@^4.6.1": + version "4.10.0" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.0.tgz#548f6de556857c8bb73bbee70c35dc82a2e74d63" + integrity sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA== + "@eslint/eslintrc@^0.4.3": version "0.4.3" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.3.tgz#9e42981ef035beb3dd49add17acb96e8ff6f394c" @@ -2450,6 +2469,26 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@eslint/eslintrc@^2.1.4": + version "2.1.4" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz#388a269f0f25c1b6adc317b5a2c55714894c70ad" + integrity sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^9.6.0" + globals "^13.19.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.0" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + +"@eslint/js@8.55.0": + version "8.55.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.55.0.tgz#b721d52060f369aa259cf97392403cb9ce892ec6" + integrity sha512-qQfo2mxH5yVom1kacMtZZJFVdW+E70mqHMJvVg6WTLo+VBuQJ4TojZlfWBjK0ve5BdEeNAVxOsl/nvNMpJOaJA== + "@ethereumjs/common@^3.2.0": version "3.2.0" resolved "https://registry.yarnpkg.com/@ethereumjs/common/-/common-3.2.0.tgz#b71df25845caf5456449163012074a55f048e0a0" @@ -3849,6 +3888,15 @@ dependencies: "@hapi/hoek" "^9.0.0" +"@humanwhocodes/config-array@^0.11.13": + version "0.11.13" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.13.tgz#075dc9684f40a531d9b26b0822153c1e832ee297" + integrity sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ== + dependencies: + "@humanwhocodes/object-schema" "^2.0.1" + debug "^4.1.1" + minimatch "^3.0.5" + "@humanwhocodes/config-array@^0.11.8": version "0.11.11" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.11.tgz#88a04c570dbbc7dd943e4712429c3df09bc32844" @@ -3877,6 +3925,11 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== +"@humanwhocodes/object-schema@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz#e5211452df060fa8522b55c7b3c0c4d1981cb044" + integrity sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw== + "@ioredis/commands@^1.1.1": version "1.2.0" resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11" @@ -5476,7 +5529,7 @@ dependencies: "@noble/hashes" "1.3.1" -"@noble/curves@^1.0.0", "@noble/curves@^1.1.0": +"@noble/curves@^1.0.0", "@noble/curves@^1.1.0", "@noble/curves@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.2.0.tgz#92d7e12e4e49b23105a2555c6984d41733d65c35" integrity sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw== @@ -5996,6 +6049,22 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-accordion@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-accordion/-/react-accordion-1.1.2.tgz#738441f7343e5142273cdef94d12054c3287966f" + integrity sha512-fDG7jcoNKVjSK6yfmuAs0EnPDro0WMXIhMtXdTBWqEioVW206ku+4Lw07e+13lUkFkpoEQ2PdeMIAGpdqEAmDg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-collapsible" "1.0.3" + "@radix-ui/react-collection" "1.0.3" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-direction" "1.0.1" + "@radix-ui/react-id" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-arrow@1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz#c24f7968996ed934d57fe6cde5d6ec7266e1d25d" @@ -6004,7 +6073,7 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-primitive" "1.0.3" -"@radix-ui/react-collapsible@^1.0.3": +"@radix-ui/react-collapsible@1.0.3", "@radix-ui/react-collapsible@^1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@radix-ui/react-collapsible/-/react-collapsible-1.0.3.tgz#df0e22e7a025439f13f62d4e4a9e92c4a0df5b81" integrity sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg== @@ -8077,6 +8146,27 @@ rpc-websockets "^7.5.1" superstruct "^0.14.2" +"@solana/web3.js@^1.87.0": + version "1.87.6" + resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.87.6.tgz#6744cfc5f4fc81e0f58241c0a92648a7320bb3bf" + integrity sha512-LkqsEBgTZztFiccZZXnawWa8qNCATEqE97/d0vIwjTclmVlc8pBpD1DmjfVHtZ1HS5fZorFlVhXfpwnCNDZfyg== + dependencies: + "@babel/runtime" "^7.23.2" + "@noble/curves" "^1.2.0" + "@noble/hashes" "^1.3.1" + "@solana/buffer-layout" "^4.0.0" + agentkeepalive "^4.3.0" + bigint-buffer "^1.1.5" + bn.js "^5.2.1" + borsh "^0.7.0" + bs58 "^4.0.1" + buffer "6.0.3" + fast-stable-stringify "^1.0.0" + jayson "^4.1.0" + node-fetch "^2.6.12" + rpc-websockets "^7.5.1" + superstruct "^0.14.2" + "@solana/web3.js@~1.72.0": version "1.72.0" resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.72.0.tgz#8d54de6887bc885c78a4a2bebe891c349fbb029e" @@ -9053,6 +9143,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-18.17.12.tgz#c6bd7413a13e6ad9cfb7e97dd5c4e904c1821e50" integrity sha512-d6xjC9fJ/nSnfDeU0AMDsaJyb1iHsqCSOdi84w4u+SlN/UgQdY5tRhpMzaFYsI4mnpvgTivEaQd0yOUhAtOnEQ== +"@types/node@^20.8.3": + version "20.10.4" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.10.4.tgz#b246fd84d55d5b1b71bf51f964bd514409347198" + integrity sha512-D08YG6rr8X90YB56tSIuBaddy/UXAA9RKJoFvrsnogAum/0pmjkgi4+2nx96A330FmioegBWmEYQ+syqCFaveg== + dependencies: + undici-types "~5.26.4" + "@types/normalize-package-data@^2.4.0": version "2.4.1" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" @@ -9366,6 +9463,11 @@ tiny-invariant "^1.2.0" tslib "^2.4.0" +"@ungap/structured-clone@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" + integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== + "@upstash/core-analytics@^0.0.6": version "0.0.6" resolved "https://registry.yarnpkg.com/@upstash/core-analytics/-/core-analytics-0.0.6.tgz#8680e7697e0f2660c2507bbc56348f5541bb0237" @@ -13843,7 +13945,7 @@ eslint-scope@5.1.1, eslint-scope@^5.1.1: esrecurse "^4.3.0" estraverse "^4.1.1" -eslint-scope@^7.1.1: +eslint-scope@^7.1.1, eslint-scope@^7.2.2: version "7.2.2" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.2.2.tgz#deb4f92563390f32006894af62a22dba1c46423f" integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg== @@ -13875,7 +13977,7 @@ eslint-visitor-keys@^2.0.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303" integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw== -eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1: +eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: version "3.4.3" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== @@ -13971,6 +14073,50 @@ eslint@^7.23.0, eslint@^7.32.0: text-table "^0.2.0" v8-compile-cache "^2.0.3" +eslint@^8.51.0: + version "8.55.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.55.0.tgz#078cb7b847d66f2c254ea1794fa395bf8e7e03f8" + integrity sha512-iyUUAM0PCKj5QpwGfmCAG9XXbZCWsqP/eWAWrG/W0umvjuLRBECwSFdt+rCntju0xEH7teIABPwXpahftIaTdA== + dependencies: + "@eslint-community/eslint-utils" "^4.2.0" + "@eslint-community/regexpp" "^4.6.1" + "@eslint/eslintrc" "^2.1.4" + "@eslint/js" "8.55.0" + "@humanwhocodes/config-array" "^0.11.13" + "@humanwhocodes/module-importer" "^1.0.1" + "@nodelib/fs.walk" "^1.2.8" + "@ungap/structured-clone" "^1.2.0" + ajv "^6.12.4" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.3.2" + doctrine "^3.0.0" + escape-string-regexp "^4.0.0" + eslint-scope "^7.2.2" + eslint-visitor-keys "^3.4.3" + espree "^9.6.1" + esquery "^1.4.2" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^6.0.1" + find-up "^5.0.0" + glob-parent "^6.0.2" + globals "^13.19.0" + graphemer "^1.4.0" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + is-path-inside "^3.0.3" + js-yaml "^4.1.0" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.4.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.3" + strip-ansi "^6.0.1" + text-table "^0.2.0" + espree@^7.3.0, espree@^7.3.1: version "7.3.1" resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.1.tgz#f2df330b752c6f55019f8bd89b7660039c1bbbb6" @@ -13980,7 +14126,7 @@ espree@^7.3.0, espree@^7.3.1: acorn-jsx "^5.3.1" eslint-visitor-keys "^1.3.0" -espree@^9.0.0, espree@^9.4.0: +espree@^9.0.0, espree@^9.4.0, espree@^9.6.0, espree@^9.6.1: version "9.6.1" resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ== @@ -13994,7 +14140,7 @@ esprima@^4.0.0, esprima@^4.0.1, esprima@~4.0.0: resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== -esquery@^1.4.0: +esquery@^1.4.0, esquery@^1.4.2: version "1.5.0" resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b" integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg== @@ -15403,6 +15549,11 @@ grapheme-splitter@^1.0.4: resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== +graphemer@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" + integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== + graphql-tag@^2.10.1: version "2.12.6" resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.6.tgz#d441a569c1d2537ef10ca3d1633b48725329b5f1" @@ -19619,7 +19770,7 @@ optionator@^0.8.1: type-check "~0.3.2" word-wrap "~1.2.3" -optionator@^0.9.1: +optionator@^0.9.1, optionator@^0.9.3: version "0.9.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.3.tgz#007397d44ed1872fdc6ed31360190f81814e2c64" integrity sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg== @@ -23791,6 +23942,11 @@ typescript@^5.1.6: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78" integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w== +typescript@^5.2.2: + version "5.3.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37" + integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw== + u3@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/u3/-/u3-0.1.1.tgz#5f52044f42ee76cd8de33148829e14528494b73b" @@ -23849,6 +24005,11 @@ underscore@~1.13.2: resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.6.tgz#04786a1f589dc6c09f761fc5f45b89e935136441" integrity sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A== +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc"