diff --git a/apps/marginfi-v2-ui/package.json b/apps/marginfi-v2-ui/package.json index 4f42be0b9b..8d70f580a6 100644 --- a/apps/marginfi-v2-ui/package.json +++ b/apps/marginfi-v2-ui/package.json @@ -14,6 +14,8 @@ "@coral-xyz/borsh": "^0.28.0", "@emotion/react": "^11.10.5", "@emotion/styled": "^11.10.5", + "@jup-ag/api": "^6.0.6", + "@jup-ag/react-hook": "^6.0.0-beta.2", "@mrgnlabs/lip-client": "*", "@mrgnlabs/marginfi-client-v2": "*", "@mrgnlabs/marginfi-v2-ui-state": "*", @@ -23,6 +25,8 @@ "@next/bundle-analyzer": "^13.4.19", "@next/font": "13.1.1", "@socialgouv/matomo-next": "^1.4.0", + "@solana/spl-stake-pool": "^0.6.5", + "@solana/spl-token-registry": "^0.2.4574", "@solana/wallet-adapter-base": "^0.9.20", "@solana/wallet-adapter-react": "^0.15.28", "@solana/wallet-adapter-react-ui": "^0.9.27", @@ -34,6 +38,7 @@ "bs58": "^5.0.0", "firebase": "^9.22.1", "firebase-admin": "^11.9.0", + "jsbi": "^4.3.0", "next": "13.4.19", "react": "18.2.0", "react-dom": "18.2.0", diff --git a/apps/marginfi-v2-ui/src/components/AssetsList/AssetRow/AssetRow.tsx b/apps/marginfi-v2-ui/src/components/AssetsList/AssetRow/AssetRow.tsx index 86e918b019..04b9446add 100644 --- a/apps/marginfi-v2-ui/src/components/AssetsList/AssetRow/AssetRow.tsx +++ b/apps/marginfi-v2-ui/src/components/AssetsList/AssetRow/AssetRow.tsx @@ -31,7 +31,7 @@ import { useWalletContext } from "~/components/useWalletContext"; const CLOSE_BALANCE_TOAST_ID = "close-balance"; const BORROW_OR_LEND_TOAST_ID = "borrow-or-lend"; -const EMISSION_MINT_INFO_MAP = new Map([ +export const EMISSION_MINT_INFO_MAP = new Map([ [ "UXD", { diff --git a/apps/marginfi-v2-ui/src/components/CampaignWizard.tsx b/apps/marginfi-v2-ui/src/components/CampaignWizard.tsx index 4faea1b5d5..4c144d4c61 100644 --- a/apps/marginfi-v2-ui/src/components/CampaignWizard.tsx +++ b/apps/marginfi-v2-ui/src/components/CampaignWizard.tsx @@ -79,7 +79,7 @@ const CampaignWizardInputBox: FC = ({ ); }; -interface CampaignWizardProps { } +interface CampaignWizardProps {} const CampaignWizard: FC = () => { const [guaranteedApy, setGuaranteedApy] = useState(0); @@ -271,7 +271,7 @@ const CampaignWizard: FC = () => { setGuaranteedApy(value / 100)} - loadingSafetyCheck={() => { }} + loadingSafetyCheck={() => {}} maxDecimals={2} disabled={!walletContext.connected} /> @@ -282,7 +282,7 @@ const CampaignWizard: FC = () => { { }} + loadingSafetyCheck={() => {}} maxDecimals={4} disabled={!walletContext.connected} /> @@ -293,7 +293,7 @@ const CampaignWizard: FC = () => { { }} + loadingSafetyCheck={() => {}} maxDecimals={3} disabled={!walletContext.connected} /> @@ -343,12 +343,12 @@ const CampaignWizard: FC = () => { > {campaignBank ? percentFormatterDyn.format( - computeGuaranteedApy( - contractInputs.lockupPeriod.toNumber(), - contractInputs.maxDeposits.toNumber(), - contractInputs.maxRewards.toNumber() + computeGuaranteedApy( + contractInputs.lockupPeriod.toNumber(), + contractInputs.maxDeposits.toNumber(), + contractInputs.maxRewards.toNumber() + ) ) - ) : 0} diff --git a/apps/marginfi-v2-ui/src/components/Earn/index.tsx b/apps/marginfi-v2-ui/src/components/Earn/index.tsx index 6d80b532f9..52d43d45f5 100644 --- a/apps/marginfi-v2-ui/src/components/Earn/index.tsx +++ b/apps/marginfi-v2-ui/src/components/Earn/index.tsx @@ -1,5 +1,5 @@ import React, { FC, MouseEventHandler, ReactNode, useCallback, useEffect, useMemo, useState } from "react"; -import { useConnection, useWallet } from "@solana/wallet-adapter-react"; +import { useConnection } from "@solana/wallet-adapter-react"; import { PageHeader } from "~/components/PageHeader"; import { useLipClient } from "~/context"; import Button from "@mui/material/Button"; @@ -210,7 +210,6 @@ const Earn = () => { return ( <> -
diff --git a/apps/marginfi-v2-ui/src/components/Footer/Footer.tsx b/apps/marginfi-v2-ui/src/components/Footer/Footer.tsx index 5dac2f1708..d8a88bb877 100644 --- a/apps/marginfi-v2-ui/src/components/Footer/Footer.tsx +++ b/apps/marginfi-v2-ui/src/components/Footer/Footer.tsx @@ -64,13 +64,19 @@ const HotkeysInfo: FC = () => { }; const LendZoomControl: FC = () => { - const [lendZoomLevel, setLendZoomLevel] = useUserProfileStore((state) => [state.lendZoomLevel, state.setLendZoomLevel]); + const [lendZoomLevel, setLendZoomLevel] = useUserProfileStore((state) => [ + state.lendZoomLevel, + state.setLendZoomLevel, + ]); return (
setLendZoomLevel(1)} viewBox="0 0 17 17"> - + {
setLendZoomLevel(2)} viewBox="0 0 16 16"> - + {
setLendZoomLevel(3)} viewBox="0 0 16 16"> - + { return (
- - Airdrop - + Airdrop
diff --git a/apps/marginfi-v2-ui/src/components/Navbar/Navbar.tsx b/apps/marginfi-v2-ui/src/components/Navbar/Navbar.tsx index c84ad794e3..9ae52533e1 100644 --- a/apps/marginfi-v2-ui/src/components/Navbar/Navbar.tsx +++ b/apps/marginfi-v2-ui/src/components/Navbar/Navbar.tsx @@ -1,4 +1,4 @@ -import { FC, useEffect, useState } from "react"; +import { FC, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import Image from "next/image"; import AirdropZone from "./AirdropZone"; @@ -11,14 +11,20 @@ import { useRouter } from "next/router"; import { HotkeysEvent } from "react-hotkeys-hook/dist/types"; import { Badge } from "@mui/material"; import { useFirebaseAccount } from "../useFirebaseAccount"; -import { groupedNumberFormatterDyn, numeralFormatter } from "@mrgnlabs/mrgn-common"; +import { groupedNumberFormatterDyn, processTransaction } from "@mrgnlabs/mrgn-common"; import { useWalletContext } from "../useWalletContext"; +import { Features, isActive } from "~/utils/featureGates"; +import { EMISSION_MINT_INFO_MAP } from "../AssetsList/AssetRow/AssetRow"; +import { PublicKey, Transaction } from "@solana/web3.js"; +import { useConnection } from "@solana/wallet-adapter-react"; +import { toast } from "react-toastify"; // @todo implement second pretty navbar row const Navbar: FC = () => { useFirebaseAccount(); - const { connected, walletAddress } = useWalletContext(); + const { connection } = useConnection(); + const { connected, walletAddress, wallet } = useWalletContext(); const router = useRouter(); const [accountSummary, selectedAccount, extendedBankInfos] = useMrgnlendStore((state) => [ state.accountSummary, @@ -36,6 +42,16 @@ const Navbar: FC = () => { const [isHotkeyMode, setIsHotkeyMode] = useState(false); const [currentRoute, setCurrentRoute] = useState(router.pathname); + const bankAddressesWithEmissions: PublicKey[] = useMemo(() => { + if (!selectedAccount) return []; + return [...EMISSION_MINT_INFO_MAP.keys()] + .map((bankMintSymbol) => { + const uxdBankInfo = extendedBankInfos?.find((b) => b.isActive && b.meta.tokenSymbol === bankMintSymbol); + return uxdBankInfo?.address; + }) + .filter((address) => address !== undefined) as PublicKey[]; + }, [selectedAccount, extendedBankInfos]); + useEffect(() => { if (!walletAddress) return; fetchPoints(walletAddress.toBase58()).catch(console.error); @@ -143,6 +159,30 @@ const Navbar: FC = () => { + {isActive(Features.STAKE) && ( + + + stake + + + )} + { > swap @@ -180,7 +222,9 @@ const Navbar: FC = () => { > bridge @@ -234,22 +278,31 @@ const Navbar: FC = () => {
{ - if (selectedAccount && extendedBankInfos?.find((b) => b.meta.tokenSymbol === "UXD")?.info.rawBank) { - selectedAccount!.withdrawEmissions( - extendedBankInfos.find((b) => b.meta.tokenSymbol === "UXD")!.address - ); - } - - if (selectedAccount && extendedBankInfos?.find((b) => b.meta.tokenSymbol === "bSOL")?.info.rawBank) { - selectedAccount!.withdrawEmissions( - extendedBankInfos.find((b) => b.meta.tokenSymbol === "bSOL")!.address - ); + className={`whitespace-nowrap hidden md:inline-flex ${ + bankAddressesWithEmissions.length > 0 ? "cursor-pointer hover:text-[#AAA]" : "cursor-not-allowed" + }`} + onClick={async () => { + if (!wallet || !selectedAccount || bankAddressesWithEmissions.length === 0) return; + const tx = new Transaction(); + const ixs = []; + const signers = []; + for (const bankAddress of bankAddressesWithEmissions) { + const ix = await selectedAccount.makeWithdrawEmissionsIx(bankAddress); + ixs.push(...ix.instructions); + signers.push(ix.keys); } + tx.add(...ixs); + await processTransaction(connection, wallet, tx); + toast.success("Withdrawal successful"); }} > withdraw all rewards + {bankAddressesWithEmissions.length > 0 && ( + + + + + )}
= ({ text = "mrgnlend" }) => { +const PageHeader: FC = ({ children }) => { return ( -
-
- {text} -
+
+
{children}
); }; -const PageHeaderSwap: FC = () => { - return ( -
-
- swap - Powered - {/* Different components here by word so spacing can be the same */} - by - Jupiter -
-
- ); -}; - -const PageHeaderBridge: FC = () => { - return ( -
-
- bridge - Powered - {/* Different components here by word so spacing can be the same */} - by - - Mayan - -
-
- ); -}; - -export { PageHeader, PageHeaderSwap, PageHeaderBridge }; +export { PageHeader }; diff --git a/apps/marginfi-v2-ui/src/components/Spinner.tsx b/apps/marginfi-v2-ui/src/components/Spinner.tsx new file mode 100644 index 0000000000..3465d54687 --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/Spinner.tsx @@ -0,0 +1,13 @@ +export const Spinner = () => ( + + +) diff --git a/apps/marginfi-v2-ui/src/components/Staking/StakingCard/PrimaryButton.tsx b/apps/marginfi-v2-ui/src/components/Staking/StakingCard/PrimaryButton.tsx new file mode 100644 index 0000000000..6a52435d21 --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/Staking/StakingCard/PrimaryButton.tsx @@ -0,0 +1,27 @@ +import React, { FC, ReactNode } from "react"; + +// Put this in common folder in the future when all is merged + +interface PrimaryButtonProps { + children?: ReactNode; + loading?: boolean; + disabled?: boolean; + onClick?: () => void; +} + +export const PrimaryButton: FC = ({ children, disabled, loading, onClick }) => ( + +
+ {children} +
+
+); diff --git a/apps/marginfi-v2-ui/src/components/Staking/StakingCard/RefreshIcon.tsx b/apps/marginfi-v2-ui/src/components/Staking/StakingCard/RefreshIcon.tsx new file mode 100644 index 0000000000..a818563900 --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/Staking/StakingCard/RefreshIcon.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +export const RefreshIcon: React.FC> = ({ width = "12", height = "12" }) => { + return ( + + + + + + + + + + + ); +}; diff --git a/apps/marginfi-v2-ui/src/components/Staking/StakingCard/SettingsIcon.tsx b/apps/marginfi-v2-ui/src/components/Staking/StakingCard/SettingsIcon.tsx new file mode 100644 index 0000000000..384c7ab2dd --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/Staking/StakingCard/SettingsIcon.tsx @@ -0,0 +1,15 @@ +import React from "react"; + +export const SettingsIcon: React.FC> = ({ width = "12", height = "12" }) => { + return ( + + + + ); +}; diff --git a/apps/marginfi-v2-ui/src/components/Staking/StakingCard/SettingsModal.tsx b/apps/marginfi-v2-ui/src/components/Staking/StakingCard/SettingsModal.tsx new file mode 100644 index 0000000000..756351f42b --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/Staking/StakingCard/SettingsModal.tsx @@ -0,0 +1,72 @@ +import { Typography, Modal } from "@mui/material"; +import { Dispatch, FC, SetStateAction, useState } from "react"; +import { Close } from "@mui/icons-material"; +import { PrimaryButton } from "./PrimaryButton"; +import { SupportedSlippagePercent } from "~/store/lstStore"; + +interface SettingsModalProps { + isOpen: boolean; + handleClose: () => void; + setSelectedSlippagePercent: (slippage: SupportedSlippagePercent) => void; + selectedSlippagePercent: SupportedSlippagePercent; +} + +const SLIPPAGE_PRESET: SupportedSlippagePercent[] = [0.1, 0.5, 1.0, 5.0]; + +export const SettingsModal: FC = ({ + isOpen, + handleClose, + selectedSlippagePercent: selectedSlippage, + setSelectedSlippagePercent: setSelectedSlippage, +}) => { + const [localSlippage, setLocalSlippage] = useState(selectedSlippage); + + const onSaveSettings = () => { + setSelectedSlippage(localSlippage); + handleClose(); + }; + + return ( + +
+
+ Swap Settings +
+ +
+
+ Slippage Settings +
+ {SLIPPAGE_PRESET.map((slippage, idx) => { + const displayText = Number(slippage) + "%"; + const isHighlighted = localSlippage === slippage; + return ( + { + setLocalSlippage(slippage); + }} + > +
+ {displayText} +
+
+ ); + })} +
+
+ onSaveSettings()}>Save +
+
+
+ ); +}; diff --git a/apps/marginfi-v2-ui/src/components/Staking/StakingCard/StakingCard.tsx b/apps/marginfi-v2-ui/src/components/Staking/StakingCard/StakingCard.tsx new file mode 100644 index 0000000000..234be202e3 --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/Staking/StakingCard/StakingCard.tsx @@ -0,0 +1,716 @@ +import { Dispatch, FC, SetStateAction, useCallback, useEffect, useMemo, useState } from "react"; +import { TextField, Typography, CircularProgress } from "@mui/material"; +import * as solanaStakePool from "@solana/spl-stake-pool"; +import { WalletIcon } from "./WalletIcon"; +import { PrimaryButton } from "./PrimaryButton"; +import { useLstStore } from "~/pages/stake"; +import { useWalletContext } from "~/components/useWalletContext"; +import { + ACCOUNT_SIZE, + TOKEN_PROGRAM_ID, + createAssociatedTokenAccountIdempotentInstruction, + createCloseAccountInstruction, + createInitializeAccountInstruction, + getAssociatedTokenAddressSync, + numeralFormatter, + percentFormatter, +} from "@mrgnlabs/mrgn-common"; +import { ArrowDropDown } from "@mui/icons-material"; +import { StakingModal } from "./StakingModal"; +import Image from "next/image"; +import { NumberFormatValues, NumericFormat } from "react-number-format"; +import { + AddressLookupTableAccount, + Connection, + Keypair, + PublicKey, + SYSVAR_RENT_PUBKEY, + Signer, + SystemProgram, + TransactionInstruction, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import { useConnection } from "@solana/wallet-adapter-react"; +import { SwapMode, useJupiter } from "@jup-ag/react-hook"; +import JSBI from "jsbi"; +import { usePrevious } from "~/utils"; +import { createJupiterApiClient, instanceOfSwapInstructionsResponse, Instruction } from "@jup-ag/api"; +import { toast } from "react-toastify"; +import { SettingsModal } from "./SettingsModal"; +import { SettingsIcon } from "./SettingsIcon"; +import { SOL_MINT, TokenData, TokenDataMap } from "~/store/lstStore"; +import { RefreshIcon } from "./RefreshIcon"; +import { StakePoolProxyProgram } from "~/utils/stakePoolProxy"; +import { Spinner } from "~/components/Spinner"; + +const QUOTE_EXPIRY_MS = 30_000; + +type OngoingAction = "swapping" | "minting"; + +export const StakingCard: FC = () => { + const { connection } = useConnection(); + const { connected, wallet, walletAddress, openWalletSelector } = useWalletContext(); + const [lstData, tokenDataMap, fetchLstState, slippagePct, setSlippagePct, stakePoolProxyProgram] = useLstStore( + (state) => [ + state.lstData, + state.tokenDataMap, + state.fetchLstState, + state.slippagePct, + state.setSlippagePct, + state.stakePoolProxyProgram, + ] + ); + + const jupiterApiClient = createJupiterApiClient(); + + const [ongoingAction, setOngoingAction] = useState(null); + const [refreshingQuotes, setRefreshingQuotes] = useState(false); + const [depositAmount, setDepositAmount] = useState(null); + const [selectedMint, setSelectedMint] = useState(SOL_MINT); + const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); + + const { slippage, slippageBps } = useMemo( + () => ({ slippage: slippagePct / 100, slippageBps: slippagePct * 100 }), + [slippagePct] + ); + + const prevSelectedMint = usePrevious(selectedMint); + useEffect(() => { + if (selectedMint?.toBase58() !== prevSelectedMint?.toBase58()) { + setDepositAmount(0); + } + }, [selectedMint, prevSelectedMint]); + + const prevWalletAddress = usePrevious(walletAddress); + useEffect(() => { + if ((!walletAddress && prevWalletAddress) || (walletAddress && !prevWalletAddress)) { + setDepositAmount(0); + } + }, [walletAddress, prevWalletAddress]); + + const selectedMintInfo: TokenData | null = useMemo(() => { + if (tokenDataMap === null) return null; + return tokenDataMap.get(selectedMint.toString()) ?? null; + }, [tokenDataMap, selectedMint]); + + const rawDepositAmount = useMemo( + () => Math.trunc(Math.pow(10, selectedMintInfo?.decimals ?? 0) * (depositAmount ?? 0)), + [depositAmount, selectedMintInfo] + ); + + const { + quoteResponseMeta, + loading: loadingQuotes, + refresh, + lastRefreshTimestamp, + } = useJupiter({ + amount: selectedMint.equals(SOL_MINT) ? JSBI.BigInt(0) : JSBI.BigInt(rawDepositAmount), // amountIn trick to avoid SOL -> SOL quote calls + inputMint: selectedMint, + outputMint: SOL_MINT, + swapMode: SwapMode.ExactIn, + slippageBps, + debounceTime: 250, // debounce ms time before consecutive refresh calls + }); + + const priceImpactPct: number | null = useMemo(() => { + if (!quoteResponseMeta?.quoteResponse) return null; + return Number(quoteResponseMeta.quoteResponse.priceImpactPct); + }, [quoteResponseMeta?.quoteResponse]); + + const refreshQuoteIfNeeded = useCallback( + (force: boolean = false) => { + const hasExpired = Date.now() - lastRefreshTimestamp > QUOTE_EXPIRY_MS; + if (!selectedMint.equals(SOL_MINT) && (depositAmount ?? 0 > 0) && (hasExpired || force)) { + setRefreshingQuotes(true); + refresh(); + } + }, + [selectedMint, depositAmount, refresh, lastRefreshTimestamp] + ); + + useEffect(() => { + refreshQuoteIfNeeded(); + const id = setInterval(refreshQuoteIfNeeded, 1_000); + return () => clearInterval(id); + }, [refreshQuoteIfNeeded]); + + useEffect(() => { + if (!loadingQuotes) { + setTimeout(() => setRefreshingQuotes(false), 500); + } + }, [loadingQuotes]); + + const lstOutAmount: number | null = useMemo(() => { + if (depositAmount === null) return null; + if (!selectedMint || !lstData?.lstSolValue) return 0; + + if (selectedMint.equals(SOL_MINT)) { + return depositAmount / lstData.lstSolValue; + } else { + if (quoteResponseMeta?.quoteResponse?.outAmount) { + const outAmount = JSBI.toNumber(quoteResponseMeta?.quoteResponse?.outAmount) / 1e9; + return outAmount / lstData.lstSolValue; + } else { + return 0; + } + } + }, [depositAmount, selectedMint, lstData?.lstSolValue, quoteResponseMeta?.quoteResponse?.outAmount]); + + const onChange = useCallback( + (event: NumberFormatValues) => setDepositAmount(event.floatValue ?? null), + [setDepositAmount] + ); + + const onMint = useCallback(async () => { + if (!lstData || !wallet || !walletAddress || !depositAmount || !stakePoolProxyProgram) return; + console.log("depositing", depositAmount, selectedMint); + + let sigs = []; + + const { + value: { blockhash, lastValidBlockHeight }, + } = await connection.getLatestBlockhashAndContext(); + + try { + if (selectedMint.equals(SOL_MINT)) { + setOngoingAction("minting"); + const _depositAmount = depositAmount * 1e9; + + const { instructions, signers } = await makeDepositSolToStakePoolIx( + lstData.accountData, + lstData.poolAddress, + walletAddress, + _depositAmount, + undefined + ); + + const depositMessage = new TransactionMessage({ + instructions: instructions, + payerKey: walletAddress, + recentBlockhash: blockhash, + }); + + const depositTransaction = new VersionedTransaction(depositMessage.compileToV0Message([])); + depositTransaction.sign(signers); + + const signedTransaction = await wallet.signTransaction(depositTransaction); + const depositSig = await connection.sendTransaction(signedTransaction); + + sigs.push(depositSig); + } else { + setOngoingAction("swapping"); + const quote = quoteResponseMeta?.original; + if (!quote) { + throw new Error("Route not calculated yet"); + } + + const destinationTokenAccountKeypair = Keypair.generate(); + + const minimumRentExemptionForTokenAccount = await connection.getMinimumBalanceForRentExemption(ACCOUNT_SIZE); + const createWSolAccountIx = SystemProgram.createAccount({ + fromPubkey: walletAddress, + newAccountPubkey: destinationTokenAccountKeypair.publicKey, + lamports: minimumRentExemptionForTokenAccount, + space: ACCOUNT_SIZE, + programId: TOKEN_PROGRAM_ID, + }); + const initWSolAccountIx = createInitializeAccountInstruction( + destinationTokenAccountKeypair.publicKey, + SOL_MINT, + walletAddress + ); + + // Craft swap tx + const swapInstructionsResult = await jupiterApiClient.swapInstructionsPost({ + swapRequest: { + quoteResponse: quote, + userPublicKey: walletAddress.toBase58(), + wrapAndUnwrapSol: false, + destinationTokenAccount: destinationTokenAccountKeypair.publicKey.toBase58(), + }, + }); + if (!instanceOfSwapInstructionsResponse(swapInstructionsResult)) throw new Error("Invalid swap response"); + + const addressLookupTableAccounts: AddressLookupTableAccount[] = []; + + const swapInstructions = [ + ...swapInstructionsResult.computeBudgetInstructions.map(jupIxToSolanaIx), + createWSolAccountIx, + initWSolAccountIx, + ...swapInstructionsResult.setupInstructions.map(jupIxToSolanaIx), + jupIxToSolanaIx(swapInstructionsResult.swapInstruction), + ]; + if (swapInstructionsResult.cleanupInstruction) { + swapInstructions.push(jupIxToSolanaIx(swapInstructionsResult.cleanupInstruction)); + } + + addressLookupTableAccounts.push( + ...(await getAdressLookupTableAccounts(connection, swapInstructionsResult.addressLookupTableAddresses)) + ); + + const swapTransactionMessage = new TransactionMessage({ + instructions: swapInstructions, + payerKey: walletAddress, + recentBlockhash: blockhash, + }).compileToV0Message(addressLookupTableAccounts); + const swapTransaction = new VersionedTransaction(swapTransactionMessage); + swapTransaction.sign([destinationTokenAccountKeypair]); + + // Craft stake pool deposit tx + const { instructions, signers } = await makeDepositWSolToStakePoolIx( + lstData.accountData, + lstData.poolAddress, + wallet.publicKey, + destinationTokenAccountKeypair.publicKey, + stakePoolProxyProgram, + minimumRentExemptionForTokenAccount + ); + const depositMessage = new TransactionMessage({ + instructions: instructions, + payerKey: walletAddress, + recentBlockhash: blockhash, + }); + const depositTransaction = new VersionedTransaction(depositMessage.compileToV0Message([])); + depositTransaction.sign(signers); + + // Send txs + const signedSwapTransaction = await wallet.signTransaction(swapTransaction); + const swapSig = await connection.sendTransaction(signedSwapTransaction); + await connection.confirmTransaction( + { + blockhash, + lastValidBlockHeight, + signature: swapSig, + }, + "confirmed" + ); // TODO: explicitly warn if second tx fails + + setOngoingAction("minting"); + + const signedDepositTransaction = await wallet.signTransaction(depositTransaction); + const depositSig = await connection.sendTransaction(signedDepositTransaction); + await connection.confirmTransaction( + { + blockhash, + lastValidBlockHeight, + signature: depositSig, + }, + "confirmed" + ); + } + + toast.success("Minting complete"); + } catch (error: any) { + if (error.logs) { + console.log("------ Logs 👇 ------"); + console.log(error.logs.join("\n")); + } + console.log(error); + let errorMsg = typeof error === "string" ? error : error?.message; + if (errorMsg) { + errorMsg = errorMsg ? errorMsg : "Transaction failed!"; + } + toast.error(errorMsg); + } finally { + await Promise.all([refresh(), fetchLstState()]); + setDepositAmount(0); + setOngoingAction(null); + } + }, [ + lstData, + wallet, + walletAddress, + depositAmount, + stakePoolProxyProgram, + selectedMint, + connection, + quoteResponseMeta?.original, + jupiterApiClient, + refresh, + fetchLstState, + ]); + + return ( + <> +
+
+
+ Deposit + {selectedMintInfo && ( +
+ {selectedMintInfo.symbol !== "SOL" && ( + <> +
refreshQuoteIfNeeded(true)} + > + +
+
setIsSettingsModalOpen(true)} + > + + + {isNaN(slippagePct) ? "0" : slippagePct}% + +
+ + )} +
+ )} +
+ + {connected && selectedMintInfo && ( +
+
+ +
+ + {selectedMintInfo.balance + ? selectedMintInfo.balance < 0.01 + ? "< 0.01" + : numeralFormatter(selectedMintInfo.balance) + : "-"} + + setDepositAmount(selectedMintInfo.balance)} + > + MAX + +
+ )} +
+ + { + if (e.key === "Enter" && connected && depositAmount !== 0 && !refreshingQuotes && !ongoingAction) { + onMint(); + } + }} + thousandSeparator="," + customInput={TextField} + size="small" + isAllowed={(values) => { + const { floatValue } = values; + if (!connected || selectedMintInfo === null) { + return true; + } + if (selectedMintInfo.balance === 0) { + return false; + } + return floatValue ? floatValue < selectedMintInfo.balance : true; + }} + sx={{ + input: { textAlign: "right", MozAppearance: "textfield" }, + "input::-webkit-inner-spin-button": { WebkitAppearance: "none", margin: 0 }, + "& .MuiOutlinedInput-root": { + "&.Mui-focused fieldset": { + borderWidth: "0px", + }, + }, + }} + className="bg-[#0F1111] p-2 rounded-xl" + InputProps={ + tokenDataMap + ? { + className: "font-aeonik text-[#e1e1e1] p-0 m-0", + startAdornment: ( + + ), + } + : {} + } + /> + +
+ You will receive + + {lstOutAmount !== null && selectedMintInfo + ? lstOutAmount < 0.01 && lstOutAmount > 0 + ? "< 0.01" + : numeralFormatter(lstOutAmount) + : "-"}{" "} + $LST + +
+
+ + {!connected ? ( + "connect" + ) : ongoingAction ? ( + `${ongoingAction}...` + ) : refreshingQuotes ? ( + + ) : ( + "mint" + )} + +
+
+ Current price + + 1 $LST = {lstData ? makeTokenAmountFormatter(3).format(lstData.lstSolValue) : "-"} SOL + +
+
+ Deposit fee + {lstData?.solDepositFee ?? 0}% +
+ {priceImpactPct !== null && ( +
0.1 ? "text-[#FF6B6B]" : priceImpactPct > 0.02 ? "text-[#FFB06B]" : "text-[#fff]" + }`} + > + Price impact + + {priceImpactPct < 0.01 ? "< 0.01%" : `~ ${percentFormatter.format(priceImpactPct)}`} + +
+ )} +
+ setIsSettingsModalOpen(false)} + selectedSlippagePercent={slippagePct} + setSelectedSlippagePercent={setSlippagePct} + /> + + ); +}; + +interface DropDownButtonProps { + tokenDataMap: TokenDataMap; + selectedMintInfo: TokenData | null; + setSelectedMint: Dispatch>; + disabled?: boolean; +} + +const DropDownButton: FC = ({ tokenDataMap, selectedMintInfo, setSelectedMint, disabled }) => { + const [isModalOpen, setIsModalOpen] = useState(false); + return ( + <> +
setIsModalOpen(true)} + className={`flex flex-row justify-between items-center py-2 px-3 text-white bg-[#303030] rounded-lg ${ + disabled ? "opacity-50" : "cursor-pointer hover:bg-[#2D2D2D]" + }`} + > +
+ token logo +
+ {selectedMintInfo?.symbol ?? "SOL"} + +
+ + setIsModalOpen(false)} + tokenDataMap={tokenDataMap} + setSelectedMint={setSelectedMint} + /> + + ); +}; + +export function makeTokenAmountFormatter(decimals: number) { + return new Intl.NumberFormat("en-US", { + useGrouping: true, + minimumFractionDigits: 0, + maximumFractionDigits: decimals, + }); +} + +/** + * Creates instructions required to deposit sol to stake pool. + */ +async function makeDepositSolToStakePoolIx( + stakePool: solanaStakePool.StakePool, + stakePoolAddress: PublicKey, + from: PublicKey, + lamports: number, + destinationTokenAccount?: PublicKey, + referrerTokenAccount?: PublicKey, + depositAuthority?: PublicKey +) { + // Ephemeral SOL account just to do the transfer + const userSolTransfer = new Keypair(); + const signers: Signer[] = [userSolTransfer]; + const instructions: TransactionInstruction[] = []; + + // Create the ephemeral SOL account + instructions.push( + SystemProgram.transfer({ + fromPubkey: from, + toPubkey: userSolTransfer.publicKey, + lamports, + }) + ); + + // Create token account if not specified + if (!destinationTokenAccount) { + const associatedAddress = getAssociatedTokenAddressSync(stakePool.poolMint, from); + instructions.push( + createAssociatedTokenAccountIdempotentInstruction(from, associatedAddress, from, stakePool.poolMint) + ); + destinationTokenAccount = associatedAddress; + } + + const withdrawAuthority = findWithdrawAuthorityProgramAddress( + solanaStakePool.STAKE_POOL_PROGRAM_ID, + stakePoolAddress + ); + + instructions.push( + solanaStakePool.StakePoolInstruction.depositSol({ + stakePool: stakePoolAddress, + reserveStake: stakePool.reserveStake, + fundingAccount: userSolTransfer.publicKey, + destinationPoolAccount: destinationTokenAccount, + managerFeeAccount: stakePool.managerFeeAccount, + referralPoolAccount: referrerTokenAccount ?? destinationTokenAccount, + poolMint: stakePool.poolMint, + lamports, + withdrawAuthority, + depositAuthority, + }) + ); + + return { + instructions, + signers, + }; +} + +/** + * Creates instructions required to deposit the whole balance of a wsol account to stake pool. + */ +async function makeDepositWSolToStakePoolIx( + stakePool: solanaStakePool.StakePool, + stakePoolAddress: PublicKey, + from: PublicKey, + sourceSolTokenAccount: PublicKey, + stakePoolProxyProgram: StakePoolProxyProgram, + minimumRentExemptionForTokenAccount: number +) { + // Ephemeral SOL account just to receive the wSOL conversion + const userSolTransfer = new Keypair(); + const signers: Signer[] = [userSolTransfer]; + const instructions: TransactionInstruction[] = []; + + // Create the ephemeral SOL account + instructions.push(createCloseAccountInstruction(sourceSolTokenAccount, userSolTransfer.publicKey, from)); + instructions.push( + SystemProgram.transfer({ + fromPubkey: userSolTransfer.publicKey, + toPubkey: from, + lamports: minimumRentExemptionForTokenAccount, + }) + ); + // Create token account + const associatedAddress = getAssociatedTokenAddressSync(stakePool.poolMint, from); + instructions.push( + createAssociatedTokenAccountIdempotentInstruction(from, associatedAddress, from, stakePool.poolMint) + ); + const destinationLstTokenAccount = associatedAddress; + + const withdrawAuthority = findWithdrawAuthorityProgramAddress( + solanaStakePool.STAKE_POOL_PROGRAM_ID, + stakePoolAddress + ); + instructions.push( + await stakePoolProxyProgram.methods + .depositAllSol() + .accountsStrict({ + stakePool: stakePoolAddress, + reserveStakeAccount: stakePool.reserveStake, + lamportsFrom: userSolTransfer.publicKey, + poolTokensTo: destinationLstTokenAccount, + managerFeeAccount: stakePool.managerFeeAccount, + referrerPoolTokensAccount: destinationLstTokenAccount, + poolMint: stakePool.poolMint, + stakePoolWithdrawAuthority: withdrawAuthority, + stakePoolProgram: solanaStakePool.STAKE_POOL_PROGRAM_ID, + rent: SYSVAR_RENT_PUBKEY, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + }) + .instruction() + ); + + return { + instructions, + signers, + }; +} + +/** + * Generates the withdraw authority program address for the stake pool + */ +function findWithdrawAuthorityProgramAddress(programId: PublicKey, stakePoolAddress: PublicKey) { + const [publicKey] = PublicKey.findProgramAddressSync( + [stakePoolAddress.toBuffer(), Buffer.from("withdraw")], + programId + ); + return publicKey; +} + +function jupIxToSolanaIx(ix: Instruction): TransactionInstruction { + return new TransactionInstruction({ + programId: new PublicKey(ix.programId), + keys: ix.accounts.map((acc) => { + return { + pubkey: new PublicKey(acc.pubkey), + isSigner: acc.isSigner, + isWritable: acc.isWritable, + }; + }), + data: Buffer.from(ix.data, "base64"), + }); +} + +export const getAdressLookupTableAccounts = async ( + connection: Connection, + keys: string[] +): Promise => { + const addressLookupTableAccountInfos = await connection.getMultipleAccountsInfo( + keys.map((key) => new PublicKey(key)) + ); + + return addressLookupTableAccountInfos.reduce((acc, accountInfo, index) => { + const addressLookupTableAddress = keys[index]; + if (accountInfo) { + const addressLookupTableAccount = new AddressLookupTableAccount({ + key: new PublicKey(addressLookupTableAddress), + state: AddressLookupTableAccount.deserialize(accountInfo.data), + }); + acc.push(addressLookupTableAccount); + } + + return acc; + }, new Array()); +}; diff --git a/apps/marginfi-v2-ui/src/components/Staking/StakingCard/StakingModal.tsx b/apps/marginfi-v2-ui/src/components/Staking/StakingCard/StakingModal.tsx new file mode 100644 index 0000000000..1a2c9aac64 --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/Staking/StakingCard/StakingModal.tsx @@ -0,0 +1,68 @@ +import { Typography, Modal } from "@mui/material"; +import { Dispatch, FC, SetStateAction } from "react"; +import { Close } from "@mui/icons-material"; +import Image from "next/image"; +import { PublicKey } from "@solana/web3.js"; +import { numeralFormatter } from "@mrgnlabs/mrgn-common"; +import { TokenDataMap } from "~/store/lstStore"; + +interface StakingModalProps { + isOpen: boolean; + handleClose: () => void; + setSelectedMint: Dispatch>; + tokenDataMap: TokenDataMap; +} + +export const StakingModal: FC = ({ isOpen, handleClose, tokenDataMap, setSelectedMint }) => { + return ( + +
+
+ Select token +
+ +
+
+
+ {[...tokenDataMap.values()] + .sort((a, b) => Number(b.balance) * Number(b.price) - Number(a.balance) * Number(a.price)) + .map((token) => { + const usdValue = token.balance * token.price; + return ( + { + setSelectedMint(new PublicKey(token.address)); + handleClose(); + }} + > +
+
+ token logo + {token.symbol} +
+
+ {token.balance > 0 && ( + <> + {token.balance < 0.01 ? "< 0.01" : numeralFormatter(token.balance)} + + ({usdValue < 0.01 ? "< $0.01" : `$${numeralFormatter(usdValue)}`}) + + + )} +
+
+
+ ); + })} +
+
+
+ ); +}; diff --git a/apps/marginfi-v2-ui/src/components/Staking/StakingCard/WalletIcon.tsx b/apps/marginfi-v2-ui/src/components/Staking/StakingCard/WalletIcon.tsx new file mode 100644 index 0000000000..89dbdfb059 --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/Staking/StakingCard/WalletIcon.tsx @@ -0,0 +1,12 @@ +import * as React from "react"; + +export const WalletIcon: React.FC> = ({ width = 16, height = 17 }) => { + return ( + + + + ); +}; diff --git a/apps/marginfi-v2-ui/src/components/Staking/StakingCard/index.ts b/apps/marginfi-v2-ui/src/components/Staking/StakingCard/index.ts new file mode 100644 index 0000000000..a3b0a8ba48 --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/Staking/StakingCard/index.ts @@ -0,0 +1 @@ +export * from "./StakingCard"; diff --git a/apps/marginfi-v2-ui/src/components/Staking/StakingStats.tsx b/apps/marginfi-v2-ui/src/components/Staking/StakingStats.tsx new file mode 100644 index 0000000000..1d94f8e63f --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/Staking/StakingStats.tsx @@ -0,0 +1,46 @@ +import { FC } from "react"; +import { Typography } from "@mui/material"; +import { numeralFormatter, percentFormatterDyn } from "@mrgnlabs/mrgn-common"; +import { useLstStore } from "~/pages/stake"; + +export const StakingStats: FC = () => { + const [lstData, solUsdValue] = useLstStore((state) => [state.lstData, state.solUsdValue]); + + return ( +
+
+
+ + TVL + + + {lstData && solUsdValue ? `$${numeralFormatter(lstData.tvl * lstData.lstSolValue * solUsdValue)}` : "-"} + +
+ + + +
+ + Projected APY + + + {lstData ? percentFormatterDyn.format(lstData.projectedApy) : "-"} + +
+
+
+ ); +}; + +const DividerLine = () =>
; diff --git a/apps/marginfi-v2-ui/src/components/Staking/index.ts b/apps/marginfi-v2-ui/src/components/Staking/index.ts new file mode 100644 index 0000000000..09918a5852 --- /dev/null +++ b/apps/marginfi-v2-ui/src/components/Staking/index.ts @@ -0,0 +1 @@ +export * from "./StakingStats"; diff --git a/apps/marginfi-v2-ui/src/middleware.ts b/apps/marginfi-v2-ui/src/middleware.ts index 7f9f563b4b..31a4915b17 100644 --- a/apps/marginfi-v2-ui/src/middleware.ts +++ b/apps/marginfi-v2-ui/src/middleware.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; export const config = { - matcher: ["/", "/index"], + matcher: ["/", "/index", "/stake", "/swap", "/bridge", "/earn", "/points"], }; export function middleware(req: NextRequest) { diff --git a/apps/marginfi-v2-ui/src/pages/bridge.tsx b/apps/marginfi-v2-ui/src/pages/bridge.tsx index 3614d52579..5561624fd7 100644 --- a/apps/marginfi-v2-ui/src/pages/bridge.tsx +++ b/apps/marginfi-v2-ui/src/pages/bridge.tsx @@ -5,7 +5,7 @@ import config from "~/config"; import Script from "next/script"; import { toast } from "react-toastify"; import { useHotkeys } from "react-hotkeys-hook"; -import { PageHeaderBridge } from "~/components/PageHeader"; +import { PageHeader } from "~/components/PageHeader"; import { MayanWidgetColors, MayanWidgetConfigType } from "~/types"; import { useUserProfileStore } from "~/store"; import { useWalletContext } from "~/components/useWalletContext"; @@ -166,7 +166,19 @@ const BridgePage = () => { return ( <> - + +
+ bridge +
+ Powered + {/* Different components here by word so spacing can be the same */} + by + + Mayan + +
+
+
Zero fees. Always. diff --git a/apps/marginfi-v2-ui/src/pages/earn.tsx b/apps/marginfi-v2-ui/src/pages/earn.tsx index a5ed12e3fb..bd9979408d 100644 --- a/apps/marginfi-v2-ui/src/pages/earn.tsx +++ b/apps/marginfi-v2-ui/src/pages/earn.tsx @@ -1,5 +1,6 @@ import dynamic from "next/dynamic"; import React from "react"; +import { PageHeader } from "~/components/PageHeader"; import { LipClientProvider } from "~/context"; const Earn = dynamic(async () => (await import("~/components/Earn")).Earn, { ssr: false }); @@ -7,6 +8,7 @@ const Earn = dynamic(async () => (await import("~/components/Earn")).Earn, { ssr const EarnPage = () => { return ( + earn ); diff --git a/apps/marginfi-v2-ui/src/pages/index.tsx b/apps/marginfi-v2-ui/src/pages/index.tsx index ff607d8341..a51f2509d3 100644 --- a/apps/marginfi-v2-ui/src/pages/index.tsx +++ b/apps/marginfi-v2-ui/src/pages/index.tsx @@ -59,13 +59,13 @@ const Home = () => { return ( <> - + lend
{walletAddress && selectedAccount && isOverride && ( )} diff --git a/apps/marginfi-v2-ui/src/pages/lip.tsx b/apps/marginfi-v2-ui/src/pages/lip.tsx index 85dd283c6a..688724a894 100644 --- a/apps/marginfi-v2-ui/src/pages/lip.tsx +++ b/apps/marginfi-v2-ui/src/pages/lip.tsx @@ -8,7 +8,7 @@ const LIP = () => { return ( - + lip {connected && } ); diff --git a/apps/marginfi-v2-ui/src/pages/points.tsx b/apps/marginfi-v2-ui/src/pages/points.tsx index 0e2f587ad7..a3d6ef0839 100644 --- a/apps/marginfi-v2-ui/src/pages/points.tsx +++ b/apps/marginfi-v2-ui/src/pages/points.tsx @@ -65,7 +65,7 @@ const Points: FC = () => { return ( <> - + points
{!connected ? ( diff --git a/apps/marginfi-v2-ui/src/pages/stake.tsx b/apps/marginfi-v2-ui/src/pages/stake.tsx new file mode 100644 index 0000000000..d03273452d --- /dev/null +++ b/apps/marginfi-v2-ui/src/pages/stake.tsx @@ -0,0 +1,77 @@ +import { JupiterProvider } from "@jup-ag/react-hook"; +import { createJupiterStore } from "@mrgnlabs/marginfi-v2-ui-state"; +import { useConnection } from "@solana/wallet-adapter-react"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; +import { PageHeader } from "~/components/PageHeader"; +import { StakingStats } from "~/components/Staking"; +import { StakingCard } from "~/components/Staking/StakingCard/StakingCard"; +import { useWalletContext } from "~/components/useWalletContext"; +import { createLstStore } from "~/store/lstStore"; +import { usePrevious } from "~/utils"; +import { Features, isActive } from "~/utils/featureGates"; + +export const useLstStore = createLstStore(); +export const useJupiterStore = createJupiterStore(); + +const StakePage = () => { + const { wallet, walletAddress } = useWalletContext(); + const { connection } = useConnection(); + const [mounted, setMounted] = useState(false); + + const router = useRouter(); + + useEffect(() => { + if (router.pathname.startsWith('/stake') && !isActive(Features.STAKE)) { + router.push('/'); + } else { + setMounted(true); + } + }, [router]); + + const [fetchLstState, setIsRefreshingStore, userDataFetched, resetUserData] = useLstStore((state) => [ + state.fetchLstState, + state.setIsRefreshingStore, + state.userDataFetched, + state.resetUserData, + ]); + + useEffect(() => { + setIsRefreshingStore(true); + fetchLstState({ connection, wallet }).catch(console.error); + const id = setInterval(() => { + setIsRefreshingStore(true); + fetchLstState().catch(console.error); + }, 30_000); + return () => clearInterval(id); + }, [wallet]); // eslint-disable-line react-hooks/exhaustive-deps + // ^ crucial to omit both `connection` and `fetchMrgnlendState` from the dependency array + // TODO: fix... + + const prevWalletAddress = usePrevious(walletAddress); + useEffect(() => { + if (!prevWalletAddress && walletAddress) { + resetUserData(); + } + }, [walletAddress, prevWalletAddress, resetUserData]); + + useEffect(() => { + if (!walletAddress && userDataFetched) { + resetUserData(); + } + }, [walletAddress, userDataFetched, resetUserData]); + + if (!mounted) return null; + + return ( + + stake +
+ + +
+
+ ); +}; + +export default StakePage; diff --git a/apps/marginfi-v2-ui/src/pages/swap.tsx b/apps/marginfi-v2-ui/src/pages/swap.tsx index 3ac0f5ee7a..86b5fdb377 100644 --- a/apps/marginfi-v2-ui/src/pages/swap.tsx +++ b/apps/marginfi-v2-ui/src/pages/swap.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; import config from "~/config"; -import { PageHeaderSwap } from "~/components/PageHeader"; +import { PageHeader } from "~/components/PageHeader"; import { useWalletContext } from "~/components/useWalletContext"; const SwapPage = () => { @@ -21,7 +21,19 @@ const SwapPage = () => { return ( <> - + +
+ swap +
+ Powered + {/* Different components here by word so spacing can be the same */} + by + + Jupiter + +
+
+
diff --git a/apps/marginfi-v2-ui/src/pages/terms/points.tsx b/apps/marginfi-v2-ui/src/pages/terms/points.tsx index 4d80151738..5b6d9d0c14 100644 --- a/apps/marginfi-v2-ui/src/pages/terms/points.tsx +++ b/apps/marginfi-v2-ui/src/pages/terms/points.tsx @@ -4,7 +4,7 @@ import { PageHeader } from "~/components/PageHeader"; function Disclaimer() { return ( <> - + terms: points

Last Updated: June 2023

diff --git a/apps/marginfi-v2-ui/src/store.ts b/apps/marginfi-v2-ui/src/store/index.ts similarity index 100% rename from apps/marginfi-v2-ui/src/store.ts rename to apps/marginfi-v2-ui/src/store/index.ts diff --git a/apps/marginfi-v2-ui/src/store/lstStore.ts b/apps/marginfi-v2-ui/src/store/lstStore.ts new file mode 100644 index 0000000000..0a011d0944 --- /dev/null +++ b/apps/marginfi-v2-ui/src/store/lstStore.ts @@ -0,0 +1,289 @@ +import { AnchorProvider } from "@coral-xyz/anchor"; +import { vendor } from "@mrgnlabs/marginfi-client-v2"; +import { ACCOUNT_SIZE, TOKEN_PROGRAM_ID, Wallet, aprToApy } from "@mrgnlabs/mrgn-common"; +import { Connection, PublicKey } from "@solana/web3.js"; +import { create, StateCreator } from "zustand"; +import * as solanaStakePool from "@solana/spl-stake-pool"; +import { EPOCHS_PER_YEAR } from "~/utils"; +import { TokenInfo, TokenInfoMap, TokenListContainer } from "@solana/spl-token-registry"; +import { TokenAccount, TokenAccountMap, fetchBirdeyePrices } from "@mrgnlabs/marginfi-v2-ui-state"; +import { persist } from "zustand/middleware"; +import { StakePoolProxyProgram, getStakePoolProxyProgram } from "~/utils/stakePoolProxy"; + +export const SOL_MINT = new PublicKey("So11111111111111111111111111111111111111112"); +const NETWORK_FEE_LAMPORTS = 15000; // network fee + some for potential account creation +const SOL_USD_PYTH_ORACLE = new PublicKey("H6ARHf6YXhGYeQfUzQNGk6rDNnLBQKrenN712K4AQJEG"); +const STAKE_POOL_ID = new PublicKey("DqhH94PjkZsjAqEze2BEkWhFQJ6EyU6MdtMphMgnXqeK"); + +const SUPPORTED_TOKENS = [ + "7kbnvuGBxxj8AG9qp8Scn56muWGaRaFqxg1FsRp3PaFT", + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn", + "mSoLzYCxHdYgdzU16g5QSh3i5K3z3KZK7ytfqcJm7So", + "bSo13r4TkiE4KumL71LsHTPpL2euBYLFx6h9HP3piy1", + "So11111111111111111111111111111111111111112", + "7dHbWXmci3dT8UFYWYZweBLXgycu7Y3iL6trKn1Y7ARj", + "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", +]; + +export type TokenData = Omit & { price: number; balance: number; iconUrl: string }; +export type TokenDataMap = Map; + +export type SupportedSlippagePercent = 0.1 | 0.5 | 1.0 | 5.0; + +interface LstState { + // State + initialized: boolean; + userDataFetched: boolean; + isRefreshingStore: boolean; + connection: Connection | null; + wallet: Wallet | null; + lstData: LstData | null; + tokenDataMap: TokenDataMap | null; + solUsdValue: number | null; + slippagePct: SupportedSlippagePercent; + stakePoolProxyProgram: StakePoolProxyProgram | null; + + // Actions + fetchLstState: (args?: { connection?: Connection; wallet?: Wallet; isOverride?: boolean }) => Promise; + setIsRefreshingStore: (isRefreshingStore: boolean) => void; + resetUserData: () => void; + setSlippagePct: (slippagePct: SupportedSlippagePercent) => void; +} + +function createLstStore() { + return create]]>( + persist(stateCreator, { + name: "lst-peristent-store", + partialize(state) { + return { + slippagePct: state.slippagePct, + }; + }, + }) + ); +} + +interface LstData { + poolAddress: PublicKey; + tvl: number; + projectedApy: number; + lstSolValue: number; + solDepositFee: number; + accountData: solanaStakePool.StakePool; +} + +const stateCreator: StateCreator = (set, get) => ({ + // State + initialized: false, + userDataFetched: false, + isRefreshingStore: false, + connection: null, + wallet: null, + lstData: null, + tokenDataMap: null, + solUsdValue: null, + slippagePct: 1, + stakePoolProxyProgram: null, + + // Actions + fetchLstState: async (args?: { connection?: Connection; wallet?: Wallet }) => { + try { + let userDataFetched = false; + + const connection = args?.connection || get().connection; + if (!connection) throw new Error("Connection not found"); + + const wallet = args?.wallet || get().wallet; + + const provider = new AnchorProvider(connection, wallet ?? ({} as Wallet), { + ...AnchorProvider.defaultOptions(), + commitment: connection.commitment ?? AnchorProvider.defaultOptions().commitment, + }); + const stakePoolProxyProgram = getStakePoolProxyProgram(provider); + + let lstData: LstData | null = null; + let tokenDataMap: TokenDataMap | null = null; + let solUsdValue: number | null = null; + if (wallet?.publicKey) { + const [accountsAiList, minimumRentExemption, _lstData, jupiterTokenInfo, userTokenAccounts] = await Promise.all( + [ + connection.getMultipleAccountsInfo([wallet.publicKey, SOL_USD_PYTH_ORACLE]), + connection.getMinimumBalanceForRentExemption(ACCOUNT_SIZE), + fetchLstData(connection), + fetchJupiterTokenInfo(), + fetchUserTokenAccounts(connection, wallet.publicKey), + ] + ); + + lstData = _lstData; + const [walletAi, solUsdPythFeedAi] = accountsAiList; + const nativeSolBalance = walletAi?.lamports ? walletAi.lamports : 0; + const availableSolBalance = (nativeSolBalance - minimumRentExemption - NETWORK_FEE_LAMPORTS) / 1e9; + solUsdValue = vendor.parsePriceData(solUsdPythFeedAi!.data).emaPrice.value; + + const tokenPrices = await fetchTokenPrices( + [...jupiterTokenInfo.values()].map((tokenInfo) => new PublicKey(tokenInfo.address)) + ); + tokenDataMap = new Map( + [...jupiterTokenInfo.entries()].map(([tokenMint, tokenInfo]) => { + const price = tokenPrices.get(tokenInfo.address); + const { logoURI, ..._tokenInfo } = tokenInfo; + + let walletBalance: number = 0; + if (tokenMint === SOL_MINT.toBase58()) { + walletBalance = availableSolBalance; + } else { + const tokenAccount = userTokenAccounts?.get(tokenMint); + walletBalance = tokenAccount?.balance ?? 0; + } + + return [ + tokenMint, + { ..._tokenInfo, iconUrl: logoURI ?? "/info_icon.png", price: price ? price : 0, balance: walletBalance }, + ]; + }) + ); + + userDataFetched = true; + } else { + const [accountsAiList, _lstData, jupiterTokenInfo] = await Promise.all([ + connection.getMultipleAccountsInfo([SOL_USD_PYTH_ORACLE]), + fetchLstData(connection), + fetchJupiterTokenInfo(), + ]); + + const tokenPrices = await fetchTokenPrices( + [...jupiterTokenInfo.values()].map((tokenInfo) => new PublicKey(tokenInfo.address)) + ); + tokenDataMap = new Map( + [...jupiterTokenInfo.entries()].map(([tokenMint, tokenInfo]) => { + const price = tokenPrices.get(tokenInfo.address); + const { logoURI, ..._tokenInfo } = tokenInfo; + return [ + tokenMint, + { ..._tokenInfo, iconUrl: logoURI ?? "/info_icon.png", price: price ? price : 0, balance: 0 }, + ]; + }) + ); + + lstData = _lstData; + const [solUsdPythFeedAi] = accountsAiList; + solUsdValue = vendor.parsePriceData(solUsdPythFeedAi!.data).emaPrice.value; + } + + set({ + initialized: true, + userDataFetched, + isRefreshingStore: false, + connection, + wallet, + lstData, + tokenDataMap, + solUsdValue, + stakePoolProxyProgram, + }); + } catch (err) { + console.error("error refreshing state: ", err); + set({ isRefreshingStore: false }); + } + }, + setIsRefreshingStore: (isRefreshingStore: boolean) => set({ isRefreshingStore }), + resetUserData: () => { + let tokenDataMap = get().tokenDataMap; + if (tokenDataMap) { + tokenDataMap = new Map( + [...tokenDataMap?.entries()].map( + ([tokenMint, tokenData]) => [tokenMint, { ...tokenData, balance: 0 }] as [string, TokenData] + ) + ); + } + set({ userDataFetched: false, tokenDataMap }); + }, + setSlippagePct: (slippagePct: SupportedSlippagePercent) => set({ slippagePct }), +}); + +async function fetchLstData(connection: Connection): Promise { + const [stakePoolInfo, stakePoolAccount] = await Promise.all([ + solanaStakePool.stakePoolInfo(connection, STAKE_POOL_ID), + solanaStakePool.getStakePoolAccount(connection, STAKE_POOL_ID), + ]); + const stakePool = stakePoolAccount.account.data; + + const poolTokenSupply = Number(stakePoolInfo.poolTokenSupply); + const totalLamports = Number(stakePoolInfo.totalLamports); + const lastPoolTokenSupply = Number(stakePoolInfo.lastEpochPoolTokenSupply); + const lastTotalLamports = Number(stakePoolInfo.lastEpochTotalLamports); + + const solDepositFee = stakePoolInfo.solDepositFee.denominator.eqn(0) + ? 0 + : stakePoolInfo.solDepositFee.numerator.toNumber() / stakePoolInfo.solDepositFee.denominator.toNumber(); + + const lstSolValue = poolTokenSupply > 0 ? totalLamports / poolTokenSupply : 1; + + let projectedApy; + if (lastTotalLamports === 0 || lastPoolTokenSupply === 0) { + projectedApy = 0.08; + } else { + const lastLstSolValue = lastPoolTokenSupply > 0 ? lastTotalLamports / lastPoolTokenSupply : 1; + const epochRate = lstSolValue / lastLstSolValue - 1; + const apr = epochRate * EPOCHS_PER_YEAR; + projectedApy = aprToApy(apr, EPOCHS_PER_YEAR); + } + + return { + poolAddress: new PublicKey(stakePoolInfo.address), + tvl: totalLamports / 1e9, + projectedApy, + lstSolValue, + solDepositFee, + accountData: stakePool, + }; +} + +async function fetchJupiterTokenInfo(): Promise { + const preferredTokenListMode: any = "strict"; + const tokens = await (preferredTokenListMode === "strict" + ? await fetch("https://token.jup.ag/strict") + : await fetch("https://token.jup.ag/all") + ).json(); + const res = new TokenListContainer(tokens); + const list = res.filterByChainId(101).getList(); + const tokenMap = list + .filter((tokenInfo) => SUPPORTED_TOKENS.includes(tokenInfo.address)) + .reduce((acc, item) => { + acc.set(item.address, item); + return acc; + }, new Map()); + + return tokenMap; +} + +async function fetchUserTokenAccounts(connection: Connection, walletAddress: PublicKey): Promise { + const response = await connection.getParsedTokenAccountsByOwner( + walletAddress, + { programId: TOKEN_PROGRAM_ID }, + "confirmed" + ); + + const reducedResult = response.value.map((item: any) => { + return { + created: true, + mint: new PublicKey(item.account.data.parsed.info.mint), + balance: item.account.data.parsed.info.tokenAmount.uiAmount, + } as TokenAccount; + }); + + const userTokenAccounts = new Map( + reducedResult.map((tokenAccount: any) => [tokenAccount.mint.toString(), tokenAccount]) + ); + return userTokenAccounts; +} + +async function fetchTokenPrices(mints: PublicKey[]): Promise> { + const prices = await fetchBirdeyePrices(mints); + return new Map(prices.map((price, index) => [mints[index].toString(), price.toNumber()])); +} + +export { createLstStore }; +export type { LstState }; diff --git a/apps/marginfi-v2-ui/src/styles/globals.css b/apps/marginfi-v2-ui/src/styles/globals.css index 904e99a4c1..94f8b8efc9 100644 --- a/apps/marginfi-v2-ui/src/styles/globals.css +++ b/apps/marginfi-v2-ui/src/styles/globals.css @@ -116,3 +116,53 @@ a { padding-left: 0px; padding-right: 0px; } + +@keyframes radiate { + 0% { + color: #ccc; + } + 100% { + color: #fff; + } +} + +.radiating-text { + animation: radiate 2s infinite alternate; +} + +/* width */ +::-webkit-scrollbar { + width: 8px; +} + +/* Track */ +::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 5px; +} + +/* Handle */ +::-webkit-scrollbar-thumb { + background: #888; + border-radius: 5px; +} + +/* Handle on hover */ +::-webkit-scrollbar-thumb:hover { + background: #555; +} + +@keyframes wavyGradientAnimation { + 0% { + background-position: 200% 0; + } + 100% { + background-position: 0 0; + } +} + +.wavy-gradient-bg { + animation: wavyGradientAnimation 2s linear infinite; + background: linear-gradient(90deg, #808080, #DCE85DCC, #808080); + background-size: 200% 100%; +} diff --git a/apps/marginfi-v2-ui/src/utils/featureGates.ts b/apps/marginfi-v2-ui/src/utils/featureGates.ts new file mode 100644 index 0000000000..53607a8123 --- /dev/null +++ b/apps/marginfi-v2-ui/src/utils/featureGates.ts @@ -0,0 +1,14 @@ +enum Features { + STAKE = 'stake', +} + +function isActive(feature: Features) { + const featureGatesRaw = process.env.NEXT_PUBLIC_FEATURE_GATES as string | undefined; + if (!featureGatesRaw) return false; + + const featureGates = JSON.parse(featureGatesRaw) as Record; + + return !!featureGates[feature]; +} + +export { Features, isActive }; diff --git a/apps/marginfi-v2-ui/src/utils/index.ts b/apps/marginfi-v2-ui/src/utils/index.ts index b3dc1d121a..74700c026e 100644 --- a/apps/marginfi-v2-ui/src/utils/index.ts +++ b/apps/marginfi-v2-ui/src/utils/index.ts @@ -2,6 +2,7 @@ import { PublicKey, TransactionInstruction } from "@solana/web3.js"; import BN from "bn.js"; import { TOKEN_PROGRAM_ID } from "@mrgnlabs/mrgn-common"; import { ActiveBankInfo } from "@mrgnlabs/marginfi-v2-ui-state"; +import { useEffect, useRef } from "react"; // ================ development utils ================ @@ -37,4 +38,20 @@ export function isWholePosition(activeBankInfo: ActiveBankInfo, amount: number): return amount >= positionTokenAmount; } +const DEFAULT_TICKS_PER_SECOND = 160; +const DEFAULT_TICKS_PER_SLOT = 64; +const SECONDS_PER_DAY = 24 * 60 * 60; +const TICKS_PER_DAY = DEFAULT_TICKS_PER_SECOND * SECONDS_PER_DAY; +const DEFAULT_SLOTS_PER_EPOCH = (2 * TICKS_PER_DAY) / DEFAULT_TICKS_PER_SLOT; +const DEFAULT_S_PER_SLOT = DEFAULT_TICKS_PER_SLOT / DEFAULT_TICKS_PER_SECOND; +const SECONDS_PER_EPOCH = DEFAULT_SLOTS_PER_EPOCH * DEFAULT_S_PER_SLOT; +export const EPOCHS_PER_YEAR = (SECONDS_PER_DAY * 365.25) / SECONDS_PER_EPOCH; + +export function usePrevious(value: T): T | undefined { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }, [value]); + return ref.current; +} export { OKXWalletAdapter } from "./OKXWalletAdapter"; diff --git a/apps/marginfi-v2-ui/src/utils/stakePoolProxy/index.ts b/apps/marginfi-v2-ui/src/utils/stakePoolProxy/index.ts new file mode 100644 index 0000000000..a7312ff261 --- /dev/null +++ b/apps/marginfi-v2-ui/src/utils/stakePoolProxy/index.ts @@ -0,0 +1,12 @@ +import { AnchorProvider, Program } from "@coral-xyz/anchor"; +import { Program as ProgramType } from "@mrgnlabs/mrgn-common"; +import { StakePoolProxy, IDL } from "./stake-pool-proxy-types"; +import { PublicKey } from "@solana/web3.js"; + +export type StakePoolProxyProgram = ProgramType; + +export const STAKE_POOL_PROXY_PROGRAM_ID = new PublicKey("SPPdCjFYYwH3ca2kCT9baLcgbXz81P5bd5QutHynuRz"); + +export function getStakePoolProxyProgram(provider: AnchorProvider) { + return new Program(IDL, STAKE_POOL_PROXY_PROGRAM_ID, provider) as any as StakePoolProxyProgram; +} diff --git a/apps/marginfi-v2-ui/src/utils/stakePoolProxy/stake-pool-proxy-types.ts b/apps/marginfi-v2-ui/src/utils/stakePoolProxy/stake-pool-proxy-types.ts new file mode 100644 index 0000000000..a08c829ab6 --- /dev/null +++ b/apps/marginfi-v2-ui/src/utils/stakePoolProxy/stake-pool-proxy-types.ts @@ -0,0 +1,145 @@ +export type StakePoolProxy = { + "version": "0.1.0", + "name": "stake_pool_proxy", + "instructions": [ + { + "name": "depositAllSol", + "accounts": [ + { + "name": "stakePool", + "isMut": true, + "isSigner": false + }, + { + "name": "stakePoolWithdrawAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "reserveStakeAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "lamportsFrom", + "isMut": true, + "isSigner": true + }, + { + "name": "poolTokensTo", + "isMut": true, + "isSigner": false + }, + { + "name": "managerFeeAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "referrerPoolTokensAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "poolMint", + "isMut": true, + "isSigner": false + }, + { + "name": "stakePoolProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ], + "args": [] + } + ] +}; + +export const IDL: StakePoolProxy = { + "version": "0.1.0", + "name": "stake_pool_proxy", + "instructions": [ + { + "name": "depositAllSol", + "accounts": [ + { + "name": "stakePool", + "isMut": true, + "isSigner": false + }, + { + "name": "stakePoolWithdrawAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "reserveStakeAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "lamportsFrom", + "isMut": true, + "isSigner": true + }, + { + "name": "poolTokensTo", + "isMut": true, + "isSigner": false + }, + { + "name": "managerFeeAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "referrerPoolTokensAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "poolMint", + "isMut": true, + "isSigner": false + }, + { + "name": "stakePoolProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ], + "args": [] + } + ] +}; diff --git a/packages/marginfi-client-v2/src/index.ts b/packages/marginfi-client-v2/src/index.ts index 477152f025..55e3cf5cf3 100644 --- a/packages/marginfi-client-v2/src/index.ts +++ b/packages/marginfi-client-v2/src/index.ts @@ -12,4 +12,5 @@ export * from "./models/account"; export * from "./idl"; export * from "./types"; export * from "./utils"; +export * as vendor from "./vendor"; export { MarginfiClient }; diff --git a/packages/marginfi-client-v2/src/vendor/index.ts b/packages/marginfi-client-v2/src/vendor/index.ts new file mode 100644 index 0000000000..ddddafe5ea --- /dev/null +++ b/packages/marginfi-client-v2/src/vendor/index.ts @@ -0,0 +1,2 @@ +export * from "./pyth"; +export * from "./switchboard"; diff --git a/packages/marginfi-v2-ui-state/src/lib/mrgnlend.ts b/packages/marginfi-v2-ui-state/src/lib/mrgnlend.ts index 1d8cc82a52..31c7dd4b07 100644 --- a/packages/marginfi-v2-ui-state/src/lib/mrgnlend.ts +++ b/packages/marginfi-v2-ui-state/src/lib/mrgnlend.ts @@ -123,7 +123,7 @@ function makeBankInfo(bank: Bank, oraclePrice: OraclePrice, emissionTokenData?: } const BIRDEYE_API = "https://public-api.birdeye.so"; -async function fetchBirdeyePrices(mints: PublicKey[]): Promise { +export async function fetchBirdeyePrices(mints: PublicKey[]): Promise { const mintList = mints.map((mint) => mint.toBase58()).join(","); const response = await fetch(`${BIRDEYE_API}/public/multi_price?list_address=${mintList}`, { headers: { Accept: "application/json" }, diff --git a/packages/marginfi-v2-ui-state/src/store/jupiterStore.ts b/packages/marginfi-v2-ui-state/src/store/jupiterStore.ts index 532e06b7d6..083fccc200 100644 --- a/packages/marginfi-v2-ui-state/src/store/jupiterStore.ts +++ b/packages/marginfi-v2-ui-state/src/store/jupiterStore.ts @@ -68,7 +68,7 @@ const stateCreator: StateCreator = (set, get) => ({ return { created: true, mint: new PublicKey(item.account.data.parsed.info.mint), - balance: nativeToUi(new BN(item.account.data.parsed.info.tokenAmount.uiAmount), 0), + balance: item.account.data.parsed.info.tokenAmount.uiAmount, } as TokenAccount; }); diff --git a/packages/mrgn-common/package.json b/packages/mrgn-common/package.json index b409915bdd..a5a487adbe 100644 --- a/packages/mrgn-common/package.json +++ b/packages/mrgn-common/package.json @@ -17,6 +17,7 @@ "@solana/wallet-adapter-base": "^0.9.20", "@solana/web3.js": "^1.71.0", "bignumber.js": "^9.1.1", + "bs58": "^5.0.0", "decimal.js": "^10.4.3", "numeral": "^2.0.6", "superstruct": "^1.0.3" diff --git a/packages/mrgn-common/src/misc.ts b/packages/mrgn-common/src/misc.ts index 437aa29bf2..e659ebb5f4 100644 --- a/packages/mrgn-common/src/misc.ts +++ b/packages/mrgn-common/src/misc.ts @@ -1,5 +1,16 @@ -import { AnchorProvider } from "@coral-xyz/anchor"; -import { ConfirmOptions, Connection, Keypair, Signer, Transaction, TransactionSignature } from "@solana/web3.js"; +import { + ConfirmOptions, + Connection, + Keypair, + Signer, + Transaction, + TransactionMessage, + TransactionSignature, + VersionedTransaction, +} from "@solana/web3.js"; +import { TransactionOptions, Wallet } from "./types"; +import { DEFAULT_CONFIRM_OPTS } from "./constants"; +import base58 from "bs58"; /** * Load Keypair from the provided file. @@ -30,49 +41,94 @@ export function getValueInsensitive(map: Record, key: string): T { * Transaction processing and error-handling helper. */ export async function processTransaction( - provider: AnchorProvider, - tx: Transaction, + connection: Connection, + wallet: Wallet, + transaction: Transaction | VersionedTransaction, signers?: Array, - opts?: ConfirmOptions + opts?: TransactionOptions ): Promise { - const connection = new Connection(provider.connection.rpcEndpoint, provider.opts); - const { - context: { slot: minContextSlot }, - value: { blockhash, lastValidBlockHeight }, - } = await connection.getLatestBlockhashAndContext(); - - tx.recentBlockhash = blockhash; - tx.feePayer = provider.wallet.publicKey; - tx = await provider.wallet.signTransaction(tx); - - if (signers === undefined) { - signers = []; - } - signers - .filter((s) => s !== undefined) - .forEach((kp) => { - tx.partialSign(kp); - }); + let signature: TransactionSignature = ""; try { - const signature = await connection.sendRawTransaction( - tx.serialize(), - opts || { - skipPreflight: false, - preflightCommitment: provider.connection.commitment, - commitment: provider.connection.commitment, + let versionedTransaction: VersionedTransaction; + + const { + context: { slot: minContextSlot }, + value: { blockhash, lastValidBlockHeight }, + } = await connection.getLatestBlockhashAndContext(); + + if (transaction instanceof Transaction) { + const versionedMessage = new TransactionMessage({ + instructions: transaction.instructions, + payerKey: wallet.publicKey, + recentBlockhash: blockhash, + }); + + versionedTransaction = new VersionedTransaction(versionedMessage.compileToV0Message([])); + } else { + versionedTransaction = transaction; + } + + if (signers) versionedTransaction.sign(signers); + + if (opts?.dryRun) { + const response = await connection.simulateTransaction( + versionedTransaction, + opts ?? { minContextSlot, sigVerify: false } + ); + console.log( + response.value.err ? `❌ Error: ${response.value.err}` : `✅ Success - ${response.value.unitsConsumed} CU` + ); + console.log("------ Logs 👇 ------"); + console.log(response.value.logs); + + const signaturesEncoded = encodeURIComponent( + JSON.stringify(versionedTransaction.signatures.map((s) => base58.encode(s))) + ); + const messageEncoded = encodeURIComponent( + Buffer.from(versionedTransaction.message.serialize()).toString("base64") + ); + console.log(Buffer.from(versionedTransaction.message.serialize()).toString("base64")); + + const urlEscaped = `https://explorer.solana.com/tx/inspector?cluster=mainnet&signatures=${signaturesEncoded}&message=${messageEncoded}`; + console.log("------ Inspect 👇 ------"); + console.log(urlEscaped); + + return versionedTransaction.signatures[0].toString(); + } else { + versionedTransaction = await wallet.signTransaction(versionedTransaction); + + let mergedOpts: ConfirmOptions = { + ...DEFAULT_CONFIRM_OPTS, + commitment: connection.commitment ?? DEFAULT_CONFIRM_OPTS.commitment, + preflightCommitment: connection.commitment ?? DEFAULT_CONFIRM_OPTS.commitment, minContextSlot, - } - ); - await connection.confirmTransaction({ - blockhash, - lastValidBlockHeight, - signature, - }); - return signature; - } catch (e: any) { - console.log(e); - throw e; + ...opts, + }; + + signature = await connection.sendTransaction(versionedTransaction, { + minContextSlot: mergedOpts.minContextSlot, + skipPreflight: mergedOpts.skipPreflight, + preflightCommitment: mergedOpts.preflightCommitment, + maxRetries: mergedOpts.maxRetries, + }); + await connection.confirmTransaction( + { + blockhash, + lastValidBlockHeight, + signature, + }, + mergedOpts.commitment + ); + return signature; + } + } catch (error: any) { + if (error.logs) { + console.log("------ Logs 👇 ------"); + console.log(error.logs.join("\n")); + } + + throw `Transaction failed! ${error?.message}`; } } diff --git a/turbo.json b/turbo.json index 6a35a2860c..31616c84ef 100644 --- a/turbo.json +++ b/turbo.json @@ -32,6 +32,7 @@ "NEXT_PUBLIC_MARGINFI_FEATURES_CREATE_CAMPAIGN", "NEXT_PUBLIC_OMNI_TABLE_ID", "NEXT_PUBLIC_FIREBASE_PROJECT_ID", + "NEXT_PUBLIC_FEATURE_GATES", "FIREBASE_CLIENT_EMAIL", "FIREBASE_PRIVATE_KEY", "NEXT_PUBLIC_FIREBASE_DATABASE_URL", diff --git a/yarn.lock b/yarn.lock index 5e1d91c896..0856acd218 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3815,6 +3815,11 @@ resolved "https://registry.yarnpkg.com/@jup-ag/api/-/api-4.0.1-alpha.0.tgz#6c9fd02e607830206802b5e372fd0c2ec5a6a0c1" integrity sha512-H9hyf9K7sXMPd0++/hQ5Fs2eVgi2w9wtJP0YmGFLJMjF8zHYDYqQ0ZCdgmLLSp/vrEjmOHbbiLJT2psuV5W+bw== +"@jup-ag/api@^6.0.6", "@jup-ag/api@~6.0.6": + version "6.0.6" + resolved "https://registry.yarnpkg.com/@jup-ag/api/-/api-6.0.6.tgz#f0f838aaf8ca5964d7cb9ff24535599bf4d03813" + integrity sha512-wGp3FjT+WNdd5TzsWaKr2a2rQ2FgIyaV6sq8L76R8KVYxH7TfCY/BZwhcij6YhxHGjxrF8j7qI6ZPpmUTICpnA== + "@jup-ag/common@4.0.0-beta.21": version "4.0.0-beta.21" resolved "https://registry.yarnpkg.com/@jup-ag/common/-/common-4.0.0-beta.21.tgz#416b3fc75a3e0a3055dfb5fc404cb71edcd6a60b" @@ -3830,7 +3835,7 @@ bs58 "^4.0.1" jsbi "4.3.0" -"@jup-ag/common@^6.0.0-beta.2": +"@jup-ag/common@6.0.0-beta.2", "@jup-ag/common@^6.0.0-beta.2": version "6.0.0-beta.2" resolved "https://registry.yarnpkg.com/@jup-ag/common/-/common-6.0.0-beta.2.tgz#64cce00a04299b8047c1fa5e0c821b0328cef591" integrity sha512-KHXKs+6aE+Y4rtzisIeZcC2j4iRVJeS0mX8H3wIBQ2D/FQtVrZp1mI6A/kA3ifPad1jmjWTLPLTwLAcf1Y/Fjw== @@ -4066,6 +4071,17 @@ "@solana/spl-token" "0.1.8" cross-fetch "3.1.5" +"@jup-ag/react-hook@^6.0.0-beta.2": + version "6.0.0-beta.2" + resolved "https://registry.yarnpkg.com/@jup-ag/react-hook/-/react-hook-6.0.0-beta.2.tgz#1effec260c488d862474a1b33c7324f4ca5ee2c4" + integrity sha512-vWvjn/uy54ri2bJ/086NlRM7hkL92ZU5iFiLlL5V1v21ZdmKwof6J30hShf/l3fTbv8IjUWIsLpU3EUVyuO26w== + dependencies: + "@jup-ag/api" "~6.0.6" + "@jup-ag/common" "6.0.0-beta.2" + "@mercurial-finance/optimist" "0.3.0" + "@solana/spl-token" "0.1.8" + superstruct "~1.0.3" + "@jup-ag/whirlpools-sdk@0.7.2": version "0.7.2" resolved "https://registry.yarnpkg.com/@jup-ag/whirlpools-sdk/-/whirlpools-sdk-0.7.2.tgz#c4cc2fa601686e5cb5f1020b8c50acf38c9fb529" @@ -6222,6 +6238,18 @@ dependencies: buffer "~6.0.3" +"@solana/spl-stake-pool@^0.6.5": + version "0.6.5" + resolved "https://registry.yarnpkg.com/@solana/spl-stake-pool/-/spl-stake-pool-0.6.5.tgz#edb9985df9a7c5b772e379c0435545506fc526a7" + integrity sha512-gAYjX4LlRem3Bje1csZOOBStX8wAH8b8tu4sublUTIoJxLMdEbXqnwc8RJ2lAsmFkjxxomEM9Hk65F8jcvv15A== + dependencies: + "@project-serum/borsh" "^0.2.2" + "@solana/buffer-layout" "^4.0.0" + "@solana/spl-token" "^0.1.8" + "@solana/web3.js" "^1.30.2" + bn.js "^5.2.0" + buffer "^6.0.3" + "@solana/spl-token-registry@0.2.1105": version "0.2.1105" resolved "https://registry.yarnpkg.com/@solana/spl-token-registry/-/spl-token-registry-0.2.1105.tgz#460fc363096aa59c5150f67736cddc1d5a810e8a" @@ -6229,7 +6257,7 @@ dependencies: cross-fetch "3.0.6" -"@solana/spl-token-registry@^0.2.1107", "@solana/spl-token-registry@^0.2.3225", "@solana/spl-token-registry@^0.2.4484", "@solana/spl-token-registry@~0.2.1105": +"@solana/spl-token-registry@^0.2.1107", "@solana/spl-token-registry@^0.2.3225", "@solana/spl-token-registry@^0.2.4484", "@solana/spl-token-registry@^0.2.4574", "@solana/spl-token-registry@~0.2.1105": version "0.2.4574" resolved "https://registry.yarnpkg.com/@solana/spl-token-registry/-/spl-token-registry-0.2.4574.tgz#13f4636b7bec90d2bb43bbbb83512cd90d2ce257" integrity sha512-JzlfZmke8Rxug20VT/VpI2XsXlsqMlcORIUivF+Yucj7tFi7A0dXG7h+2UnD0WaZJw8BrUz2ABNkUnv89vbv1A== @@ -20734,7 +20762,7 @@ superstruct@^0.15.4: resolved "https://registry.yarnpkg.com/superstruct/-/superstruct-0.15.5.tgz#0f0a8d3ce31313f0d84c6096cd4fa1bfdedc9dab" integrity sha512-4AOeU+P5UuE/4nOUkmcQdW5y7i9ndt1cQd/3iUe+LTz3RxESf/W/5lg4B74HbDMMv8PHnPnGCQFH45kBcrQYoQ== -superstruct@^1.0.3: +superstruct@^1.0.3, superstruct@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/superstruct/-/superstruct-1.0.3.tgz#de626a5b49c6641ff4d37da3c7598e7a87697046" integrity sha512-8iTn3oSS8nRGn+C2pgXSKPI3jmpm6FExNazNpjvqS6ZUJQCej3PUXEKM8NjHBOs54ExM+LPW/FBRhymrdcCiSg==