diff --git a/apps/marginfi-v2-ui/.gitignore b/apps/marginfi-v2-ui/.gitignore index ec44f9d478..60a1863bee 100644 --- a/apps/marginfi-v2-ui/.gitignore +++ b/apps/marginfi-v2-ui/.gitignore @@ -39,3 +39,6 @@ next-env.d.ts # turbo .turbo + +# Sentry Auth Token +.sentryclirc diff --git a/apps/marginfi-v2-ui/next.config.js b/apps/marginfi-v2-ui/next.config.js index c5245d74d9..1349c33f52 100644 --- a/apps/marginfi-v2-ui/next.config.js +++ b/apps/marginfi-v2-ui/next.config.js @@ -70,3 +70,38 @@ module.exports = withBundleAnalyzer({ ], }, }); + +const { withSentryConfig } = require("@sentry/nextjs"); + +module.exports = withSentryConfig( + module.exports, + { + // For all available options, see: + // https://github.com/getsentry/sentry-webpack-plugin#options + + // Suppresses source map uploading logs during build + silent: true, + + org: "mrgn-labs", + project: "marginfi-v2-ui", + }, + { + // For all available options, see: + // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ + + // Upload a larger set of source maps for prettier stack traces (increases build time) + widenClientFileUpload: true, + + // Transpiles SDK to be compatible with IE11 (increases bundle size) + transpileClientSDK: true, + + // Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load) + tunnelRoute: "/monitoring", + + // Hides source maps from generated client bundles + hideSourceMaps: true, + + // Automatically tree-shake Sentry logger statements to reduce bundle size + disableLogger: true, + } +); diff --git a/apps/marginfi-v2-ui/package.json b/apps/marginfi-v2-ui/package.json index 4f42be0b9b..d9ce2b2268 100644 --- a/apps/marginfi-v2-ui/package.json +++ b/apps/marginfi-v2-ui/package.json @@ -11,9 +11,12 @@ "lint-fix": "yarn lint --fix" }, "dependencies": { + "@bonfida/spl-name-service": "^1.1.1", "@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": "*", @@ -22,7 +25,10 @@ "@mui/material": "^5.11.2", "@next/bundle-analyzer": "^13.4.19", "@next/font": "13.1.1", + "@sentry/nextjs": "^7.68.0", "@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 +40,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/sentry.client.config.ts b/apps/marginfi-v2-ui/sentry.client.config.ts new file mode 100644 index 0000000000..2767473ec9 --- /dev/null +++ b/apps/marginfi-v2-ui/sentry.client.config.ts @@ -0,0 +1,5 @@ +// This file configures the initialization of Sentry on the client. +// The config you add here will be used whenever a users loads a page in their browser. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; diff --git a/apps/marginfi-v2-ui/sentry.edge.config.ts b/apps/marginfi-v2-ui/sentry.edge.config.ts new file mode 100644 index 0000000000..6eb66d1e61 --- /dev/null +++ b/apps/marginfi-v2-ui/sentry.edge.config.ts @@ -0,0 +1,7 @@ +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + tracesSampleRate: 1, + debug: false, +}); diff --git a/apps/marginfi-v2-ui/sentry.server.config.ts b/apps/marginfi-v2-ui/sentry.server.config.ts new file mode 100644 index 0000000000..6eb66d1e61 --- /dev/null +++ b/apps/marginfi-v2-ui/sentry.server.config.ts @@ -0,0 +1,7 @@ +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + tracesSampleRate: 1, + debug: false, +}); 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 21b7bcadcc..56c8d61541 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); @@ -133,11 +149,40 @@ const Navbar: FC = () => { badgeContent={"l"} invisible={!showBadges} > - + lend + {isActive(Features.STAKE) && ( + + + stake + + + )} + { badgeContent={"s"} invisible={!showBadges} > - + swap @@ -170,7 +220,12 @@ const Navbar: FC = () => { badgeContent={"b"} invisible={!showBadges} > - + bridge @@ -190,11 +245,27 @@ const Navbar: FC = () => { invisible={!showBadges} className="hidden md:block" > - + earn + + {connected && currentFirebaseUser + ? `${groupedNumberFormatterDyn.format(Math.round(userPointsData.totalPoints))} points` + : "points"} + + {
{ - if (selectedAccount && extendedBankInfos?.find((b) => b.meta.tokenSymbol === "UXD")?.info.rawBank) { - selectedAccount!.withdrawEmissions( - extendedBankInfos.find((b) => b.meta.tokenSymbol === "UXD")!.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"); }} > - {accountSummary.outstandingUxpEmissions === 0 - ? `Lend UXD to earn UXP` - : `Claim ${ - accountSummary.outstandingUxpEmissions < 1 - ? accountSummary.outstandingUxpEmissions.toExponential(5) - : numeralFormatter(accountSummary.outstandingUxpEmissions) - } UXP`} + collect rewards + {bankAddressesWithEmissions.length > 0 && ( + + + + + )}
- - {connected && currentFirebaseUser - ? `${groupedNumberFormatterDyn.format(Math.round(userPointsData.totalPoints))} points` - : "points"} - -
diff --git a/apps/marginfi-v2-ui/src/components/Navbar/WalletButton.tsx b/apps/marginfi-v2-ui/src/components/Navbar/WalletButton.tsx index dfbd690866..bcb715d4ed 100644 --- a/apps/marginfi-v2-ui/src/components/Navbar/WalletButton.tsx +++ b/apps/marginfi-v2-ui/src/components/Navbar/WalletButton.tsx @@ -1,6 +1,6 @@ import dynamic from "next/dynamic"; import { FC } from "react"; -import { useWalletContext } from "../useWalletContext"; +import { useWalletContext } from "~/components/useWalletContext"; const WalletMultiButtonDynamic = dynamic( async () => (await import("@solana/wallet-adapter-react-ui")).WalletMultiButton, @@ -8,12 +8,21 @@ const WalletMultiButtonDynamic = dynamic( ); const WalletButton: FC = () => { - const { connected } = useWalletContext(); + const { connected, walletContextState, openWalletSelector } = useWalletContext(); return (
- {!connected &&
CONNECT
} + {walletContextState.connecting ? ( + + CONNECTING + + ) : ( + !connected &&
CONNECT
+ )}
); diff --git a/apps/marginfi-v2-ui/src/components/PageHeader.tsx b/apps/marginfi-v2-ui/src/components/PageHeader.tsx index 57dc641432..9b86ab8497 100644 --- a/apps/marginfi-v2-ui/src/components/PageHeader.tsx +++ b/apps/marginfi-v2-ui/src/components/PageHeader.tsx @@ -1,59 +1,15 @@ -import { FC } from "react"; +import { FC, ReactNode } from "react"; interface PageHeaderProps { - text?: string; + children: ReactNode; } -const PageHeader: FC = ({ 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..0a429a4509 --- /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 + +
+
+ Commission + {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/api/user/get.ts b/apps/marginfi-v2-ui/src/pages/api/user/get.ts index e689dba899..fd3b2c9c5f 100644 --- a/apps/marginfi-v2-ui/src/pages/api/user/get.ts +++ b/apps/marginfi-v2-ui/src/pages/api/user/get.ts @@ -1,5 +1,6 @@ import { NextApiResponse } from "next"; -import { NextApiRequest, getFirebaseUserByWallet, initFirebaseIfNeeded } from "./utils"; +import { getFirebaseUserByWallet, initFirebaseIfNeeded } from "./utils"; +import { NextApiRequest } from "../utils"; import { STATUS_INTERNAL_ERROR, STATUS_NOT_FOUND, STATUS_OK, firebaseApi } from "@mrgnlabs/marginfi-v2-ui-state"; initFirebaseIfNeeded(); diff --git a/apps/marginfi-v2-ui/src/pages/api/user/login.ts b/apps/marginfi-v2-ui/src/pages/api/user/login.ts index 5b554c7d88..302b9ef294 100644 --- a/apps/marginfi-v2-ui/src/pages/api/user/login.ts +++ b/apps/marginfi-v2-ui/src/pages/api/user/login.ts @@ -1,5 +1,7 @@ import * as admin from "firebase-admin"; -import { NextApiRequest, getFirebaseUserByWallet, initFirebaseIfNeeded, logLoginAttempt } from "./utils"; +import * as Sentry from "@sentry/nextjs"; +import { getFirebaseUserByWallet, initFirebaseIfNeeded, logLoginAttempt } from "./utils"; +import { NextApiRequest } from "../utils"; import { MEMO_PROGRAM_ID } from "@mrgnlabs/mrgn-common"; import { PublicKey, Transaction } from "@solana/web3.js"; import base58 from "bs58"; @@ -30,6 +32,7 @@ export default async function handler(req: NextApiRequest, res: an const loginData = validateAndUnpackLoginData(signedAuthDataRaw, method); signer = loginData.signer.toBase58(); } catch (error: any) { + Sentry.captureException(error); let status; switch (error.message) { case "Invalid login tx": @@ -50,10 +53,12 @@ export default async function handler(req: NextApiRequest, res: an const userResult = await getFirebaseUserByWallet(signer); if (userResult === undefined) { await logLoginAttempt(signer, null, signedAuthDataRaw, false); + Sentry.captureException({ message: "User not found" }); return res.status(STATUS_NOT_FOUND).json({ error: "User not found" }); } user = userResult; } catch (error: any) { + Sentry.captureException(error); return res.status(STATUS_INTERNAL_ERROR).json({ error: error.message }); // An unexpected error occurred } diff --git a/apps/marginfi-v2-ui/src/pages/api/user/signup.ts b/apps/marginfi-v2-ui/src/pages/api/user/signup.ts index 8b786fcd95..0cdf342ee8 100644 --- a/apps/marginfi-v2-ui/src/pages/api/user/signup.ts +++ b/apps/marginfi-v2-ui/src/pages/api/user/signup.ts @@ -1,11 +1,14 @@ import * as admin from "firebase-admin"; +import * as Sentry from "@sentry/nextjs"; import { - NextApiRequest, createFirebaseUser, getFirebaseUserByWallet, initFirebaseIfNeeded, logSignupAttempt, } from "./utils"; +import { + NextApiRequest, +} from "../utils"; import { is } from "superstruct"; import { MEMO_PROGRAM_ID } from "@mrgnlabs/mrgn-common"; import { PublicKey, Transaction } from "@solana/web3.js"; @@ -30,6 +33,11 @@ export interface SignupRequest { export default async function handler(req: NextApiRequest, res: any) { const { method, signedAuthDataRaw } = req.body; + Sentry.setContext("signup_args", { + method, + signedAuthDataRaw, + }); + let signer; let payload; try { @@ -37,6 +45,7 @@ export default async function handler(req: NextApiRequest, res: a signer = signupData.signer.toBase58(); payload = signupData.payload; } catch (error: any) { + Sentry.captureException(error); let status; switch (error.message) { case "Invalid signup tx": @@ -54,8 +63,12 @@ export default async function handler(req: NextApiRequest, res: a try { const user = await getFirebaseUserByWallet(signer); - if (user) return res.status(STATUS_BAD_REQUEST).json({ error: "User already exists" }); + if (user) { + Sentry.captureException({ message: "User already exists" }); + return res.status(STATUS_BAD_REQUEST).json({ error: "User already exists" }); + } } catch (error: any) { + Sentry.captureException(error); return res.status(STATUS_INTERNAL_ERROR).json({ error: error.message }); // An unexpected error occurred } @@ -63,6 +76,7 @@ export default async function handler(req: NextApiRequest, res: a await createFirebaseUser(signer, payload.referralCode); console.log("successfully created new user"); } catch (createUserError: any) { + Sentry.captureException(createUserError); return res.status(STATUS_INTERNAL_ERROR).json({ error: createUserError.message }); } diff --git a/apps/marginfi-v2-ui/src/pages/api/user/utils.ts b/apps/marginfi-v2-ui/src/pages/api/user/utils.ts index 3bd07ccbfe..f6150eb0c9 100644 --- a/apps/marginfi-v2-ui/src/pages/api/user/utils.ts +++ b/apps/marginfi-v2-ui/src/pages/api/user/utils.ts @@ -111,31 +111,3 @@ export async function createFirebaseUser(walletAddress: string, referralCode?: s referralCode: uuidv4(), }); } - -// ------- Next helpers - -export declare type Env = { - [key: string]: string | undefined; -}; - -export interface NextApiRequest extends IncomingMessage { - /** - * Object of `query` values from url - */ - query: Partial<{ - [key: string]: string | string[]; - }>; - /** - * Object of `cookies` from header - */ - cookies: Partial<{ - [key: string]: string; - }>; - body: T; - env: Env; - preview?: boolean; - /** - * Preview data set on the request, if any - * */ - previewData?: PreviewData; -} diff --git a/apps/marginfi-v2-ui/src/pages/api/utils.ts b/apps/marginfi-v2-ui/src/pages/api/utils.ts new file mode 100644 index 0000000000..2835338ce4 --- /dev/null +++ b/apps/marginfi-v2-ui/src/pages/api/utils.ts @@ -0,0 +1,31 @@ + +// ------- Next helpers + +import { IncomingMessage } from "http"; +import { PreviewData } from "next"; + +export declare type Env = { + [key: string]: string | undefined; +}; + +export interface NextApiRequest extends IncomingMessage { + /** + * Object of `query` values from url + */ + query: Partial<{ + [key: string]: string | string[]; + }>; + /** + * Object of `cookies` from header + */ + cookies: Partial<{ + [key: string]: string; + }>; + body: T; + env: Env; + preview?: boolean; + /** + * Preview data set on the request, if any + * */ + previewData?: PreviewData; +} 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..77fa773f95 100644 --- a/apps/marginfi-v2-ui/src/pages/points.tsx +++ b/apps/marginfi-v2-ui/src/pages/points.tsx @@ -32,6 +32,8 @@ import { useUserProfileStore } from "~/store"; import { LeaderboardRow, fetchLeaderboardData, firebaseApi } from "@mrgnlabs/marginfi-v2-ui-state"; import { numeralFormatter, groupedNumberFormatterDyn } from "@mrgnlabs/mrgn-common"; import { useWalletContext } from "~/components/useWalletContext"; +import { getFavoriteDomain } from "@bonfida/spl-name-service"; +import { Connection, PublicKey } from "@solana/web3.js"; const HtmlTooltip = styled(({ className, ...props }: TooltipProps) => ( @@ -47,6 +49,7 @@ const HtmlTooltip = styled(({ className, ...props }: TooltipProps) => ( const Points: FC = () => { const { connected, walletAddress } = useWalletContext(); + const { connection } = useConnection(); const { query: routerQuery } = useRouter(); const [currentFirebaseUser, hasUser, userPointsData] = useUserProfileStore((state) => [ state.currentFirebaseUser, @@ -55,17 +58,33 @@ const Points: FC = () => { ]); const [leaderboardData, setLeaderboardData] = useState([]); + const [domain, setDomain] = useState(); - const currentUserId = useMemo(() => currentFirebaseUser?.uid, [currentFirebaseUser]); + const currentUserId = useMemo(() => domain ?? currentFirebaseUser?.uid, [currentFirebaseUser, domain]); const referralCode = useMemo(() => routerQuery.referralCode as string | undefined, [routerQuery.referralCode]); useEffect(() => { - fetchLeaderboardData().then(setLeaderboardData); // TODO: cache leaderboard and avoid call - }, [connected, walletAddress]); // Dependency array to re-fetch when these variables change + if (connection && walletAddress) { + resolveDomain(connection, new PublicKey(walletAddress)); + } + }, [connection, walletAddress]); + + const resolveDomain = async (connection: Connection, user: PublicKey) => { + try { + const { domain, reverse } = await getFavoriteDomain(connection, user); + setDomain(`${reverse}.sol`); + } catch (error) { + return; + } + }; + + useEffect(() => { + fetchLeaderboardData(connection).then(setLeaderboardData); // TODO: cache leaderboard and avoid call + }, [connection, connected, walletAddress]); // Dependency array to re-fetch when these variables change return ( <> - + points
{!connected ? ( @@ -337,7 +356,7 @@ const Points: FC = () => { style={{ textDecoration: "none", color: "inherit" }} className="hover:text-[#DCE85D]" > - {`${row.id.slice(0, 5)}...${row.id.slice(-5)}`} + {row.id.endsWith(".sol") ? row.id : `${row.id.slice(0, 5)}...${row.id.slice(-5)}`}