- {percentFormatter.format(aprToApy(bank.info.state.lendingRate))}
+ {percentFormatter.format(aprToApy(pool.tokenBank.info.state.lendingRate))}
- {percentFormatter.format(aprToApy(bank.info.state.borrowingRate))}
+ {percentFormatter.format(aprToApy(pool.tokenBank.info.state.borrowingRate))}
@@ -159,8 +161,8 @@ const YieldItem = ({
{isProvidingLiquidity && bank.isActive && (
<>
- {numeralFormatter(bank.position.amount)}
- {bank.meta.tokenSymbol}
+ {numeralFormatter(pool.tokenBank.position.amount)}
+ {pool.tokenBank.meta.tokenSymbol}
>
)}
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/action-button/action-button.tsx b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/action-button/action-button.tsx
new file mode 100644
index 0000000000..ac442124c1
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/action-button/action-button.tsx
@@ -0,0 +1,38 @@
+import { IconLoader2 } from "@tabler/icons-react";
+import React from "react";
+
+import { WalletButton } from "~/components/wallet-v2";
+import { Button } from "~/components/ui/button";
+import { cn } from "@mrgnlabs/mrgn-utils";
+
+type ActionButtonProps = {
+ isLoading: boolean;
+ isEnabled: boolean;
+ buttonLabel: string;
+ connected?: boolean;
+ handleAction: () => void;
+ tradeState: "long" | "short";
+};
+
+export const ActionButton = ({
+ isLoading,
+ isEnabled,
+ buttonLabel,
+ connected = false,
+ handleAction,
+ tradeState,
+}: ActionButtonProps) => {
+ if (!connected) {
+ return
;
+ }
+
+ return (
+
+ {isLoading ? : buttonLabel}
+
+ );
+};
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/action-button/index.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/action-button/index.ts
new file mode 100644
index 0000000000..526e8cc592
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/action-button/index.ts
@@ -0,0 +1 @@
+export * from "./action-button";
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/action-simulation-status/action-simuation-status.tsx b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/action-simulation-status/action-simuation-status.tsx
new file mode 100644
index 0000000000..0be11f5601
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/action-simulation-status/action-simuation-status.tsx
@@ -0,0 +1,76 @@
+import React from "react";
+
+import { IconCheck, IconX } from "@tabler/icons-react";
+
+import { IconLoader } from "~/components/ui/icons";
+import { SimulationStatus } from "~/components/common/trade-box-v2/utils";
+
+type ActionSimulationStatusProps = {
+ simulationStatus: SimulationStatus;
+ hasErrorMessages: boolean;
+ isActive: boolean;
+};
+
+enum SimulationCompleteStatus {
+ NULL = "NULL",
+ LOADING = "LOADING",
+ SUCCESS = "SUCCESS",
+ ERROR = "ERROR",
+}
+
+const ActionSimulationStatus = ({
+ simulationStatus,
+ hasErrorMessages = false,
+ isActive = false,
+}: ActionSimulationStatusProps) => {
+ const [simulationCompleteStatus, setSimulationCompleteStatus] = React.useState
(
+ SimulationCompleteStatus.NULL
+ );
+ const [isNewSimulation, setIsNewSimulation] = React.useState(false);
+
+ React.useEffect(() => {
+ if (simulationStatus === SimulationStatus.SIMULATING || simulationStatus === SimulationStatus.PREPARING) {
+ setSimulationCompleteStatus(SimulationCompleteStatus.LOADING);
+ setIsNewSimulation(false);
+ } else if (hasErrorMessages && !isNewSimulation) {
+ setSimulationCompleteStatus(SimulationCompleteStatus.ERROR);
+ } else if (simulationStatus === SimulationStatus.COMPLETE && !isNewSimulation) {
+ setSimulationCompleteStatus(SimulationCompleteStatus.SUCCESS);
+ }
+ }, [simulationStatus, hasErrorMessages, isNewSimulation]);
+
+ React.useEffect(() => {
+ if (!isActive) {
+ setIsNewSimulation(true);
+ setSimulationCompleteStatus(SimulationCompleteStatus.NULL);
+ }
+ }, [isActive]);
+
+ if (!isActive) {
+ return
; // Return empty div to align the settings button
+ }
+
+ return (
+
+ {simulationCompleteStatus === SimulationCompleteStatus.LOADING && (
+
+ Simulating transaction...
+
+ )}
+
+ {simulationCompleteStatus === SimulationCompleteStatus.SUCCESS && (
+
+ Simulation complete!
+
+ )}
+
+ {simulationCompleteStatus === SimulationCompleteStatus.ERROR && (
+
+ Simulation failed
+
+ )}
+
+ );
+};
+
+export { ActionSimulationStatus };
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/action-simulation-status/index.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/action-simulation-status/index.ts
new file mode 100644
index 0000000000..67194a0ea3
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/action-simulation-status/index.ts
@@ -0,0 +1 @@
+export * from "./action-simuation-status";
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/action-toggle/action-toggle.tsx b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/action-toggle/action-toggle.tsx
new file mode 100644
index 0000000000..e4295bee84
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/action-toggle/action-toggle.tsx
@@ -0,0 +1,27 @@
+import { TradeSide } from "~/components/common/trade-box-v2/utils";
+import { ToggleGroup, ToggleGroupItem } from "~/components/ui/toggle-group";
+
+interface ActionToggleProps {
+ tradeState: TradeSide;
+ setTradeState: (value: TradeSide) => void;
+}
+
+export const ActionToggle = ({ tradeState, setTradeState }: ActionToggleProps) => {
+ return (
+ {
+ value && setTradeState(value as TradeSide);
+ }}
+ >
+
+ Long
+
+
+ Short
+
+
+ );
+};
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/action-toggle/index.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/action-toggle/index.ts
new file mode 100644
index 0000000000..1f3ddea600
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/action-toggle/index.ts
@@ -0,0 +1 @@
+export * from "./action-toggle";
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-input/amount-input.tsx b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-input/amount-input.tsx
new file mode 100644
index 0000000000..55f145a583
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-input/amount-input.tsx
@@ -0,0 +1,54 @@
+import React from "react";
+import Image from "next/image";
+import { Input } from "~/components/ui/input";
+import { MaxAction } from "./components";
+import { ArenaBank } from "~/store/tradeStoreV2";
+
+interface AmountInputProps {
+ maxAmount: number;
+ amount: string;
+ collateralBank: ArenaBank | null;
+
+ handleAmountChange: (value: string) => void;
+}
+
+export const AmountInput = ({
+ amount,
+ collateralBank,
+ maxAmount,
+
+ handleAmountChange,
+}: AmountInputProps) => {
+ const amountInputRef = React.useRef(null);
+
+ return (
+
+
+
+ {collateralBank?.meta.tokenLogoUri && (
+
+ )}
+ {collateralBank?.meta.tokenSymbol.toUpperCase()}
+
+
+ handleAmountChange(e.target.value)}
+ placeholder="0"
+ className="bg-transparent shadow-none min-w-[130px] h-auto py-0 pr-0 text-right outline-none focus-visible:outline-none focus-visible:ring-0 border-none text-base font-medium"
+ />
+
+
+
+
+ );
+};
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-input/components/index.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-input/components/index.ts
new file mode 100644
index 0000000000..3e00254660
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-input/components/index.ts
@@ -0,0 +1,2 @@
+export * from "./token-select";
+export * from "./max-action";
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-input/components/max-action/index.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-input/components/max-action/index.ts
new file mode 100644
index 0000000000..a0db7a9b66
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-input/components/max-action/index.ts
@@ -0,0 +1 @@
+export * from "./max-action";
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-input/components/max-action/max-action.tsx b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-input/components/max-action/max-action.tsx
new file mode 100644
index 0000000000..52b5ec35f1
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-input/components/max-action/max-action.tsx
@@ -0,0 +1,57 @@
+import { dynamicNumeralFormatter } from "@mrgnlabs/mrgn-common";
+import React from "react";
+import { ArenaBank } from "~/store/tradeStoreV2";
+
+interface TradeActionProps {
+ maxAmount: number;
+ collateralBank: ArenaBank | null;
+
+ setAmount: (amount: string) => void;
+}
+
+export const MaxAction = ({ maxAmount, collateralBank, setAmount }: TradeActionProps) => {
+ const maxLabel = React.useMemo((): {
+ amount: string;
+ showWalletIcon?: boolean;
+ label?: string;
+ } => {
+ if (!collateralBank) {
+ return {
+ amount: "-",
+ showWalletIcon: false,
+ };
+ }
+
+ const formatAmount = (maxAmount?: number, symbol?: string) =>
+ maxAmount !== undefined ? `${dynamicNumeralFormatter(maxAmount)} ${symbol?.toUpperCase()}` : "-";
+
+ return {
+ amount: formatAmount(maxAmount, collateralBank.meta.tokenSymbol),
+ label: "Wallet: ",
+ };
+ }, [collateralBank, maxAmount]);
+ return (
+ <>
+ {collateralBank && (
+
+
+ {maxLabel.label}
+
+
{maxLabel.amount}
+
+
{
+ setAmount(maxAmount.toString());
+ }}
+ >
+ MAX
+
+
+
+
+ )}
+ >
+ );
+};
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-input/components/token-select/index.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-input/components/token-select/index.ts
new file mode 100644
index 0000000000..c540cf96d2
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-input/components/token-select/index.ts
@@ -0,0 +1 @@
+export * from "./token-select";
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-input/components/token-select/token-select.tsx b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-input/components/token-select/token-select.tsx
new file mode 100644
index 0000000000..e626aad3b9
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-input/components/token-select/token-select.tsx
@@ -0,0 +1,3 @@
+export const TokenSelect = () => {
+ return TokenSelect
;
+};
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-input/index.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-input/index.ts
new file mode 100644
index 0000000000..9103523b43
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-input/index.ts
@@ -0,0 +1 @@
+export * from "./amount-input";
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-preview/amount-preview.tsx b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-preview/amount-preview.tsx
new file mode 100644
index 0000000000..df4f3922db
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-preview/amount-preview.tsx
@@ -0,0 +1,44 @@
+import React from "react";
+
+import { dynamicNumeralFormatter } from "@mrgnlabs/mrgn-common";
+import { cn } from "@mrgnlabs/mrgn-utils";
+
+import { IconLoader } from "~/components/ui/icons";
+import { ArenaBank } from "~/store/tradeStoreV2";
+
+interface AmountPreviewProps {
+ tradeSide: "long" | "short";
+ selectedBank: ArenaBank | null;
+ amount: number;
+ isLoading?: boolean;
+}
+
+export const AmountPreview = ({ tradeSide, amount, isLoading, selectedBank }: AmountPreviewProps) => {
+ return (
+
+
+
+ {isLoading ? : dynamicNumeralFormatter(amount)}{" "}
+ {selectedBank?.meta.tokenSymbol.toUpperCase()}
+
+
+
+ );
+};
+
+interface StatProps {
+ label: string;
+ classNames?: string;
+ children: React.ReactNode;
+ style?: React.CSSProperties;
+}
+const Stat = ({ label, classNames, children, style }: StatProps) => {
+ return (
+ <>
+ {label}
+
+ {children}
+
+ >
+ );
+};
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-preview/index.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-preview/index.ts
new file mode 100644
index 0000000000..e91118fa42
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/amount-preview/index.ts
@@ -0,0 +1 @@
+export * from "./amount-preview";
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/header/header.tsx b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/header/header.tsx
new file mode 100644
index 0000000000..5769a2974d
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/header/header.tsx
@@ -0,0 +1,56 @@
+import { dynamicNumeralFormatter } from "@mrgnlabs/mrgn-common";
+import { IconChevronDown } from "@tabler/icons-react";
+import Image from "next/image";
+import Link from "next/link";
+import { useRouter } from "next/router";
+import * as React from "react";
+import { TokenCombobox } from "~/components/common/TokenCombobox";
+import { ArenaPoolV2Extended } from "~/store/tradeStoreV2";
+
+interface HeaderProps {
+ activePool: ArenaPoolV2Extended;
+ entryPrice: number;
+ volume: number | undefined;
+}
+
+export const Header = ({ activePool, entryPrice, volume }: HeaderProps) => {
+ const router = useRouter();
+
+ return (
+
+
{
+ router.push(`/trade/${pool.groupPk.toBase58()}`);
+ }}
+ >
+
+
+
+ {activePool.tokenBank.meta.tokenName}
+
+
+
+
+
+ Entry price
+ ${dynamicNumeralFormatter(entryPrice, { maxDisplay: 100 })}
+
+ {volume && (
+
+ 24h volume
+ ${dynamicNumeralFormatter(volume, { maxDisplay: 10000 })}
+
+ )}
+
+
+ );
+};
+
+// TODO: add entry price and volume
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/header/index.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/header/index.ts
new file mode 100644
index 0000000000..49ac70fe21
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/header/index.ts
@@ -0,0 +1 @@
+export * from "./header";
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/index.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/index.ts
new file mode 100644
index 0000000000..5594104f19
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/index.ts
@@ -0,0 +1,10 @@
+export * from "./action-toggle";
+export * from "./amount-input";
+export * from "./header";
+export * from "./info-messages";
+export * from "./stats";
+export * from "./settings";
+export * from "./amount-preview";
+export * from "./action-button";
+export * from "./leverage-slider";
+export * from "./action-simulation-status";
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/info-messages/index.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/info-messages/index.ts
new file mode 100644
index 0000000000..1e2ac5c4bc
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/info-messages/index.ts
@@ -0,0 +1 @@
+export * from "./info-messages";
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/info-messages/info-messages.tsx b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/info-messages/info-messages.tsx
new file mode 100644
index 0000000000..5e4c73733a
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/info-messages/info-messages.tsx
@@ -0,0 +1,205 @@
+import { ActionType } from "@mrgnlabs/marginfi-v2-ui-state";
+import { Wallet } from "@mrgnlabs/mrgn-common";
+import { ActionMessageType, cn } from "@mrgnlabs/mrgn-utils";
+import { Connection } from "@solana/web3.js";
+import { IconAlertTriangle, IconExternalLink, IconLoader } from "@tabler/icons-react";
+import Link from "next/link";
+import { ActionBox } from "~/components";
+import { Button } from "~/components/ui/button";
+import { ArenaPoolV2Extended } from "~/store/tradeStoreV2";
+
+interface InfoMessagesProps {
+ connected: boolean;
+ tradeState: string;
+ activePool: ArenaPoolV2Extended;
+ isActiveWithCollat: boolean;
+ actionMethods: ActionMessageType[];
+ setIsWalletOpen: (value: boolean) => void;
+ fetchTradeState: ({
+ connection,
+ wallet,
+ refresh,
+ }: {
+ connection?: Connection;
+ wallet?: Wallet;
+ refresh?: boolean;
+ }) => Promise;
+ refreshSimulation: () => void;
+ connection: any;
+ wallet: any;
+ isRetrying?: boolean;
+}
+
+export const InfoMessages = ({
+ connected,
+ tradeState,
+ activePool,
+ isActiveWithCollat,
+ actionMethods = [],
+ setIsWalletOpen,
+ fetchTradeState,
+ connection,
+ wallet,
+ refreshSimulation,
+ isRetrying,
+}: InfoMessagesProps) => {
+ const renderWarning = (message: string, action: () => void) => (
+
+
+
+
{message}
+
+ Swap tokens
+
+
+
+ );
+
+ const renderLongWarning = () =>
+ renderWarning(`You need to hold ${activePool?.tokenBank.meta.tokenSymbol} to open a long position.`, () =>
+ setIsWalletOpen(true)
+ );
+
+ const renderShortWarning = () =>
+ renderWarning(`You need to hold ${activePool?.quoteBank.meta.tokenSymbol} to open a short position.`, () =>
+ setIsWalletOpen(true)
+ );
+
+ const renderActionMethodMessages = () => (
+
+ {actionMethods.map(
+ (actionMethod, idx) =>
+ actionMethod.description && (
+
+
+
+ {actionMethod.actionMethod !== "INFO" && (
+
+ {(actionMethod.actionMethod || "WARNING").toLowerCase()}
+
+ )}
+
+
{actionMethod.description}
+ {actionMethod.link && (
+
+
+ {" "}
+ {actionMethod.linkText || "Read more"}
+
+
+ )}
+ {actionMethod.retry && refreshSimulation && (
+
+ {isRetrying ? (
+ <>
+ Retrying...
+ >
+ ) : (
+ "Retry"
+ )}
+
+ )}
+
+ {actionMethod.action && (
+
console.log("Position added"),
+ onComplete: () => fetchTradeState({ connection, wallet }),
+ }}
+ dialogProps={{
+ trigger: (
+
+ {actionMethod.action.type}
+
+ ),
+ title: `${actionMethod.action.type} ${actionMethod.action.bank.meta.tokenSymbol}`,
+ }}
+ />
+ )}
+
+
+ )
+ )}
+
+ );
+
+ // TODO: currently, often two warning messages are shown. We should decide if we want to do that, or if we want to show only one. if we want to show only one, we should add a 'priority' or something to decide which one to show.
+
+ const renderDepositCollateralDialog = () => (
+ console.log("Deposit Collateral"),
+ onComplete: () => fetchTradeState({ connection, wallet }),
+ }}
+ dialogProps={{
+ trigger: Deposit Collateral ,
+ title: `Supply ${activePool.quoteBank.meta.tokenSymbol}`,
+ }}
+ />
+ );
+
+ const renderContent = () => {
+ if (!connected) return null;
+
+ switch (true) {
+ case tradeState === "long" && activePool?.tokenBank.userInfo.tokenAccount.balance === 0:
+ return renderLongWarning();
+
+ case tradeState === "short" && activePool?.quoteBank.userInfo.tokenAccount.balance === 0:
+ return renderShortWarning();
+
+ case isActiveWithCollat:
+ return renderActionMethodMessages();
+
+ default:
+ return renderDepositCollateralDialog();
+ }
+ };
+
+ return {renderContent()}
;
+};
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/leverage-slider/index.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/leverage-slider/index.ts
new file mode 100644
index 0000000000..476b5d1373
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/leverage-slider/index.ts
@@ -0,0 +1 @@
+export * from "./leverage-slider";
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/leverage-slider/leverage-slider.tsx b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/leverage-slider/leverage-slider.tsx
new file mode 100644
index 0000000000..7e32a6bb09
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/leverage-slider/leverage-slider.tsx
@@ -0,0 +1,74 @@
+import React from "react";
+
+import { cn } from "@mrgnlabs/mrgn-utils";
+import { Slider } from "~/components/ui/slider";
+import { ArenaBank } from "~/store/tradeStoreV2";
+
+type LeverageSliderProps = {
+ selectedBank: ArenaBank | null;
+ selectedSecondaryBank: ArenaBank | null;
+ amountRaw: string;
+ leverageAmount: number;
+ maxLeverage: number;
+ setLeverageAmount: (amount: number) => void;
+};
+
+export const LeverageSlider = ({
+ selectedBank,
+ selectedSecondaryBank,
+ amountRaw,
+ leverageAmount,
+ maxLeverage,
+ setLeverageAmount,
+}: LeverageSliderProps) => {
+ const bothBanksSelected = React.useMemo(
+ () => Boolean(selectedBank && selectedSecondaryBank),
+ [selectedBank, selectedSecondaryBank]
+ );
+
+ return (
+ <>
+
+
+
+
{
+ if (value[0] > maxLeverage || value[0] <= 1) return;
+ setLeverageAmount(value[0]);
+ }}
+ disabled={!bothBanksSelected || !amountRaw}
+ />
+
+
+ {leverageAmount > 1 && `${leverageAmount.toFixed(2)}x leverage`}
+
+
+
+ {maxLeverage.toFixed(2)}x
+ setLeverageAmount(Number(maxLeverage))}
+ >
+ MAX
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/settings/components/index.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/settings/components/index.ts
new file mode 100644
index 0000000000..50ea3be54a
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/settings/components/index.ts
@@ -0,0 +1 @@
+export * from "./slippage";
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/settings/components/slippage.tsx b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/settings/components/slippage.tsx
new file mode 100644
index 0000000000..e428499f33
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/settings/components/slippage.tsx
@@ -0,0 +1,151 @@
+import React from "react";
+
+import { IconInfoCircle } from "@tabler/icons-react";
+import { useForm } from "react-hook-form";
+import { cn } from "@mrgnlabs/mrgn-utils";
+
+import { Button } from "~/components/ui/button";
+import { Input } from "~/components/ui/input";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip";
+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 SlippageProps = {
+ slippageBps: number;
+ setSlippageBps: (value: number) => void;
+ toggleSettings: (mode: boolean) => void;
+};
+
+const DEFAULT_SLIPPAGE_BPS = 100;
+
+const slippageOptions = [
+ {
+ label: "Low",
+ value: 0.3,
+ },
+ {
+ label: "Normal",
+ value: 0.5,
+ },
+ {
+ label: "High",
+ value: 1,
+ },
+];
+
+interface SlippageForm {
+ slippageBps: number;
+}
+
+export const Slippage = ({ slippageBps, setSlippageBps, toggleSettings }: SlippageProps) => {
+ 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]
+ );
+
+ function onSubmit(data: SlippageForm) {
+ setSlippageBps(data.slippageBps);
+ toggleSettings(false);
+ }
+
+ return (
+
+ );
+};
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/settings/index.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/settings/index.ts
new file mode 100644
index 0000000000..7b2a8f6d92
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/settings/index.ts
@@ -0,0 +1,2 @@
+export * from "./settings";
+export * from "./settings-dialog";
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/settings/settings-dialog.tsx b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/settings/settings-dialog.tsx
new file mode 100644
index 0000000000..1bd39b34f1
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/settings/settings-dialog.tsx
@@ -0,0 +1,61 @@
+import React from "react";
+
+import { Desktop, Mobile } from "@mrgnlabs/mrgn-utils";
+
+import { useIsMobile } from "~/hooks/use-is-mobile";
+import { Dialog, DialogTrigger, DialogContent } from "~/components/ui/dialog";
+
+import { TradingBoxSettings } from "./settings";
+
+type SettingsDialogProps = {
+ slippageBps: number;
+ setSlippageBps: (value: number) => void;
+
+ children: React.ReactNode;
+ isDialogTriggered?: boolean;
+};
+
+export const TradingBoxSettingsDialog = ({
+ slippageBps,
+ setSlippageBps,
+ children,
+ isDialogTriggered = false,
+}: SettingsDialogProps) => {
+ const [isDialogOpen, setIsDialogOpen] = React.useState(false);
+ const isMobile = useIsMobile();
+
+ React.useEffect(() => {
+ setIsDialogOpen(isDialogTriggered);
+ }, [setIsDialogOpen, isDialogTriggered]);
+
+ return (
+ setIsDialogOpen(open)}>
+
+ {isDialogOpen &&
}
+ {children}
+
+ setIsDialogOpen(mode)}
+ slippageBps={slippageBps}
+ setSlippageBps={setSlippageBps}
+ />
+
+
+
+ {children}
+
+
+ setIsDialogOpen(mode)}
+ slippageBps={slippageBps}
+ setSlippageBps={setSlippageBps}
+ />
+
+
+
+
+ );
+};
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/settings/settings.tsx b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/settings/settings.tsx
new file mode 100644
index 0000000000..9ea8dfe781
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/settings/settings.tsx
@@ -0,0 +1,24 @@
+import React from "react";
+
+import { IconArrowLeft } from "@tabler/icons-react";
+
+import { Slippage } from "./components";
+
+type TradingBoxSettingsProps = {
+ toggleSettings: (mode: boolean) => void;
+ slippageBps: number;
+ setSlippageBps: (value: number) => void;
+};
+
+export const TradingBoxSettings = ({ toggleSettings, slippageBps, setSlippageBps }: TradingBoxSettingsProps) => {
+ return (
+
+
toggleSettings(false)}>
+ Back to trading
+
+
+
+
+
+ );
+};
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/stats/index.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/stats/index.ts
new file mode 100644
index 0000000000..211e8756df
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/stats/index.ts
@@ -0,0 +1 @@
+export * from "./stats";
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/stats/stats.tsx b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/stats/stats.tsx
new file mode 100644
index 0000000000..a32ec3362f
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/stats/stats.tsx
@@ -0,0 +1,50 @@
+import React from "react";
+import { ArenaPoolV2Extended } from "~/store/tradeStoreV2";
+import { generateTradeStats } from "./utils/stats-utils";
+import { cn, TradeActionTxns } from "@mrgnlabs/mrgn-utils";
+import { SimulationResult } from "@mrgnlabs/marginfi-client-v2";
+import { AccountSummary } from "@mrgnlabs/marginfi-v2-ui-state";
+import { ActionStatItem } from "~/components/action-box-v2/components/action-stats/action-stat-item";
+
+interface StatsProps {
+ activePool: ArenaPoolV2Extended;
+ accountSummary: AccountSummary | null;
+ simulationResult: SimulationResult | null;
+ actionTxns: TradeActionTxns | null;
+}
+export const Stats = ({ activePool, accountSummary, simulationResult, actionTxns }: StatsProps) => {
+ const stats = React.useMemo(
+ () =>
+ generateTradeStats({
+ accountSummary: accountSummary,
+ extendedPool: activePool,
+ simulationResult: simulationResult,
+ actionTxns: actionTxns,
+ }),
+ [activePool, accountSummary, simulationResult, actionTxns]
+ );
+ return (
+ <>
+ {stats && (
+
+ {stats.map((stat, idx) => (
+
+
+
+ ))}
+
+ )}
+ >
+ );
+};
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/stats/utils/index.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/stats/utils/index.ts
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/stats/utils/stats-utils.tsx b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/stats/utils/stats-utils.tsx
new file mode 100644
index 0000000000..cac6465e58
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/components/stats/utils/stats-utils.tsx
@@ -0,0 +1,149 @@
+import { MarginRequirementType, SimulationResult } from "@mrgnlabs/marginfi-client-v2";
+import { AccountSummary } from "@mrgnlabs/marginfi-v2-ui-state";
+import { percentFormatter, tokenPriceFormatter, usdFormatter } from "@mrgnlabs/mrgn-common";
+import { TradeActionTxns } from "@mrgnlabs/mrgn-utils";
+import Link from "next/link";
+import { PreviewStat } from "~/components/action-box-v2/utils";
+import { IconPyth } from "~/components/ui/icons";
+import { IconSwitchboard } from "~/components/ui/icons";
+
+import { ArenaPoolV2Extended } from "~/store/tradeStoreV2";
+
+interface generateTradeStatsProps {
+ accountSummary: AccountSummary | null;
+ extendedPool: ArenaPoolV2Extended;
+ simulationResult: SimulationResult | null;
+ actionTxns: TradeActionTxns | null;
+}
+
+export function generateTradeStats(props: generateTradeStatsProps) {
+ const stats: PreviewStat[] = [];
+
+ // entry price stat
+ stats.push({
+ label: "Entry price",
+ value: () => <>{tokenPriceFormatter(props.extendedPool.tokenBank.info.state.price)}>,
+ });
+
+ if (props.actionTxns) {
+ // slippage stat
+ const slippageBps = props.actionTxns?.actionQuote?.slippageBps;
+ if (slippageBps) {
+ stats.push({
+ label: "Slippage",
+ color: slippageBps > 500 ? "ALERT" : "SUCCESS",
+ value: () => <>{percentFormatter.format(slippageBps / 10000)}>,
+ });
+ }
+
+ // platform fee stat
+ const platformFeeBps = props.actionTxns?.actionQuote?.platformFee
+ ? Number(props.actionTxns.actionQuote.platformFee?.feeBps)
+ : undefined;
+
+ if (platformFeeBps) {
+ stats.push({
+ label: "Platform fee",
+ value: () => <>{percentFormatter.format(platformFeeBps / 10000)}>,
+ });
+ }
+
+ // price impact stat
+ const priceImpactPct = props.actionTxns?.actionQuote
+ ? Number(props.actionTxns.actionQuote.priceImpactPct)
+ : undefined;
+
+ if (priceImpactPct !== undefined) {
+ stats.push({
+ label: "Price impact",
+ color: priceImpactPct > 0.05 ? "DESTRUCTIVE" : priceImpactPct > 0.01 ? "ALERT" : "SUCCESS",
+ value: () => <>{percentFormatter.format(priceImpactPct)}>,
+ });
+ }
+ }
+
+ // oracle stat
+ let oracle = {
+ name: "",
+ link: "",
+ };
+
+ switch (props.extendedPool.tokenBank.info.rawBank.config.oracleSetup) {
+ case "PythLegacy":
+ oracle = {
+ name: "Pyth",
+ link: "https://pyth.network/",
+ };
+ break;
+ case "PythPushOracle":
+ oracle = {
+ name: "Pyth",
+ link: "https://pyth.network/",
+ };
+ break;
+ case "SwitchboardV2":
+ oracle = {
+ name: "Switchboard",
+ link: `https://ondemand.switchboard.xyz/solana/mainnet/feed/${props.extendedPool.tokenBank.info.rawBank.config.oracleKeys[0].toBase58()}`,
+ };
+ break;
+ case "SwitchboardPull":
+ oracle = {
+ name: "Switchboard",
+ link: `https://ondemand.switchboard.xyz/solana/mainnet/feed/${props.extendedPool.tokenBank.info.rawBank.config.oracleKeys[0].toBase58()}`,
+ };
+ break;
+ }
+ stats.push({
+ label: "Oracle",
+ value: () => (
+ <>
+
+ {oracle.name === "Pyth" ? : }
+
+ >
+ ),
+ });
+
+ return stats;
+}
+
+export function getSimulationStats(simulationResult: SimulationResult, extendedPool: ArenaPoolV2Extended) {
+ const { assets, liabilities } = simulationResult.marginfiAccount.computeHealthComponents(
+ MarginRequirementType.Maintenance
+ );
+
+ const healthFactor = assets.minus(liabilities).dividedBy(assets).toNumber();
+ const liquidationPrice = simulationResult.marginfiAccount.computeLiquidationPriceForBank(
+ extendedPool.tokenBank.address
+ );
+
+ // Token position
+ const tokenPosition = simulationResult.marginfiAccount.activeBalances.find(
+ (b) => b.active && b.bankPk.equals(extendedPool.tokenBank.address)
+ );
+ let tokenPositionAmount = 0;
+ if (tokenPosition && tokenPosition.liabilityShares.gt(0)) {
+ tokenPositionAmount = tokenPosition.computeQuantityUi(extendedPool.tokenBank.info.rawBank).liabilities.toNumber();
+ } else if (tokenPosition && tokenPosition.assetShares.gt(0)) {
+ tokenPositionAmount = tokenPosition.computeQuantityUi(extendedPool.tokenBank.info.rawBank).assets.toNumber();
+ }
+
+ // quote position
+ const quotePosition = simulationResult.marginfiAccount.activeBalances.find(
+ (b) => b.active && b.bankPk.equals(extendedPool.quoteBank.address)
+ );
+ let quotePositionAmount = 0;
+ if (quotePosition && quotePosition.liabilityShares.gt(0)) {
+ quotePositionAmount = quotePosition.computeQuantityUi(extendedPool.quoteBank.info.rawBank).liabilities.toNumber();
+ } else if (quotePosition && quotePosition.assetShares.gt(0)) {
+ quotePositionAmount = quotePosition.computeQuantityUi(extendedPool.quoteBank.info.rawBank).assets.toNumber();
+ }
+
+ return {
+ tokenPositionAmount,
+ quotePositionAmount,
+ healthFactor,
+ liquidationPrice,
+ };
+}
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/hooks/index.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/hooks/index.ts
new file mode 100644
index 0000000000..8b726a9210
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/hooks/index.ts
@@ -0,0 +1,2 @@
+export * from "./use-trade-simulation";
+export * from "./use-action-amounts";
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/hooks/use-action-amounts.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/hooks/use-action-amounts.ts
new file mode 100644
index 0000000000..ceed58ba33
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/hooks/use-action-amounts.ts
@@ -0,0 +1,49 @@
+import React from "react";
+
+import { ActionType, ExtendedBankInfo } from "@mrgnlabs/marginfi-v2-ui-state";
+import { WSOL_MINT } from "@mrgnlabs/mrgn-common";
+
+import { useAmountDebounce } from "~/hooks/useAmountDebounce";
+import { ArenaBank, ArenaPoolV2Extended } from "~/store/tradeStoreV2";
+
+export function useActionAmounts({
+ amountRaw,
+ activePool,
+ collateralBank,
+ nativeSolBalance,
+}: {
+ amountRaw: string;
+ activePool: ArenaPoolV2Extended | null;
+ collateralBank: ArenaBank | null;
+ nativeSolBalance: number;
+}) {
+ const amount = React.useMemo(() => {
+ const strippedAmount = amountRaw.replace(/,/g, "");
+ return isNaN(Number.parseFloat(strippedAmount)) ? 0 : Number.parseFloat(strippedAmount);
+ }, [amountRaw]);
+
+ const debouncedAmount = useAmountDebounce(amount, 500);
+
+ const walletAmount = React.useMemo(
+ () =>
+ collateralBank?.info.state.mint?.equals && collateralBank?.info.state.mint?.equals(WSOL_MINT)
+ ? collateralBank?.userInfo.tokenAccount.balance + nativeSolBalance
+ : collateralBank?.userInfo.tokenAccount.balance,
+ [nativeSolBalance, collateralBank]
+ );
+
+ const maxAmount = React.useMemo(() => {
+ if (!collateralBank) {
+ return 0;
+ }
+
+ return collateralBank.userInfo.maxDeposit;
+ }, [collateralBank]);
+
+ return {
+ amount,
+ debouncedAmount,
+ walletAmount,
+ maxAmount,
+ };
+}
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/hooks/use-trade-simulation.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/hooks/use-trade-simulation.ts
new file mode 100644
index 0000000000..fa6e76035b
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/hooks/use-trade-simulation.ts
@@ -0,0 +1,280 @@
+import {
+ computeMaxLeverage,
+ MarginfiAccountWrapper,
+ MarginfiClient,
+ SimulationResult,
+} from "@mrgnlabs/marginfi-client-v2";
+import { AccountSummary } from "@mrgnlabs/marginfi-v2-ui-state";
+import { SolanaTransaction } from "@mrgnlabs/mrgn-common";
+import {
+ ActionMessageType,
+ CalculateLoopingProps,
+ DYNAMIC_SIMULATION_ERRORS,
+ extractErrorString,
+ TradeActionTxns,
+ STATIC_SIMULATION_ERRORS,
+ usePrevious,
+} from "@mrgnlabs/mrgn-utils";
+import { Transaction, VersionedTransaction } from "@solana/web3.js";
+import React from "react";
+import { calculateLooping } from "~/components/action-box-v2/actions/loop-box/utils/loop-action.utils";
+import { SimulationStatus } from "~/components/action-box-v2/utils";
+import { ArenaBank, ArenaPoolV2Extended } from "~/store/tradeStoreV2";
+import { calculateSummary, generateTradeTx, getSimulationResult } from "../utils";
+import BigNumber from "bignumber.js";
+
+export type TradeSimulationProps = {
+ debouncedAmount: number;
+ debouncedLeverage: number;
+ selectedBank: ArenaBank | null;
+ selectedSecondaryBank: ArenaBank | null;
+ marginfiClient: MarginfiClient | null;
+ wrappedAccount: MarginfiAccountWrapper | null;
+ isEnabled: boolean;
+
+ slippageBps: number;
+ platformFeeBps: number;
+
+ setActionTxns: (actionTxns: TradeActionTxns) => void;
+ setErrorMessage: (error: ActionMessageType | null) => void;
+ setIsLoading: ({ isLoading, status }: { isLoading: boolean; status: SimulationStatus }) => void;
+ setSimulationResult: (result: SimulationResult | null) => void;
+ setMaxLeverage: (maxLeverage: number) => void;
+};
+
+export function useTradeSimulation({
+ debouncedAmount,
+ debouncedLeverage,
+ selectedBank,
+ selectedSecondaryBank,
+ marginfiClient,
+ wrappedAccount,
+ slippageBps,
+ platformFeeBps,
+ isEnabled,
+ setActionTxns,
+ setErrorMessage,
+ setIsLoading,
+ setSimulationResult,
+ setMaxLeverage,
+}: TradeSimulationProps) {
+ const prevDebouncedAmount = usePrevious(debouncedAmount);
+ const prevDebouncedLeverage = usePrevious(debouncedLeverage);
+ const prevSelectedSecondaryBank = usePrevious(selectedSecondaryBank);
+
+ const handleError = (
+ actionMessage: ActionMessageType | string,
+ callbacks: {
+ setErrorMessage: (error: ActionMessageType | null) => void;
+ setSimulationResult: (result: SimulationResult | null) => void;
+ setActionTxns: (actionTxns: TradeActionTxns) => void;
+ setIsLoading: ({ isLoading, status }: { isLoading: boolean; status: SimulationStatus }) => void;
+ }
+ ) => {
+ if (typeof actionMessage === "string") {
+ const errorMessage = extractErrorString(actionMessage);
+ const _actionMessage: ActionMessageType = {
+ isEnabled: true,
+ description: errorMessage,
+ };
+ callbacks.setErrorMessage(_actionMessage);
+ } else {
+ callbacks.setErrorMessage(actionMessage);
+ }
+ callbacks.setSimulationResult(null);
+ callbacks.setActionTxns({
+ actionTxn: null,
+ additionalTxns: [],
+ actionQuote: null,
+ lastValidBlockHeight: undefined,
+ actualDepositAmount: 0,
+ borrowAmount: new BigNumber(0),
+ });
+ console.error(
+ "Error simulating transaction",
+ typeof actionMessage === "string" ? extractErrorString(actionMessage) : actionMessage.description
+ );
+ callbacks.setIsLoading({ isLoading: false, status: SimulationStatus.COMPLETE });
+ };
+
+ const simulationAction = async (props: {
+ account: MarginfiAccountWrapper;
+ bank: ArenaBank;
+ txns: (VersionedTransaction | Transaction)[];
+ }): Promise<{
+ simulationResult: SimulationResult | null;
+ actionMessage: ActionMessageType | null;
+ }> => {
+ if (props.txns.length > 0) {
+ const simulationResult = await getSimulationResult(props);
+
+ if (simulationResult.actionMethod) {
+ return { simulationResult: null, actionMessage: simulationResult.actionMethod };
+ } else if (simulationResult.simulationResult) {
+ return { simulationResult: simulationResult.simulationResult, actionMessage: null };
+ } else {
+ const errorMessage = DYNAMIC_SIMULATION_ERRORS.REPAY_COLLAT_FAILED_CHECK(props.bank.meta.tokenSymbol); // TODO: update
+ return { simulationResult: null, actionMessage: errorMessage };
+ }
+ } else {
+ throw new Error("account, bank or transactions are null");
+ }
+ };
+
+ const fetchTradeTxnsAction = async (
+ props: CalculateLoopingProps
+ ): Promise<{ actionTxns: TradeActionTxns | null; actionMessage: ActionMessageType | null }> => {
+ try {
+ const loopingResult = await generateTradeTx({
+ ...props,
+ });
+
+ if (loopingResult && "actionQuote" in loopingResult) {
+ return { actionTxns: loopingResult, actionMessage: null };
+ } else {
+ const errorMessage =
+ loopingResult ?? DYNAMIC_SIMULATION_ERRORS.REPAY_COLLAT_FAILED_CHECK(props.borrowBank.meta.tokenSymbol);
+ // TODO: update
+ return { actionTxns: null, actionMessage: errorMessage };
+ }
+ } catch (error) {
+ return { actionTxns: null, actionMessage: STATIC_SIMULATION_ERRORS.REPAY_COLLAT_FAILED }; // TODO: update
+ }
+ };
+
+ const handleSimulation = React.useCallback(
+ async (amount: number, leverage: number) => {
+ try {
+ if (amount === 0 || leverage === 0 || !selectedBank || !selectedSecondaryBank || !marginfiClient) {
+ setActionTxns({
+ actionTxn: null,
+ additionalTxns: [],
+ actionQuote: null,
+ lastValidBlockHeight: undefined,
+ actualDepositAmount: 0,
+ borrowAmount: new BigNumber(0),
+ });
+ setSimulationResult(null);
+ return;
+ }
+ setIsLoading({ isLoading: true, status: SimulationStatus.SIMULATING });
+
+ const tradeActionTxns = await fetchTradeTxnsAction({
+ marginfiClient: marginfiClient,
+ marginfiAccount: wrappedAccount,
+ depositBank: selectedBank,
+ borrowBank: selectedSecondaryBank,
+ targetLeverage: leverage,
+ depositAmount: amount,
+ slippageBps: slippageBps,
+ connection: marginfiClient?.provider.connection,
+ platformFeeBps: platformFeeBps,
+ });
+
+ if (tradeActionTxns.actionMessage || tradeActionTxns.actionTxns === null) {
+ handleError(tradeActionTxns.actionMessage ?? STATIC_SIMULATION_ERRORS.REPAY_COLLAT_FAILED, {
+ // TODO: update error message
+ setErrorMessage,
+ setSimulationResult,
+ setActionTxns,
+ setIsLoading,
+ });
+ return;
+ }
+
+ const finalAccount = tradeActionTxns?.actionTxns.marginfiAccount || wrappedAccount;
+
+ if (!finalAccount) {
+ throw new Error("Marginfi account is null");
+ }
+
+ const simulationResult = await simulationAction({
+ account: finalAccount,
+ bank: selectedBank,
+ txns: [
+ ...(tradeActionTxns?.actionTxns?.additionalTxns ?? []),
+ ...(tradeActionTxns?.actionTxns?.actionTxn ? [tradeActionTxns?.actionTxns?.actionTxn] : []),
+ ],
+ });
+
+ if (simulationResult.actionMessage || simulationResult.simulationResult === null) {
+ handleError(simulationResult.actionMessage ?? STATIC_SIMULATION_ERRORS.REPAY_COLLAT_FAILED, {
+ // TODO: update
+ setErrorMessage,
+ setSimulationResult,
+ setActionTxns,
+ setIsLoading,
+ });
+ return;
+ } else if (simulationResult.simulationResult) {
+ setSimulationResult(simulationResult.simulationResult);
+ setActionTxns(tradeActionTxns.actionTxns);
+ } else {
+ throw new Error("Unknown error");
+ }
+ } catch (error) {
+ console.error("Error simulating transaction", error);
+ setSimulationResult(null);
+ setActionTxns({
+ actionTxn: null,
+ additionalTxns: [],
+ actionQuote: null,
+ lastValidBlockHeight: undefined,
+ actualDepositAmount: 0,
+ borrowAmount: new BigNumber(0),
+ });
+ } finally {
+ setIsLoading({ isLoading: false, status: SimulationStatus.COMPLETE });
+ }
+ },
+ [
+ selectedBank,
+ selectedSecondaryBank,
+ marginfiClient,
+ setIsLoading,
+ wrappedAccount,
+ slippageBps,
+ platformFeeBps,
+ setActionTxns,
+ setSimulationResult,
+ setErrorMessage,
+ ]
+ );
+
+ const fetchMaxLeverage = React.useCallback(async () => {
+ if (selectedBank && selectedSecondaryBank) {
+ const { maxLeverage, ltv } = computeMaxLeverage(selectedBank.info.rawBank, selectedSecondaryBank.info.rawBank);
+
+ if (!maxLeverage) {
+ const errorMessage = DYNAMIC_SIMULATION_ERRORS.REPAY_COLLAT_FAILED_CHECK(
+ selectedSecondaryBank.meta.tokenSymbol
+ );
+ setErrorMessage(errorMessage);
+ } else {
+ setMaxLeverage(maxLeverage);
+ }
+ }
+ }, [selectedBank, selectedSecondaryBank, setErrorMessage, setMaxLeverage]);
+
+ React.useEffect(() => {
+ if ((prevDebouncedAmount !== debouncedAmount || prevDebouncedLeverage !== debouncedLeverage) && isEnabled) {
+ // Only set to PREPARING if we're actually going to simulate
+ if (debouncedAmount > 0 && debouncedLeverage > 0) {
+ handleSimulation(debouncedAmount, debouncedLeverage);
+ }
+ }
+ }, [debouncedAmount, debouncedLeverage, handleSimulation, isEnabled, prevDebouncedAmount, prevDebouncedLeverage]);
+
+ // Fetch max leverage based when the secondary bank changes
+ React.useEffect(() => {
+ if (selectedSecondaryBank && prevSelectedSecondaryBank?.address !== selectedSecondaryBank.address) {
+ fetchMaxLeverage();
+ }
+ }, [selectedSecondaryBank, prevSelectedSecondaryBank, fetchMaxLeverage]);
+
+ const refreshSimulation = React.useCallback(async () => {
+ await handleSimulation(debouncedAmount ?? 0, debouncedLeverage ?? 0);
+ }, [handleSimulation, debouncedAmount, debouncedLeverage]);
+
+ return { refreshSimulation };
+}
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/index.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/index.ts
new file mode 100644
index 0000000000..8ab72d9631
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/index.ts
@@ -0,0 +1,2 @@
+export * from "./trade-box";
+export * from "./utils";
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/store/index.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/store/index.ts
new file mode 100644
index 0000000000..87831830a7
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/store/index.ts
@@ -0,0 +1,5 @@
+import { createTradeBoxStore } from "./trade-box-store";
+
+const useTradeBoxStore = createTradeBoxStore();
+
+export { useTradeBoxStore };
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/store/trade-box-store.tsx b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/store/trade-box-store.tsx
new file mode 100644
index 0000000000..73cb7991ea
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/store/trade-box-store.tsx
@@ -0,0 +1,162 @@
+import { ActionMessageType, TradeActionTxns } from "@mrgnlabs/mrgn-utils";
+import BigNumber from "bignumber.js";
+
+import { SimulationResult } from "@mrgnlabs/marginfi-client-v2";
+import { create, StateCreator } from "zustand";
+import { TradeSide } from "..";
+import { ArenaBank } from "~/store/tradeStoreV2";
+
+interface TradeBoxState {
+ // State
+ amountRaw: string;
+ tradeState: TradeSide;
+ leverage: number;
+ maxLeverage: number;
+
+ selectedBank: ArenaBank | null;
+ selectedSecondaryBank: ArenaBank | null;
+
+ simulationResult: SimulationResult | null;
+ actionTxns: TradeActionTxns;
+
+ errorMessage: ActionMessageType | null;
+
+ // Actions
+ refreshState: () => void;
+
+ setAmountRaw: (amountRaw: string, maxAmount?: number) => void;
+ setTradeState: (tradeState: TradeSide) => void;
+ setLeverage: (leverage: number) => void;
+ setSimulationResult: (result: SimulationResult | null) => void;
+ setActionTxns: (actionTxns: TradeActionTxns) => void;
+ setErrorMessage: (errorMessage: ActionMessageType | null) => void;
+ setSelectedBank: (bank: ArenaBank | null) => void;
+ setSelectedSecondaryBank: (bank: ArenaBank | null) => void;
+ setMaxLeverage: (maxLeverage: number) => void;
+}
+
+const initialState = {
+ amountRaw: "",
+ leverageAmount: 0,
+ leverage: 0,
+ simulationResult: null,
+ errorMessage: null,
+ selectedBank: null,
+ selectedSecondaryBank: null,
+ maxLeverage: 0,
+
+ actionTxns: {
+ actionTxn: null,
+ additionalTxns: [],
+ actionQuote: null,
+ lastValidBlockHeight: undefined,
+ actualDepositAmount: 0,
+ borrowAmount: new BigNumber(0),
+ },
+};
+
+function createTradeBoxStore() {
+ return create(stateCreator);
+}
+
+const stateCreator: StateCreator = (set, get) => ({
+ // State
+ ...initialState,
+ tradeState: "long" as TradeSide,
+
+ refreshState() {
+ set({
+ amountRaw: initialState.amountRaw,
+ leverage: initialState.leverage,
+ actionTxns: initialState.actionTxns,
+ errorMessage: null,
+ });
+ },
+
+ setAmountRaw(amountRaw, maxAmount) {
+ const prevAmountRaw = get().amountRaw;
+ const isAmountChanged = amountRaw !== prevAmountRaw;
+
+ if (isAmountChanged) {
+ set({
+ simulationResult: null,
+ actionTxns: initialState.actionTxns,
+ errorMessage: null,
+ });
+ }
+
+ if (!maxAmount) {
+ set({ amountRaw });
+ } else {
+ const strippedAmount = amountRaw.replace(/,/g, "");
+ const amount = isNaN(Number.parseFloat(strippedAmount)) ? 0 : Number.parseFloat(strippedAmount);
+ const numberFormatter = new Intl.NumberFormat("en-US", { maximumFractionDigits: 10 });
+
+ if (amount && amount > maxAmount) {
+ set({ amountRaw: numberFormatter.format(maxAmount) });
+ } else {
+ set({ amountRaw: numberFormatter.format(amount) });
+ }
+ }
+ },
+
+ setTradeState(tradeState: TradeSide) {
+ set({ tradeState });
+ },
+
+ setLeverage(leverage: number) {
+ set({ leverage });
+ },
+
+ setSimulationResult(result: SimulationResult | null) {
+ set({ simulationResult: result });
+ },
+
+ setActionTxns(actionTxns: TradeActionTxns) {
+ set({ actionTxns: actionTxns });
+ },
+
+ setErrorMessage(errorMessage: ActionMessageType | null) {
+ set({ errorMessage: errorMessage });
+ },
+
+ setSelectedBank(tokenBank) {
+ const selectedBank = get().selectedBank;
+ const hasBankChanged = !tokenBank || !selectedBank || !tokenBank.address.equals(selectedBank.address);
+
+ if (hasBankChanged) {
+ set({
+ selectedBank: tokenBank,
+ amountRaw: initialState.amountRaw,
+ leverage: initialState.leverage,
+ actionTxns: initialState.actionTxns,
+ errorMessage: null,
+ });
+ }
+ },
+
+ setSelectedSecondaryBank(secondaryBank) {
+ const selectedSecondaryBank = get().selectedSecondaryBank;
+ const hasBankChanged =
+ !secondaryBank || !selectedSecondaryBank || !secondaryBank.address.equals(selectedSecondaryBank.address);
+
+ if (hasBankChanged) {
+ set({
+ selectedSecondaryBank: secondaryBank,
+ amountRaw: initialState.amountRaw,
+ leverage: initialState.leverage,
+ actionTxns: initialState.actionTxns,
+ errorMessage: null,
+ });
+ } else {
+ set({ selectedSecondaryBank: secondaryBank });
+ }
+ },
+
+ setMaxLeverage(maxLeverage) {
+ set({ maxLeverage });
+ },
+});
+
+export { createTradeBoxStore };
+export type { TradeBoxState };
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/trade-box.tsx b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/trade-box.tsx
new file mode 100644
index 0000000000..da3ba5b944
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/trade-box.tsx
@@ -0,0 +1,520 @@
+"use client";
+
+import React from "react";
+
+import {
+ ActionMessageType,
+ ActionTxns,
+ capture,
+ ExecuteTradeActionProps,
+ formatAmount,
+ IndividualFlowError,
+ TradeActionTxns,
+ MultiStepToastHandle,
+ showErrorToast,
+ useConnection,
+} from "@mrgnlabs/mrgn-utils";
+import { IconSettings } from "@tabler/icons-react";
+import { ActionType, ActiveBankInfo } from "@mrgnlabs/marginfi-v2-ui-state";
+
+import { ArenaPoolV2, ArenaPoolV2Extended } from "~/store/tradeStoreV2";
+import { handleExecuteTradeAction, SimulationStatus, TradeSide } from "~/components/common/trade-box-v2/utils";
+import { Card, CardContent, CardHeader } from "~/components/ui/card";
+import { useTradeStoreV2, useUiStore } from "~/store";
+import { useWallet, useWalletStore } from "~/components/wallet-v2";
+import { useExtendedPool } from "~/hooks/useExtendedPools";
+import { useMarginfiClient } from "~/hooks/useMarginfiClient";
+import { useWrappedAccount } from "~/hooks/useWrappedAccount";
+import { useAmountDebounce } from "~/hooks/useAmountDebounce";
+import { PreviousTxn } from "~/types";
+
+import {
+ ActionButton,
+ ActionToggle,
+ AmountInput,
+ AmountPreview,
+ Header,
+ LeverageSlider,
+ Stats,
+ TradingBoxSettingsDialog,
+ InfoMessages,
+ ActionSimulationStatus,
+} from "./components";
+import { useTradeBoxStore } from "./store";
+import { checkTradeActionAvailable } from "./utils";
+import { useTradeSimulation, useActionAmounts } from "./hooks";
+
+interface TradeBoxV2Props {
+ activePool: ArenaPoolV2;
+ side?: TradeSide;
+}
+
+export const TradeBoxV2 = ({ activePool, side = "long" }: TradeBoxV2Props) => {
+ // Stores
+ const [
+ amountRaw,
+ tradeState,
+ leverage,
+ simulationResult,
+ actionTxns,
+ errorMessage,
+ selectedBank,
+ selectedSecondaryBank,
+ maxLeverage,
+ refreshState,
+ setAmountRaw,
+ setTradeState,
+ setLeverage,
+ setSimulationResult,
+ setActionTxns,
+ setErrorMessage,
+ setSelectedBank,
+ setSelectedSecondaryBank,
+ setMaxLeverage,
+ ] = useTradeBoxStore((state) => [
+ state.amountRaw,
+ state.tradeState,
+ state.leverage,
+ state.simulationResult,
+ state.actionTxns,
+ state.errorMessage,
+ state.selectedBank,
+ state.selectedSecondaryBank,
+ state.maxLeverage,
+ state.refreshState,
+ state.setAmountRaw,
+ state.setTradeState,
+ state.setLeverage,
+ state.setSimulationResult,
+ state.setActionTxns,
+ state.setErrorMessage,
+ state.setSelectedBank,
+ state.setSelectedSecondaryBank,
+ state.setMaxLeverage,
+ ]);
+ const [
+ slippageBps,
+ setSlippageBps,
+ platformFeeBps,
+ broadcastType,
+ priorityFees,
+ setIsActionComplete,
+ setPreviousTxn,
+ ] = useUiStore((state) => [
+ state.slippageBps,
+ state.setSlippageBps,
+ state.platformFeeBps,
+ state.broadcastType,
+ state.priorityFees,
+ state.setIsActionComplete,
+ state.setPreviousTxn,
+ ]);
+ const [setIsWalletOpen] = useWalletStore((state) => [state.setIsWalletOpen]);
+ const [fetchTradeState, nativeSolBalance, setIsRefreshingStore, refreshGroup] = useTradeStoreV2((state) => [
+ state.fetchTradeState,
+ state.nativeSolBalance,
+ state.setIsRefreshingStore,
+ state.refreshGroup,
+ ]);
+
+ // Hooks
+ const activePoolExtended = useExtendedPool(activePool);
+ const client = useMarginfiClient({ groupPk: activePoolExtended.groupPk });
+ const { accountSummary, wrappedAccount } = useWrappedAccount({
+ client,
+ groupPk: activePoolExtended.groupPk,
+ banks: [activePoolExtended.tokenBank, activePoolExtended.quoteBank],
+ });
+ const { wallet, connected } = useWallet();
+ const { connection } = useConnection();
+ const { amount, debouncedAmount, maxAmount } = useActionAmounts({
+ amountRaw,
+ activePool: activePoolExtended,
+ collateralBank: selectedBank,
+ nativeSolBalance,
+ });
+ const debouncedLeverage = useAmountDebounce(leverage, 500);
+
+ // States
+ const [dynamicActionMessages, setDynamicActionMessages] = React.useState([]);
+
+ // Loading states
+ const [isTransactionExecuting, setIsTransactionExecuting] = React.useState(false);
+ const [isSimulating, setIsSimulating] = React.useState<{
+ isLoading: boolean;
+ status: SimulationStatus;
+ }>({
+ isLoading: false,
+ status: SimulationStatus.IDLE,
+ });
+ const isLoading = React.useMemo(
+ () => isTransactionExecuting || isSimulating.isLoading,
+ [isTransactionExecuting, isSimulating.isLoading]
+ );
+
+ // Memos
+ const numberFormater = React.useMemo(() => new Intl.NumberFormat("en-US", { maximumFractionDigits: 10 }), []);
+
+ const leveragedAmount = React.useMemo(() => {
+ if (tradeState === "long") {
+ return actionTxns?.actualDepositAmount;
+ } else {
+ return actionTxns?.borrowAmount.toNumber();
+ }
+ }, [tradeState, actionTxns]);
+
+ const staticActionMethods = React.useMemo(
+ () =>
+ checkTradeActionAvailable({
+ amount,
+ connected,
+ collateralBank: selectedBank,
+ secondaryBank: selectedSecondaryBank,
+ actionQuote: actionTxns.actionQuote,
+ tradeState,
+ }),
+
+ [amount, connected, actionTxns, tradeState, selectedSecondaryBank, selectedBank]
+ );
+
+ const actionMethods = React.useMemo(() => {
+ return staticActionMethods.concat(dynamicActionMessages);
+ }, [staticActionMethods, dynamicActionMessages]);
+
+ const isDisabled = React.useMemo(() => {
+ if (!actionTxns?.actionQuote || !actionTxns?.actionTxn) return true;
+ if (actionMethods.filter((value) => value.isEnabled === false).length) return true;
+ return false;
+ }, [actionMethods, actionTxns]);
+
+ // Effects
+ React.useEffect(() => {
+ if (activePoolExtended) {
+ if (tradeState === "short") {
+ setSelectedBank(activePoolExtended.quoteBank);
+ setSelectedSecondaryBank(activePoolExtended.tokenBank);
+ } else {
+ setSelectedBank(activePoolExtended.tokenBank);
+ setSelectedSecondaryBank(activePoolExtended.quoteBank);
+ }
+ }
+ }, [activePoolExtended, setSelectedBank, setSelectedSecondaryBank, tradeState]);
+
+ React.useEffect(() => {
+ if (errorMessage && errorMessage.description) {
+ if (errorMessage.actionMethod === "ERROR") {
+ showErrorToast(errorMessage?.description);
+ }
+ setDynamicActionMessages([errorMessage]);
+ } else {
+ setDynamicActionMessages([]);
+ }
+ }, [errorMessage]);
+
+ React.useEffect(() => {
+ refreshState();
+ }, [refreshState]);
+
+ React.useEffect(() => {
+ setTradeState(side);
+ }, [setTradeState, side]);
+
+ const { refreshSimulation } = useTradeSimulation({
+ debouncedAmount: debouncedAmount ?? 0,
+ debouncedLeverage: debouncedLeverage ?? 0,
+ selectedBank: selectedBank,
+ selectedSecondaryBank: selectedSecondaryBank,
+ marginfiClient: client,
+ wrappedAccount: wrappedAccount,
+ slippageBps: slippageBps,
+ platformFeeBps: platformFeeBps,
+ isEnabled: !actionMethods.filter((value) => value.isEnabled === false).length,
+ setActionTxns: setActionTxns,
+ setErrorMessage: setErrorMessage,
+ setIsLoading: setIsSimulating,
+ setSimulationResult,
+ setMaxLeverage,
+ });
+
+ const isActiveWithCollat = true; // TODO: figure out what this does?
+
+ const handleAmountChange = React.useCallback(
+ (amountRaw: string) => {
+ const amount = formatAmount(amountRaw, maxAmount, selectedBank ?? null, numberFormater);
+ setAmountRaw(amount);
+ },
+ [maxAmount, selectedBank, numberFormater, setAmountRaw]
+ );
+
+ /////////////////////
+ // Trading Actions //
+ /////////////////////
+ const executeAction = async (
+ params: ExecuteTradeActionProps,
+ leverage: number,
+ activePoolExtended: ArenaPoolV2Extended,
+ callbacks: {
+ captureEvent?: (event: string, properties?: Record) => void;
+ setIsActionComplete: (isComplete: boolean) => void;
+ setPreviousTxn: (previousTxn: PreviousTxn) => void;
+ onComplete?: (txn: PreviousTxn) => void;
+ setIsLoading: (isLoading: boolean) => void;
+ setAmountRaw: (amountRaw: string) => void;
+ retryCallback: (txs: TradeActionTxns, toast: MultiStepToastHandle) => void;
+ }
+ ) => {
+ const action = async (params: ExecuteTradeActionProps) => {
+ await handleExecuteTradeAction({
+ props: params,
+ captureEvent: (event, properties) => {
+ callbacks.captureEvent && callbacks.captureEvent(event, properties);
+ },
+ setIsComplete: (txnSigs) => {
+ const _actionTxns = params.actionTxns as TradeActionTxns;
+ callbacks.setIsActionComplete(true);
+ callbacks.setPreviousTxn({
+ txnType: "TRADING",
+ txn: txnSigs[txnSigs.length - 1] ?? "",
+ tradingOptions: {
+ depositBank: params.depositBank as ActiveBankInfo,
+ borrowBank: params.borrowBank as ActiveBankInfo,
+ initDepositAmount: params.depositAmount.toString(),
+ depositAmount: params.actualDepositAmount,
+ borrowAmount: params.borrowAmount.toNumber(),
+ leverage: leverage,
+ type: params.tradeSide,
+ quote: _actionTxns.actionQuote!,
+ entryPrice: activePoolExtended.tokenBank.info.oraclePrice.priceRealtime.price.toNumber(),
+ },
+ });
+
+ callbacks.onComplete &&
+ callbacks.onComplete({
+ txn: txnSigs[txnSigs.length - 1] ?? "",
+ txnType: "TRADING",
+ tradingOptions: {
+ depositBank: params.depositBank as ActiveBankInfo,
+ borrowBank: params.borrowBank as ActiveBankInfo,
+ initDepositAmount: params.depositAmount.toString(),
+ depositAmount: params.actualDepositAmount,
+ borrowAmount: params.borrowAmount.toNumber(),
+ leverage: leverage,
+ type: params.tradeSide,
+ quote: _actionTxns.actionQuote!,
+ entryPrice: activePoolExtended.tokenBank.info.oraclePrice.priceRealtime.price.toNumber(),
+ },
+ });
+ },
+ setError: (error: IndividualFlowError) => {
+ const toast = error.multiStepToast as MultiStepToastHandle;
+ if (!toast) {
+ return;
+ }
+ const txs = error.actionTxns as TradeActionTxns;
+ let retry = undefined;
+ if (error.retry && toast && txs) {
+ retry = () => callbacks.retryCallback(txs, toast);
+ }
+ toast.setFailed(error.message, retry);
+ callbacks.setIsLoading(false);
+ },
+ setIsLoading: (isLoading) => callbacks.setIsLoading(isLoading),
+ });
+ };
+ await action(params);
+ callbacks.setAmountRaw("");
+ };
+
+ const retryTradeAction = React.useCallback(
+ (params: ExecuteTradeActionProps, leverage: number) => {
+ executeAction(params, leverage, activePoolExtended, {
+ captureEvent: () => {
+ capture("trade_action_retry", {
+ group: activePoolExtended.groupPk.toBase58(),
+ bank: selectedBank?.meta.tokenSymbol,
+ });
+ },
+ setIsActionComplete: setIsActionComplete,
+ setPreviousTxn,
+ onComplete: () => {
+ refreshGroup({
+ connection,
+ wallet,
+ groupPk: activePoolExtended.groupPk,
+ banks: [activePoolExtended.tokenBank.address, activePoolExtended.quoteBank.address],
+ });
+ },
+ setIsLoading: setIsTransactionExecuting,
+ setAmountRaw,
+ retryCallback: (txns: TradeActionTxns, multiStepToast: MultiStepToastHandle) => {
+ retryTradeAction({ ...params, actionTxns: txns, multiStepToast }, leverage);
+ },
+ });
+ },
+ [
+ activePoolExtended,
+ setIsActionComplete,
+ setPreviousTxn,
+ setAmountRaw,
+ selectedBank?.meta.tokenSymbol,
+ refreshGroup,
+ connection,
+ wallet,
+ ]
+ );
+
+ const handleTradeAction = React.useCallback(async () => {
+ if (!client || !selectedBank || !selectedSecondaryBank || !actionTxns) {
+ return;
+ }
+
+ const params: ExecuteTradeActionProps = {
+ marginfiClient: client,
+ actionTxns,
+ processOpts: {
+ ...priorityFees,
+ broadcastType,
+ },
+ txOpts: {},
+
+ marginfiAccount: wrappedAccount,
+ depositAmount: amount,
+ borrowAmount: actionTxns.borrowAmount,
+ actualDepositAmount: actionTxns.actualDepositAmount,
+ depositBank: selectedBank,
+ borrowBank: selectedSecondaryBank,
+ quote: actionTxns.actionQuote!,
+ connection: client.provider.connection,
+ tradeSide: tradeState,
+ };
+
+ executeAction(params, leverage, activePoolExtended, {
+ captureEvent: () => {
+ capture("trade_action_execute", {
+ group: activePoolExtended.groupPk.toBase58(),
+ bank: selectedBank?.meta.tokenSymbol,
+ });
+ },
+ setIsActionComplete: setIsActionComplete,
+ setPreviousTxn,
+ onComplete: () => {
+ refreshGroup({
+ connection,
+ wallet,
+ groupPk: activePoolExtended.groupPk,
+ banks: [activePoolExtended.tokenBank.address, activePoolExtended.quoteBank.address],
+ });
+ },
+ setIsLoading: setIsTransactionExecuting,
+ setAmountRaw,
+ retryCallback: (txns: TradeActionTxns, multiStepToast: MultiStepToastHandle) => {
+ retryTradeAction({ ...params, actionTxns: txns, multiStepToast }, leverage);
+ },
+ });
+ }, [
+ client,
+ selectedBank,
+ selectedSecondaryBank,
+ actionTxns,
+ priorityFees,
+ broadcastType,
+ wrappedAccount,
+ amount,
+ tradeState,
+ leverage,
+ setIsActionComplete,
+ setPreviousTxn,
+ setAmountRaw,
+ refreshGroup,
+ connection,
+ wallet,
+ retryTradeAction,
+ activePoolExtended,
+ ]);
+
+ return (
+
+
+
+
+
+
+
+
+
+ {leveragedAmount > 0 && (
+
+ )}
+ {actionMethods && actionMethods.some((method) => method.description) && (
+
+ )}
+
+
{
+ handleTradeAction();
+ }}
+ buttonLabel={tradeState === "long" ? "Long" : "Short"}
+ tradeState={tradeState}
+ />
+
+
0}
+ isActive={selectedBank && amount > 0 ? true : false}
+ />
+ setSlippageBps(value * 100)}
+ slippageBps={slippageBps / 100}
+ >
+
+ Settings
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/utils/index.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/utils/index.ts
new file mode 100644
index 0000000000..80d5ecd012
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/utils/index.ts
@@ -0,0 +1,4 @@
+export * from "./trade-box.consts";
+export * from "./trade-box.utils";
+export * from "./trade-simulation.utils";
+export * from "./trade-action.utils";
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/utils/trade-action.utils.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/utils/trade-action.utils.ts
new file mode 100644
index 0000000000..fadb2731b5
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/utils/trade-action.utils.ts
@@ -0,0 +1,119 @@
+import { v4 as uuidv4 } from "uuid";
+import {
+ IndividualFlowError,
+ executeTradeAction,
+ ExecuteTradeActionProps,
+ CalculateLoopingProps,
+ ActionMessageType,
+ calculateLoopingParams,
+ TradeActionTxns,
+} from "@mrgnlabs/mrgn-utils";
+
+import { ExecuteActionsCallbackProps } from "~/components/action-box-v2/types";
+import { Keypair, PublicKey } from "@solana/web3.js";
+import {
+ BalanceRaw,
+ MarginfiAccount,
+ MarginfiAccountRaw,
+ MarginfiAccountWrapper,
+ MarginfiClient,
+} from "@mrgnlabs/marginfi-client-v2";
+import BN from "bn.js";
+import { bigNumberToWrappedI80F48, SolanaTransaction, WrappedI80F48 } from "@mrgnlabs/mrgn-common";
+import BigNumber from "bignumber.js";
+
+interface ExecuteTradeActionsProps extends ExecuteActionsCallbackProps {
+ props: ExecuteTradeActionProps;
+}
+
+export const handleExecuteTradeAction = async ({
+ props,
+ captureEvent,
+ setIsLoading,
+ setIsComplete,
+ setError,
+}: ExecuteTradeActionsProps) => {
+ try {
+ setIsLoading(true);
+ const attemptUuid = uuidv4();
+ captureEvent(`user_trade_initiate`, {
+ uuid: attemptUuid,
+ tokenSymbol: props.borrowBank.meta.tokenSymbol,
+ tokenName: props.borrowBank.meta.tokenName,
+ amount: props.depositAmount,
+ priorityFee: props.processOpts?.priorityFeeMicro ?? 0,
+ });
+
+ const txnSig = await executeTradeAction(props);
+
+ setIsLoading(false);
+
+ if (txnSig) {
+ setIsComplete(Array.isArray(txnSig) ? txnSig : [txnSig]);
+ captureEvent(`user_trade`, {
+ uuid: attemptUuid,
+ tokenSymbol: props.borrowBank.meta.tokenSymbol,
+ tokenName: props.borrowBank.meta.tokenName,
+ amount: props.depositAmount,
+ txn: txnSig!,
+ priorityFee: props.processOpts?.priorityFeeMicro ?? 0,
+ });
+ }
+ } catch (error) {
+ setError(error as IndividualFlowError);
+ }
+};
+
+export async function generateTradeTx(props: CalculateLoopingProps): Promise {
+ const hasMarginfiAccount = !!props.marginfiAccount;
+ let accountCreationTx: SolanaTransaction[] = [];
+
+ let finalAccount: MarginfiAccountWrapper | null = props.marginfiAccount;
+
+ if (!hasMarginfiAccount) {
+ // if no marginfi account, we need to create one
+ console.log("Creating new marginfi account transaction...");
+ const authority = props.marginfiAccount?.authority ?? props.marginfiClient.provider.publicKey;
+
+ const marginfiAccountKeypair = Keypair.generate();
+
+ const dummyWrappedI80F48 = bigNumberToWrappedI80F48(new BigNumber(0));
+
+ const dummyBalances: BalanceRaw[] = Array(15).fill({
+ active: false,
+ bankPk: new PublicKey("11111111111111111111111111111111"),
+ assetShares: dummyWrappedI80F48,
+ liabilityShares: dummyWrappedI80F48,
+ emissionsOutstanding: dummyWrappedI80F48,
+ lastUpdate: new BN(0),
+ });
+
+ const rawAccount: MarginfiAccountRaw = {
+ group: props.marginfiClient.group.address,
+ authority: authority,
+ lendingAccount: { balances: dummyBalances },
+ accountFlags: new BN([0, 0, 0]),
+ };
+
+ const account = new MarginfiAccount(marginfiAccountKeypair.publicKey, rawAccount);
+
+ const wrappedAccount = new MarginfiAccountWrapper(marginfiAccountKeypair.publicKey, props.marginfiClient, account);
+
+ finalAccount = wrappedAccount;
+
+ accountCreationTx.push(
+ await props.marginfiClient.createMarginfiAccountTx({ accountKeypair: marginfiAccountKeypair })
+ );
+ }
+ const result = await calculateLoopingParams({ ...props, marginfiAccount: finalAccount });
+
+ if (result && "actionQuote" in result) {
+ return {
+ ...result,
+ additionalTxns: [...accountCreationTx, ...(result.additionalTxns ?? [])],
+ marginfiAccount: finalAccount ?? undefined,
+ };
+ }
+
+ return result;
+}
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/utils/trade-box.consts.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/utils/trade-box.consts.ts
new file mode 100644
index 0000000000..408ff66b01
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/utils/trade-box.consts.ts
@@ -0,0 +1,19 @@
+import { Transaction, VersionedTransaction } from "@solana/web3.js";
+
+import { MarginfiAccountWrapper } from "@mrgnlabs/marginfi-client-v2";
+import { ExtendedBankInfo } from "@mrgnlabs/marginfi-v2-ui-state";
+
+export type TradeSide = "long" | "short";
+
+export enum SimulationStatus {
+ IDLE = "idle",
+ PREPARING = "preparing",
+ SIMULATING = "simulating",
+ COMPLETE = "complete",
+}
+
+export interface SimulateActionProps {
+ txns: (VersionedTransaction | Transaction)[];
+ account: MarginfiAccountWrapper;
+ bank: ExtendedBankInfo;
+}
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/utils/trade-box.utils.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/utils/trade-box.utils.ts
new file mode 100644
index 0000000000..13a6ca6b78
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/utils/trade-box.utils.ts
@@ -0,0 +1,141 @@
+import { QuoteResponse } from "@jup-ag/api";
+import { OperationalState } from "@mrgnlabs/marginfi-client-v2";
+import { ActiveBankInfo } from "@mrgnlabs/marginfi-v2-ui-state";
+import { ActionMessageType, DYNAMIC_SIMULATION_ERRORS, isBankOracleStale } from "@mrgnlabs/mrgn-utils";
+import { ArenaBank } from "~/store/tradeStoreV2";
+
+interface CheckTradeActionAvailableProps {
+ amount: number | null;
+ connected: boolean;
+ collateralBank: ArenaBank | null;
+ secondaryBank: ArenaBank | null;
+ actionQuote: QuoteResponse | null;
+ tradeState: "long" | "short";
+}
+
+export function checkTradeActionAvailable({
+ amount,
+ connected,
+ collateralBank,
+ secondaryBank,
+ actionQuote,
+ tradeState,
+}: CheckTradeActionAvailableProps): ActionMessageType[] {
+ let checks: ActionMessageType[] = [];
+
+ const requiredCheck = getRequiredCheck(connected, collateralBank);
+ if (requiredCheck) return [requiredCheck];
+
+ const generalChecks = getGeneralChecks(amount ?? 0);
+ if (generalChecks) checks.push(...generalChecks);
+
+ const tradeSpecificChecks = getTradeSpecificChecks(tradeState, secondaryBank);
+ if (tradeSpecificChecks) checks.push(...tradeSpecificChecks);
+
+ // allert checks
+ if (collateralBank) {
+ const tradeChecks = canBeTraded(collateralBank, secondaryBank, actionQuote);
+ if (tradeChecks.length) checks.push(...tradeChecks);
+ }
+
+ if (checks.length === 0)
+ checks.push({
+ isEnabled: true,
+ });
+
+ return checks;
+}
+
+function getRequiredCheck(connected: boolean, selectedBank: ArenaBank | null): ActionMessageType | null {
+ if (!connected) {
+ return { isEnabled: false };
+ }
+ if (!selectedBank) {
+ return { isEnabled: false };
+ }
+
+ return null;
+}
+
+function getGeneralChecks(amount: number = 0, showCloseBalance?: boolean): ActionMessageType[] {
+ let checks: ActionMessageType[] = [];
+ if (showCloseBalance) {
+ checks.push({ actionMethod: "INFO", description: "Close lending balance.", isEnabled: true });
+ }
+
+ if (amount === 0) {
+ checks.push({ isEnabled: false });
+ }
+
+ return checks;
+}
+
+function canBeTraded(
+ collateralBank: ArenaBank,
+ secondaryBank: ArenaBank | null,
+ swapQuote: QuoteResponse | null
+): ActionMessageType[] {
+ let checks: ActionMessageType[] = [];
+ const isTargetBankPaused = collateralBank.info.rawBank.config.operationalState === OperationalState.Paused;
+ const isRepayBankPaused = secondaryBank?.info.rawBank.config.operationalState === OperationalState.Paused;
+
+ if (isTargetBankPaused || isRepayBankPaused) {
+ checks.push(
+ DYNAMIC_SIMULATION_ERRORS.BANK_PAUSED_CHECK(
+ isTargetBankPaused ? collateralBank.info.rawBank.tokenSymbol : secondaryBank?.info.rawBank.tokenSymbol
+ )
+ );
+ }
+
+ if (swapQuote && swapQuote?.priceImpactPct && Number(swapQuote.priceImpactPct) > 0.01) {
+ //invert
+ if (swapQuote?.priceImpactPct && Number(swapQuote.priceImpactPct) > 0.05) {
+ checks.push(DYNAMIC_SIMULATION_ERRORS.PRICE_IMPACT_ERROR_CHECK(Number(swapQuote.priceImpactPct)));
+ } else {
+ checks.push(DYNAMIC_SIMULATION_ERRORS.PRICE_IMPACT_WARNING_CHECK(Number(swapQuote.priceImpactPct)));
+ }
+ }
+
+ if (
+ secondaryBank &&
+ secondaryBank?.info.rawBank.config.oracleSetup !== "SwitchboardV2" &&
+ isBankOracleStale(secondaryBank)
+ ) {
+ console.log(
+ `Bank ${secondaryBank.info.rawBank.tokenSymbol} oracle data is stale ⚠️ - timestamp: ${new Date(
+ secondaryBank.info.oraclePrice.timestamp.toNumber() * 1000
+ ).toLocaleString()}`
+ );
+ checks.push(DYNAMIC_SIMULATION_ERRORS.STALE_CHECK("Trading"));
+ }
+
+ if (
+ collateralBank &&
+ collateralBank.info.rawBank.config.oracleSetup !== "SwitchboardV2" &&
+ isBankOracleStale(collateralBank)
+ ) {
+ console.log(
+ `Bank ${collateralBank.info.rawBank.tokenSymbol} oracle data is stale ⚠️ - timestamp: ${new Date(
+ collateralBank.info.oraclePrice.timestamp.toNumber() * 1000
+ ).toLocaleString()}`
+ );
+ checks.push(DYNAMIC_SIMULATION_ERRORS.STALE_CHECK("Trading"));
+ }
+
+ return checks;
+}
+
+function getTradeSpecificChecks(tradeState: "long" | "short", secondaryBank: ArenaBank | null): ActionMessageType[] {
+ let checks: ActionMessageType[] = [];
+
+ if (secondaryBank?.isActive && (secondaryBank as ActiveBankInfo)?.position.isLending) {
+ checks.push({
+ isEnabled: false,
+ description: `You cannot ${tradeState} while you have an active ${
+ tradeState === "long" ? "short" : "long"
+ } position for this token.`,
+ });
+ }
+
+ return checks;
+}
diff --git a/apps/marginfi-v2-trading/src/components/common/trade-box-v2/utils/trade-simulation.utils.ts b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/utils/trade-simulation.utils.ts
new file mode 100644
index 0000000000..05a4c5ed13
--- /dev/null
+++ b/apps/marginfi-v2-trading/src/components/common/trade-box-v2/utils/trade-simulation.utils.ts
@@ -0,0 +1,120 @@
+import { SimulationResult } from "@mrgnlabs/marginfi-client-v2";
+
+import { ActionMessageType, handleSimulationError, TradeActionTxns } from "@mrgnlabs/mrgn-utils";
+
+import { ArenaBank } from "~/store/tradeStoreV2";
+import {
+ ActionPreview,
+ ActionSummary,
+ simulatedCollateral,
+ simulatedHealthFactor,
+ simulatedPositionSize,
+} from "~/components/action-box-v2/utils";
+import { SimulatedActionPreview } from "~/components/action-box-v2/actions/lend-box/utils";
+import { nativeToUi } from "@mrgnlabs/mrgn-common";
+import { AccountSummary } from "@mrgnlabs/marginfi-v2-ui-state";
+import { SimulateActionProps } from "./trade-box.consts";
+
+export const getSimulationResult = async (props: SimulateActionProps) => {
+ let actionMethod: ActionMessageType | undefined = undefined;
+ let simulationResult: SimulationResult | null = null;
+
+ try {
+ simulationResult = await simulateFlashLoan(props);
+ } catch (error: any) {
+ const actionString = "Looping";
+ console.log("error", error);
+ actionMethod = handleSimulationError(error, props.bank, false, actionString);
+ }
+
+ return { simulationResult, actionMethod };
+};
+
+async function simulateFlashLoan({ account, bank, txns }: SimulateActionProps) {
+ let simulationResult: SimulationResult;
+
+ if (txns.length > 0) {
+ // todo: should we not inspect multiple banks?
+ simulationResult = await account.simulateBorrowLendTransaction(txns, [bank.address]);
+ return simulationResult;
+ } else {
+ console.error("Failed to simulate flashloan");
+ throw new Error("Failed to simulate flashloan");
+ }
+}
+
+export function calculateSummary({
+ simulationResult,
+ bank,
+ accountSummary,
+ actionTxns,
+}: {
+ simulationResult?: SimulationResult;
+ bank: ArenaBank;
+ accountSummary: AccountSummary;
+ actionTxns: TradeActionTxns;
+}): ActionSummary {
+ let simulationPreview: SimulatedActionPreview | null = null;
+
+ if (simulationResult) {
+ simulationPreview = calculateSimulatedActionPreview(simulationResult, bank);
+ }
+
+ const actionPreview = calculateActionPreview(bank, accountSummary, actionTxns);
+
+ return {
+ actionPreview,
+ simulationPreview,
+ } as ActionSummary;
+}
+
+export function calculateSimulatedActionPreview(
+ simulationResult: SimulationResult,
+ bank: ArenaBank
+): SimulatedActionPreview {
+ const health = simulatedHealthFactor(simulationResult);
+ const positionAmount = simulatedPositionSize(simulationResult, bank);
+ const availableCollateral = simulatedCollateral(simulationResult);
+
+ const liquidationPrice = simulationResult.marginfiAccount.computeLiquidationPriceForBank(bank.address);
+ const { lendingRate, borrowingRate } = simulationResult.banks.get(bank.address.toBase58())!.computeInterestRates();
+
+ return {
+ health,
+ liquidationPrice,
+ depositRate: lendingRate.toNumber(),
+ borrowRate: borrowingRate.toNumber(),
+ positionAmount,
+ availableCollateral,
+ };
+}
+
+function calculateActionPreview(
+ bank: ArenaBank,
+ accountSummary: AccountSummary,
+ actionTxns: TradeActionTxns
+): ActionPreview {
+ const positionAmount = bank?.isActive ? bank.position.amount : 0;
+ const health = accountSummary.balance && accountSummary.healthFactor ? accountSummary.healthFactor : 1;
+ const liquidationPrice =
+ bank.isActive && bank.position.liquidationPrice && bank.position.liquidationPrice > 0.01
+ ? bank.position.liquidationPrice
+ : null;
+
+ const bankCap = nativeToUi(
+ false ? bank.info.rawBank.config.depositLimit : bank.info.rawBank.config.borrowLimit,
+ bank.info.state.mintDecimals
+ );
+
+ const priceImpactPct = actionTxns.actionQuote?.priceImpactPct;
+ const slippageBps = actionTxns.actionQuote?.slippageBps;
+
+ return {
+ positionAmount,
+ health,
+ liquidationPrice,
+ bankCap,
+ priceImpactPct,
+ slippageBps,
+ } as ActionPreview;
+}
diff --git a/apps/marginfi-v2-trading/src/hooks/useMarginfiClient.tsx b/apps/marginfi-v2-trading/src/hooks/useMarginfiClient.tsx
index 5af4afc98e..144fdb598e 100644
--- a/apps/marginfi-v2-trading/src/hooks/useMarginfiClient.tsx
+++ b/apps/marginfi-v2-trading/src/hooks/useMarginfiClient.tsx
@@ -16,6 +16,7 @@ import React from "react";
import { useConnection } from "~/hooks/use-connection";
import { useTradeStoreV2 } from "~/store";
import { ArenaBank } from "~/store/tradeStoreV2";
+import { BankMetadata } from "@mrgnlabs/mrgn-common";
type UseMarginfiClientProps = {
groupPk: PublicKey;
@@ -34,25 +35,16 @@ export function useMarginfiClient({
programId: defaultConfig.programId,
},
}: UseMarginfiClientProps) {
- const [
- arenaPools,
- banksByBankPk,
- groupsByGroupPk,
- tokenAccountMap,
- lutByGroupPk,
- mintDataByMint,
- bankMetadataCache,
- wallet,
- ] = useTradeStoreV2((state) => [
- state.arenaPools,
- state.banksByBankPk,
- state.groupsByGroupPk,
- state.tokenAccountMap,
- state.lutByGroupPk,
- state.mintDataByMint,
- state.bankMetadataCache,
- state.wallet,
- ]);
+ const [arenaPools, banksByBankPk, groupsByGroupPk, tokenAccountMap, lutByGroupPk, mintDataByMint, wallet] =
+ useTradeStoreV2((state) => [
+ state.arenaPools,
+ state.banksByBankPk,
+ state.groupsByGroupPk,
+ state.tokenAccountMap,
+ state.lutByGroupPk,
+ state.mintDataByMint,
+ state.wallet,
+ ]);
const { connection } = useConnection();
const client = React.useMemo(() => {
@@ -99,6 +91,17 @@ export function useMarginfiClient({
const program = new Program(idl, provider) as any as MarginfiProgram;
+ let bankMetadataByBankPk: Record = {};
+
+ [tokenBank, quoteBank].forEach((bank) => {
+ const bankPk = bank.info.rawBank.address.toBase58();
+ bankMetadataByBankPk[bankPk] = {
+ tokenAddress: bank.info.state.mint.toBase58(),
+ tokenName: bank.meta.tokenName,
+ tokenSymbol: bank.meta.tokenSymbol,
+ };
+ });
+
const client = new MarginfiClient(
{ groupPk, ...clientConfig },
program,
@@ -111,7 +114,7 @@ export function useMarginfiClient({
feedIdMap,
lut,
bankAddresses,
- bankMetadataCache,
+ bankMetadataByBankPk,
clientOptions?.bundleSimRpcEndpoint,
clientOptions?.processTransactionStrategy
);
@@ -132,7 +135,6 @@ export function useMarginfiClient({
clientOptions?.processTransactionStrategy,
wallet,
clientConfig,
- bankMetadataCache,
]);
return client;
diff --git a/apps/marginfi-v2-trading/src/pages/trade/[symbol].tsx b/apps/marginfi-v2-trading/src/pages/trade/[symbol].tsx
index cc5fd0b297..fa58aa2f06 100644
--- a/apps/marginfi-v2-trading/src/pages/trade/[symbol].tsx
+++ b/apps/marginfi-v2-trading/src/pages/trade/[symbol].tsx
@@ -16,6 +16,7 @@ import { Loader } from "~/components/common/Loader";
import { ArenaPoolV2 } from "~/store/tradeStoreV2";
import { GetStaticPaths, GetStaticProps } from "next";
import { StaticArenaProps, getArenaStaticProps } from "~/utils";
+import { TradeBoxV2 } from "~/components/common/trade-box-v2";
export const getStaticPaths: GetStaticPaths = async () => {
return {
@@ -76,18 +77,21 @@ export default function TradeSymbolPage({ initialData }: StaticArenaProps) {
return (
<>
- {(!initialized || !poolsFetched || !activePool) &&
}
- {initialized && poolsFetched && activePool && (
+ {!activePool &&
}
+ {activePool && (
diff --git a/packages/marginfi-client-v2/src/clients/client.ts b/packages/marginfi-client-v2/src/clients/client.ts
index a8276ccec4..8d1ee31a05 100644
--- a/packages/marginfi-client-v2/src/clients/client.ts
+++ b/packages/marginfi-client-v2/src/clients/client.ts
@@ -631,13 +631,7 @@ class MarginfiClient {
const accountKeypair = Keypair.generate();
const newAccountKey = createOpts?.newAccountKey ?? accountKeypair.publicKey;
- const ixs = await this.makeCreateMarginfiAccountIx(newAccountKey);
- const signers = [...ixs.keys];
- // If there was no newAccountKey provided, we need to sign with the ephemeraKeypair we generated.
- if (!createOpts?.newAccountKey) signers.push(accountKeypair);
-
- const tx = new Transaction().add(...ixs.instructions);
- const solanaTx = addTransactionMetadata(tx, { signers, addressLookupTables: this.addressLookupTables });
+ const solanaTx = await this.createMarginfiAccountTx({ accountKeypair });
const sig = await this.processTransaction(solanaTx, processOpts, txOpts);
dbg("Created Marginfi account %s", sig);
@@ -647,6 +641,27 @@ class MarginfiClient {
: MarginfiAccountWrapper.fetch(newAccountKey, this, txOpts?.commitment);
}
+ /**
+ * Create a transaction to initialize a new marginfi account under the authority of the user.
+ *
+ * @param createOpts - Options for creating the account
+ * @param createOpts.newAccountKey - Optional public key to use for the new account. If not provided, a new keypair will be generated.
+ * @returns Transaction that can be used to create a new marginfi account
+ */
+ async createMarginfiAccountTx(createOpts?: { accountKeypair?: Keypair }): Promise
{
+ const accountKeypair = createOpts?.accountKeypair ?? Keypair.generate();
+
+ const ixs = await this.makeCreateMarginfiAccountIx(accountKeypair.publicKey);
+ const signers = [...ixs.keys];
+ // If there was no newAccountKey provided, we need to sign with the ephemeraKeypair we generated.
+ signers.push(accountKeypair);
+
+ const tx = new Transaction().add(...ixs.instructions);
+ const solanaTx = addTransactionMetadata(tx, { signers, addressLookupTables: this.addressLookupTables });
+
+ return solanaTx;
+ }
+
/**
* Create transaction instruction to initialize a new group.
*
diff --git a/packages/mrgn-common/src/utils/formatters.utils.ts b/packages/mrgn-common/src/utils/formatters.utils.ts
index f32a7ad498..9d6f239ac1 100644
--- a/packages/mrgn-common/src/utils/formatters.utils.ts
+++ b/packages/mrgn-common/src/utils/formatters.utils.ts
@@ -31,10 +31,11 @@ const numeralFormatter = (value: number) => {
interface dynamicNumeralFormatterOptions {
minDisplay?: number;
tokenPrice?: number;
+ maxDisplay?: number;
}
export const dynamicNumeralFormatter = (value: number, options: dynamicNumeralFormatterOptions = {}) => {
- const { minDisplay = 0.00001, tokenPrice } = options;
+ const { minDisplay = 0.00001, maxDisplay = 10000, tokenPrice } = options;
if (value === 0) return "0";
@@ -42,12 +43,13 @@ export const dynamicNumeralFormatter = (value: number, options: dynamicNumeralFo
return `<${minDisplay}`;
}
- if (Math.abs(value) > 10000) {
+ if (Math.abs(value) > maxDisplay) {
return numeral(value).format("0,0.[00]a");
}
- if (Math.abs(value) >= 0.01) {
- return numeral(value).format("0,0.[0000]a");
+ if (Math.abs(value) >= minDisplay) {
+ const decimalPlaces = Math.max(0, Math.ceil(-Math.log10(minDisplay)));
+ return numeral(value).format(`0,0.[${"0".repeat(decimalPlaces)}]`);
}
if (tokenPrice) {
diff --git a/packages/mrgn-utils/src/actions/actions.ts b/packages/mrgn-utils/src/actions/actions.ts
index a136ef47cc..7386ccb362 100644
--- a/packages/mrgn-utils/src/actions/actions.ts
+++ b/packages/mrgn-utils/src/actions/actions.ts
@@ -5,7 +5,15 @@ import { FEE_MARGIN, ActionType } from "@mrgnlabs/marginfi-v2-ui-state";
import { TransactionOptions, WSOL_MINT } from "@mrgnlabs/mrgn-common";
import { MultiStepToastHandle, showErrorToast } from "../toasts";
-import { MarginfiActionParams, LstActionParams, ActionTxns, RepayWithCollatProps, LoopingProps } from "./types";
+import {
+ MarginfiActionParams,
+ LstActionParams,
+ ActionTxns,
+ RepayWithCollatProps,
+ LoopingProps,
+ LoopActionTxns,
+ TradeActionTxns,
+} from "./types";
import { WalletContextStateOverride } from "../wallet";
import {
deposit,
@@ -13,6 +21,7 @@ import {
borrow,
withdraw,
looping,
+ trade,
repayWithCollat,
createAccountAndDeposit,
createAccount,
@@ -125,6 +134,22 @@ export async function executeLoopingAction(params: ExecuteLoopingActionProps) {
return txnSig;
}
+export interface ExecuteTradeActionProps extends LoopingProps {
+ marginfiClient: MarginfiClient;
+ actionTxns: TradeActionTxns;
+ processOpts: ProcessTransactionsClientOpts;
+ txOpts: TransactionOptions;
+ tradeSide: "long" | "short";
+}
+
+export async function executeTradeAction(params: ExecuteTradeActionProps) {
+ let txnSig: string[] | undefined;
+
+ txnSig = await trade(params);
+
+ return txnSig;
+}
+
export async function executeLstAction({
actionMode,
marginfiClient,
diff --git a/packages/mrgn-utils/src/actions/flashloans/builders.ts b/packages/mrgn-utils/src/actions/flashloans/builders.ts
index 3357109a52..1bf0643422 100644
--- a/packages/mrgn-utils/src/actions/flashloans/builders.ts
+++ b/packages/mrgn-utils/src/actions/flashloans/builders.ts
@@ -244,7 +244,7 @@ export async function calculateLoopingParams({
let firstQuote;
for (const maxAccounts of maxAccountsArr) {
- const quoteParams = {
+ const quoteParams: QuoteGetRequest = {
amount: borrowAmountNative,
inputMint: loopingProps.borrowBank.info.state.mint.toBase58(), // borrow
outputMint: loopingProps.depositBank.info.state.mint.toBase58(), // deposit
@@ -252,7 +252,7 @@ export async function calculateLoopingParams({
platformFeeBps: platformFeeBps, // platform fee
maxAccounts: maxAccounts,
swapMode: "ExactIn",
- } as QuoteGetRequest;
+ };
try {
const swapQuote = await getSwapQuoteWithRetry(quoteParams);
diff --git a/packages/mrgn-utils/src/actions/individualFlows.ts b/packages/mrgn-utils/src/actions/individualFlows.ts
index 1540f5ec56..79a91102b4 100644
--- a/packages/mrgn-utils/src/actions/individualFlows.ts
+++ b/packages/mrgn-utils/src/actions/individualFlows.ts
@@ -42,6 +42,7 @@ import {
ActionTxns,
RepayWithCollatProps,
IndividualFlowError,
+ TradeActionTxns,
} from "./types";
import { captureSentryException } from "../sentry.utils";
import { loopingBuilder, repayWithCollatBuilder } from "./flashloans";
@@ -51,13 +52,24 @@ import { handleError } from "../errors";
// Local utils functions //
//-----------------------//
-export function getSteps(actionTxns?: ActionTxns) {
- return [
- { label: "Signing transaction" },
- ...(actionTxns?.additionalTxns.map((tx) => ({
- label: MRGN_TX_TYPE_TOAST_MAP[tx.type ?? "CRANK"],
- })) ?? []),
- ];
+export function getSteps(actionTxns?: ActionTxns, broadcastType?: TransactionBroadcastType) {
+ const steps = [];
+
+ steps.push({ label: "Signing transaction" });
+
+ if (actionTxns && typeof actionTxns === "object" && "accountCreationTx" in actionTxns) {
+ steps.push({ label: "Creating marginfi account" });
+
+ if (broadcastType !== "RPC") {
+ steps.push({ label: "Signing transaction" });
+ }
+ }
+
+ actionTxns?.additionalTxns.forEach((tx) => {
+ steps.push({ label: MRGN_TX_TYPE_TOAST_MAP[tx.type ?? "CRANK"] });
+ });
+
+ return steps;
}
export function composeExplorerUrl(signature?: string, broadcastType: TransactionBroadcastType = "RPC") {
@@ -587,6 +599,110 @@ export async function looping({
}
}
+interface TradeFnProps extends LoopingProps {
+ marginfiClient: MarginfiClient;
+ actionTxns: TradeActionTxns;
+ processOpts: ProcessTransactionsClientOpts;
+ txOpts: TransactionOptions;
+ tradeSide: "long" | "short";
+}
+
+export async function trade({
+ marginfiClient,
+ actionTxns,
+ processOpts,
+ txOpts,
+ multiStepToast,
+ ...tradingProps
+}: TradeFnProps) {
+ if (marginfiClient === null) {
+ showErrorToast({ message: "Marginfi client not ready" });
+ return;
+ }
+
+ if (!multiStepToast) {
+ const steps = getSteps(actionTxns);
+
+ multiStepToast = new MultiStepToastHandle("Trading", [
+ ...steps,
+ {
+ label: `${tradingProps.tradeSide === "long" ? "Longing" : "Shorting"} ${dynamicNumeralFormatter(
+ tradingProps.depositAmount
+ )} ${tradingProps.depositBank.meta.tokenSymbol} with ${dynamicNumeralFormatter(
+ tradingProps.borrowAmount.toNumber()
+ )} ${tradingProps.borrowBank.meta.tokenSymbol}`,
+ },
+ ]);
+ multiStepToast.start();
+ } else {
+ multiStepToast.resetAndStart();
+ }
+
+ try {
+ let sigs: string[] = [];
+
+ if (actionTxns?.actionTxn) {
+ const txns: SolanaTransaction[] = [...actionTxns.additionalTxns, actionTxns.actionTxn];
+ if (actionTxns.accountCreationTx) {
+ if (processOpts.broadcastType !== "RPC") {
+ await marginfiClient.processTransaction(actionTxns.accountCreationTx, {
+ ...processOpts,
+ callback: (index, success, sig, stepsToAdvance) =>
+ success &&
+ multiStepToast.setSuccessAndNext(
+ stepsToAdvance,
+ sig,
+ composeExplorerUrl(sig, processOpts?.broadcastType)
+ ),
+ }); // TODO: add sig saving & displaying
+ } else {
+ txns.push(actionTxns.accountCreationTx);
+ }
+ }
+
+ sigs = await marginfiClient.processTransactions(
+ txns,
+ {
+ ...processOpts,
+ callback: (index, success, sig, stepsToAdvance) =>
+ success &&
+ multiStepToast.setSuccessAndNext(stepsToAdvance, sig, composeExplorerUrl(sig, processOpts?.broadcastType)),
+ },
+ txOpts
+ );
+ } else {
+ // TODO fix flashloan builder to use processOpts
+ const { flashloanTx, additionalTxs } = await loopingBuilder({
+ ...tradingProps,
+ });
+ sigs = await marginfiClient.processTransactions([...additionalTxs, flashloanTx], processOpts, txOpts);
+ }
+
+ multiStepToast.setSuccess(
+ sigs[sigs.length - 1],
+ composeExplorerUrl(sigs[sigs.length - 1], processOpts?.broadcastType)
+ );
+ return sigs;
+ } catch (error: any) {
+ console.log(`Error while looping`);
+ console.log(error);
+ if (!(error instanceof ProcessTransactionError || error instanceof SolanaJSONRPCError)) {
+ captureSentryException(error, JSON.stringify(error), {
+ action: "looping",
+ wallet: tradingProps.marginfiAccount?.authority?.toBase58(),
+ bank: tradingProps.borrowBank.meta.tokenSymbol,
+ amount: tradingProps.borrowAmount.toString(),
+ });
+ }
+
+ handleIndividualFlowError({
+ error,
+ actionTxns,
+ multiStepToast,
+ });
+ }
+}
+
interface RepayWithCollatFnProps extends RepayWithCollatProps {
marginfiClient: MarginfiClient;
actionTxns: ActionTxns;
diff --git a/packages/mrgn-utils/src/actions/types.ts b/packages/mrgn-utils/src/actions/types.ts
index 222185ab02..ab82076eeb 100644
--- a/packages/mrgn-utils/src/actions/types.ts
+++ b/packages/mrgn-utils/src/actions/types.ts
@@ -76,6 +76,10 @@ export interface LoopActionTxns extends ActionTxns {
borrowAmount: BigNumber;
}
+export interface TradeActionTxns extends LoopActionTxns {
+ marginfiAccount?: MarginfiAccountWrapper;
+}
+
export interface ClosePositionActionTxns extends ActionTxns {
actionQuote: QuoteResponse | null;
}
diff --git a/packages/mrgn-utils/src/errors.ts b/packages/mrgn-utils/src/errors.ts
index 94f08446b5..c6960884b3 100644
--- a/packages/mrgn-utils/src/errors.ts
+++ b/packages/mrgn-utils/src/errors.ts
@@ -62,7 +62,7 @@ export const STATIC_SIMULATION_ERRORS: { [key: string]: ActionMessageType } = {
actionMethod: "WARNING",
description: "Transaction failed due to poor account health, please increase your collateral and try again.",
code: 108,
- },
+ }, // We should add an action to deposit collateral here, this is quite often being thrown in the arena
USER_REJECTED: {
isEnabled: false,
actionMethod: "WARNING",
diff --git a/packages/mrgn-utils/src/rpc.utils.ts b/packages/mrgn-utils/src/rpc.utils.ts
index 34987669c1..6920f1bc82 100644
--- a/packages/mrgn-utils/src/rpc.utils.ts
+++ b/packages/mrgn-utils/src/rpc.utils.ts
@@ -1,5 +1,5 @@
export function generateEndpoint(endpoint: string, rpcProxyKey: string = "") {
if (!rpcProxyKey) return endpoint;
const hash = Buffer.from(rpcProxyKey, "utf8").toString("base64").replace(/[/+=]/g, "");
- return `${endpoint}/${hash}`;
+ return `${endpoint}`;
}