From a7185877a5b68e8aa8a5cc61728b8e2babfe2e6e Mon Sep 17 00:00:00 2001 From: Kobe Leenders Date: Tue, 20 Feb 2024 19:47:48 +0100 Subject: [PATCH] feat: slippage feature & lst improvements --- apps/marginfi-v2-ui/package.json | 2 + .../components/common/ActionBox/ActionBox.tsx | 53 +++-- .../common/ActionBox/ActionBoxSlippage.tsx | 190 ++++++++++-------- .../marginfi-v2-ui/src/components/ui/form.tsx | 129 ++++++++++++ .../src/components/ui/radio-group.tsx | 38 ++++ 5 files changed, 309 insertions(+), 103 deletions(-) create mode 100644 apps/marginfi-v2-ui/src/components/ui/form.tsx create mode 100644 apps/marginfi-v2-ui/src/components/ui/radio-group.tsx diff --git a/apps/marginfi-v2-ui/package.json b/apps/marginfi-v2-ui/package.json index b51e49fd08..0814e7ded5 100644 --- a/apps/marginfi-v2-ui/package.json +++ b/apps/marginfi-v2-ui/package.json @@ -34,6 +34,7 @@ "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", @@ -78,6 +79,7 @@ "react-cookie": "^6.1.1", "react-copy-to-clipboard": "^5.1.0", "react-dom": "18.2.0", + "react-hook-form": "^7.50.1", "react-hotkeys-hook": "^4.4.1", "react-katex": "^3.0.1", "react-number-format": "^5.2.2", diff --git a/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBox.tsx b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBox.tsx index fd5e60267f..e7e0fe5a17 100644 --- a/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBox.tsx +++ b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBox.tsx @@ -1,9 +1,9 @@ import React from "react"; import { AddressLookupTableAccount, Connection, PublicKey, TransactionInstruction } from "@solana/web3.js"; -import { createJupiterApiClient, QuoteResponse } from "@jup-ag/api"; +import { createJupiterApiClient, QuoteGetRequest, QuoteResponse } from "@jup-ag/api"; -import { WSOL_MINT, nativeToUi, uiToNative } from "@mrgnlabs/mrgn-common"; +import { WSOL_MINT, nativeToUi, percentFormatter, uiToNative } from "@mrgnlabs/mrgn-common"; import { ActionType, ActiveBankInfo, ExtendedBankInfo } from "@mrgnlabs/marginfi-v2-ui-state"; import { useLstStore, useMrgnlendStore, useUiStore } from "~/store"; @@ -31,6 +31,7 @@ import { IconAlertTriangle, IconWallet, IconSettings } from "~/components/ui/ico import { ActionBoxPreview } from "./ActionBoxPreview"; import { ActionBoxTokens } from "./ActionBoxTokens"; import { ActionBoxHeader } from "./ActionBoxHeader"; +import { ActionBoxSlippage } from "./ActionBoxSlippage"; type ActionBoxProps = { requestedAction?: ActionType; @@ -94,6 +95,8 @@ export const ActionBox = ({ [lendingModeFromStore, requestedLendingMode] ); + const [slippageBps, setSlippageBps] = React.useState(100); + const [amountRaw, setAmountRaw] = React.useState(""); const [maxAmountCollat, setMaxAmountCollat] = React.useState(); @@ -102,6 +105,7 @@ export const ActionBox = ({ const [selectedTokenBank, setSelectedTokenBank] = React.useState(null); const [selectedRepayTokenBank, setSelectedRepayTokenBank] = React.useState(null); const [isPriorityFeesMode, setIsPriorityFeesMode] = React.useState(false); + const [isSlippageMode, setIsSlippageMode] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false); const [isLSTDialogOpen, setIsLSTDialogOpen] = React.useState(false); const [lstDialogVariant, setLSTDialogVariant] = React.useState(null); @@ -331,7 +335,7 @@ export const ActionBox = ({ amount: uiToNative(repayBank.userInfo.maxWithdraw, repayBank.info.state.mintDecimals).toNumber(), inputMint: repayBank.info.state.mint.toBase58(), outputMint: bank.info.state.mint.toBase58(), - slippageBps: 100, + slippageBps: slippageBps, swapMode: "ExactIn" as any, }; @@ -346,10 +350,9 @@ export const ActionBox = ({ amount: uiToNative(amount, bank.info.state.mintDecimals).toNumber(), inputMint: repayBank.info.state.mint.toBase58(), outputMint: bank.info.state.mint.toBase58(), - slippageBps: 100, + slippageBps: slippageBps, swapMode: "ExactOut" as any, - // maxAccounts: 20, - }; + } as QuoteGetRequest; const swapQuote = await jupiterQuoteApi.quoteGet(quoteParams); const withdrawAmount = nativeToUi(swapQuote.otherAmountThreshold, repayBank.info.state.mintDecimals); @@ -643,15 +646,29 @@ export const ActionBox = ({ isDialog && "border border-background-gray-light/50" )} > - setRepayMode(repayType)} - /> - {isPriorityFeesMode ? ( - + {isSlippageMode || isPriorityFeesMode ? ( + <> + {isSlippageMode && ( + { + setSlippageBps(value * 100); + setIsSlippageMode(false); + }} + slippageBps={slippageBps / 100} + /> + )} + {isPriorityFeesMode && ( + + )} + ) : ( <> + setRepayMode(repayType)} + />
{!isDialog || actionMode === ActionType.MintLST || actionMode === ActionType.Repay ? (
{titleText}
@@ -776,13 +793,21 @@ export const ActionBox = ({ actionMode={actionMode} /> -
+
+ {(actionMode === ActionType.MintLST || repayMode === RepayType.RepayCollat) && ( + + )}
diff --git a/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxSlippage.tsx b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxSlippage.tsx index 8d936f39df..bd5e446019 100644 --- a/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxSlippage.tsx +++ b/apps/marginfi-v2-ui/src/components/common/ActionBox/ActionBoxSlippage.tsx @@ -1,44 +1,56 @@ import React from "react"; import { ActionType } from "@mrgnlabs/marginfi-v2-ui-state"; +import { useForm } from "react-hook-form"; -import { useUiStore } from "~/store"; import { cn } from "~/utils"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; -import { IconInfoCircle, IconArrowLeft } from "~/components/ui/icons"; +import { IconArrowLeft } from "~/components/ui/icons"; +import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group"; +import { Label } from "~/components/ui/label"; +import { Form, FormControl, FormField, FormItem, FormMessage } from "~/components/ui/form"; type ActionBoxSlippageProps = { mode: ActionType; - setIsPriorityFeesMode: (value: boolean) => void; + slippageBps: number; + setSlippageBps: (value: number) => void; }; const DEFAULT_SLIPPAGE_BPS = 100; -const priorityFeeOptions = [ +const slippageOptions = [ { label: "Low", - value: 100, + value: 0.5, }, { label: "Normal", - value: 500, + value: 1, }, { label: "High", - value: 1000, + value: 5, }, ]; -export const ActionBoxSlippage = ({ mode, setIsPriorityFeesMode }: ActionBoxSlippageProps) => { - const [priorityFee, setPriorityFee] = useUiStore((state) => [state.priorityFee, state.setPriorityFee]); - const [selectedSlippage, setSelectedSlippage] = React.useState(DEFAULT_SLIPPAGE_BPS); +interface SlippageForm { + slippageBps: number; +} - const slippageRef = React.useRef(null); - const [isCustomPriorityFeeMode, setIsCustomPriorityFeeMode] = React.useState(false); - const [customSlippage, setCustomSlippage] = React.useState(null); +export const ActionBoxSlippage = ({ mode, slippageBps, setSlippageBps }: ActionBoxSlippageProps) => { + const form = useForm({ + defaultValues: { + slippageBps: slippageBps, + }, + }); + const formWatch = form.watch(); + + const isCustomSlippage = React.useMemo( + () => (slippageOptions.find((value) => value.value === formWatch.slippageBps) ? false : true), + [formWatch.slippageBps] + ); const modeLabel = React.useMemo(() => { let label = ""; @@ -52,83 +64,83 @@ export const ActionBoxSlippage = ({ mode, setIsPriorityFeesMode }: ActionBoxSlip return label; }, [mode]); + function onSubmit(data: SlippageForm) { + setSlippageBps(data.slippageBps); + } + return ( -
- -

- Set transaction priority{" "} - - - - - - -
-

Priority fees are paid to the Solana network.

-

This additional fee helps boost how a transaction is prioritized.

-
-
-
-
-

-
    - {priorityFeeOptions.map((option) => ( -
  • - -
  • - ))} -
-

or set manually

-
- +
+ +

Set transaction slippage

+ ( + + + field.onChange(Number(value))} + defaultValue={field.value.toString()} + className="flex justify-between" + > + {slippageOptions.map((option) => ( +
+ + +
+ ))} +
+
+ +
)} - value={customSlippage && !isCustomPriorityFeeMode ? customSlippage?.toString() : undefined} - min={0} - placeholder={ - selectedSlippage !== 100 && selectedSlippage !== 500 && selectedSlippage !== 1000 - ? priorityFee.toString() - : "0" - } - onFocus={() => setIsCustomPriorityFeeMode(true)} - onChange={() => setCustomSlippage(parseFloat(slippageRef.current?.value || "0"))} /> - SOL -
- -
+

or set manually

+ + ( + + +
+ field.onChange(e)} + className={cn( + "h-auto bg-background/50 py-3 px-4 border border-transparent text-white transition-colors focus-visible:ring-0", + isCustomSlippage && "border-chartreuse" + )} + /> + % +
+
+ +
+ )} + /> + + + + ); }; diff --git a/apps/marginfi-v2-ui/src/components/ui/form.tsx b/apps/marginfi-v2-ui/src/components/ui/form.tsx new file mode 100644 index 0000000000..1d7f50f8e6 --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/ui/form.tsx @@ -0,0 +1,129 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from "react-hook-form"; + +import { cn } from "~/utils/themeUtils"; +import { Label } from "~/components/ui/label"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName; +}; + +const FormFieldContext = React.createContext({} as FormFieldContextValue); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext({} as FormItemContextValue); + +const FormItem = React.forwardRef>( + ({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); + } +); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return