diff --git a/app/components/wrap-eth-dialog.tsx b/app/components/wrap-eth-dialog.tsx new file mode 100644 index 00000000..b54413db --- /dev/null +++ b/app/components/wrap-eth-dialog.tsx @@ -0,0 +1,139 @@ +import React from "react" + +import { VisuallyHidden } from "@radix-ui/react-visually-hidden" +import { Token } from "@renegade-fi/react" +import { useQueryClient } from "@tanstack/react-query" +import { formatUnits } from "viem" +import { useAccount, useBalance } from "wagmi" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" + +import { useWrapEth } from "@/hooks/use-wrap-eth" +import { useReadErc20BalanceOf } from "@/lib/generated" +import { cn } from "@/lib/utils" + +export function WrapEthDialog(props: React.PropsWithChildren) { + return ( + + {props.children} + { + // Prevent closing the dialog when clicking inside toast + if ( + e.target instanceof Element && + e.target.closest("[data-sonner-toast]") + ) { + e.preventDefault() + } + }} + > + +
+ + +
+ + Wrap ETH + Wrap ETH into WETH + +
+
+ +
+
+
+ ) +} + +function Content() { + const { address } = useAccount() + const queryClient = useQueryClient() + const { data: ethBalance, queryKey: ethBalanceQueryKey } = useBalance({ + address, + }) + const weth = Token.findByTicker("WETH") + const { data: l2Balance, queryKey: wethBalanceQueryKey } = + useReadErc20BalanceOf({ + address: weth?.address, + args: [address ?? "0x"], + }) + + const [amount, setAmount] = React.useState("") + const { wrapEth, status } = useWrapEth({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ethBalanceQueryKey }) + queryClient.invalidateQueries({ queryKey: wethBalanceQueryKey }) + }, + onError: (error) => { + console.error("Error wrapping ETH:", error) + // You can add error handling logic here, e.g., showing an error toast + }, + }) + + const onSubmit = () => { + wrapEth(amount) + } + + return ( + <> +
+ setAmount(e.target.value)} + /> +
+ WETH Balance:  + + {formatUnits(l2Balance ?? BigInt(0), weth?.decimals ?? 18)} + +
+
+ {ethBalance?.symbol} Balance:  + + {formatUnits( + ethBalance?.value ?? BigInt(0), + ethBalance?.decimals ?? 18, + )} + +
+
+ + + + {status === "success" &&
Transaction confirmed!
} + + ) +} diff --git a/components/dialogs/token-select.tsx b/components/dialogs/token-select.tsx index 085cb833..d94b94ac 100644 --- a/components/dialogs/token-select.tsx +++ b/components/dialogs/token-select.tsx @@ -3,9 +3,9 @@ import * as React from "react" import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons" import { Token, useBackOfQueueWallet } from "@renegade-fi/react" import { erc20Abi, isAddress } from "viem" -import { useAccount, useReadContracts } from "wagmi" +import { useAccount, useBalance, useReadContracts } from "wagmi" -import { ExternalTransferDirection } from "@/components/dialogs/transfer/transfer-dialog" +import { ExternalTransferDirection } from "@/components/dialogs/transfer/helpers" import { Button } from "@/components/ui/button" import { Command, @@ -43,6 +43,9 @@ export function TokenSelect({ }) { const [open, setOpen] = React.useState(false) const { address } = useAccount() + + const { data: ethBalance } = useBalance({ address }) + const { data: l2Balances, queryKey } = useReadContracts({ contracts: DISPLAY_TOKENS().map((token) => ({ address: token.address, @@ -73,10 +76,16 @@ export function TokenSelect({ }, }) - const displayBalances = - direction === ExternalTransferDirection.Deposit - ? l2Balances - : renegadeBalances + // TODO: Sometimes old balances are added + const displayBalances = React.useMemo(() => { + if (direction !== ExternalTransferDirection.Deposit) return renegadeBalances + if (!l2Balances) return undefined + const weth = Token.findByTicker("WETH") + const combinedEthBalance = + (l2Balances?.get(weth.address) ?? BigInt(0)) + + (ethBalance?.value ?? BigInt(0)) + return new Map(l2Balances).set(weth.address, combinedEthBalance) + }, [direction, ethBalance?.value, l2Balances, renegadeBalances]) const isDesktop = useMediaQuery("(min-width: 1024px)") diff --git a/components/dialogs/transfer/default-form.tsx b/components/dialogs/transfer/default-form.tsx new file mode 100644 index 00000000..765b4c8c --- /dev/null +++ b/components/dialogs/transfer/default-form.tsx @@ -0,0 +1,450 @@ +import * as React from "react" + +import { usePathname, useRouter } from "next/navigation" + +import { zodResolver } from "@hookform/resolvers/zod" +import { Token, UpdateType, useBalances } from "@renegade-fi/react" +import { useQueryClient } from "@tanstack/react-query" +import { useForm, UseFormReturn, useWatch } from "react-hook-form" +import { toast } from "sonner" +import { formatUnits } from "viem" +import { useAccount } from "wagmi" +import { z } from "zod" + +import { TokenSelect } from "@/components/dialogs/token-select" +import { + checkAmount, + checkBalance, + ExternalTransferDirection, + formSchema, + isMaxBalance, +} from "@/components/dialogs/transfer/helpers" +import { MaxBalancesWarning } from "@/components/dialogs/transfer/max-balances-warning" +import { useIsMaxBalances } from "@/components/dialogs/transfer/use-is-max-balances" +import { NumberInput } from "@/components/number-input" +import { Button } from "@/components/ui/button" +import { DialogClose, DialogFooter } from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + ResponsiveTooltip, + ResponsiveTooltipContent, + ResponsiveTooltipTrigger, +} from "@/components/ui/responsive-tooltip" + +import { useApprove } from "@/hooks/use-approve" +import { useCheckChain } from "@/hooks/use-check-chain" +import { useDeposit } from "@/hooks/use-deposit" +import { useMaintenanceMode } from "@/hooks/use-maintenance-mode" +import { useMediaQuery } from "@/hooks/use-media-query" +import { usePriceQuery } from "@/hooks/use-price-query" +import { useRefreshOnBlock } from "@/hooks/use-refresh-on-block" +import { useWithdraw } from "@/hooks/use-withdraw" +import { MIN_DEPOSIT_AMOUNT, Side } from "@/lib/constants/protocol" +import { constructStartToastMessage } from "@/lib/constants/task" +import { formatNumber } from "@/lib/format" +import { useReadErc20BalanceOf } from "@/lib/generated" +import { cn } from "@/lib/utils" +import { useSide } from "@/providers/side-provider" + +export function DefaultForm({ + className, + direction, + form, + onSuccess, +}: React.ComponentProps<"form"> & { + direction: ExternalTransferDirection + onSuccess: () => void + form: UseFormReturn> +}) { + const isDesktop = useMediaQuery("(min-width: 1024px)") + + const mint = useWatch({ + control: form.control, + name: "mint", + }) + const baseToken = mint + ? Token.findByAddress(mint as `0x${string}`) + : undefined + const { address } = useAccount() + const isMaxBalances = useIsMaxBalances(mint) + + const renegadeBalances = useBalances() + const renegadeBalance = baseToken + ? renegadeBalances.get(baseToken.address)?.amount + : undefined + + const formattedRenegadeBalance = baseToken + ? formatUnits(renegadeBalance ?? BigInt(0), baseToken.decimals) + : "" + const renegadeBalanceLabel = baseToken + ? formatNumber(renegadeBalance ?? BigInt(0), baseToken.decimals, true) + : "" + + const { data: l2Balance, queryKey } = useReadErc20BalanceOf({ + address: baseToken?.address, + args: [address ?? "0x"], + query: { + enabled: + direction === ExternalTransferDirection.Deposit && + !!baseToken && + !!address, + }, + }) + + useRefreshOnBlock({ queryKey }) + + const formattedL2Balance = baseToken + ? formatUnits(l2Balance ?? BigInt(0), baseToken.decimals) + : "" + const l2BalanceLabel = baseToken + ? formatNumber(l2Balance ?? BigInt(0), baseToken.decimals, true) + : "" + + const balance = + direction === ExternalTransferDirection.Deposit + ? formattedL2Balance + : formattedRenegadeBalance + const balanceLabel = + direction === ExternalTransferDirection.Deposit + ? l2BalanceLabel + : renegadeBalanceLabel + + const amount = useWatch({ + control: form.control, + name: "amount", + }) + const hideMaxButton = + !mint || balance === "0" || amount.toString() === balance + + const { handleDeposit, status: depositStatus } = useDeposit({ + amount, + mint, + }) + + const { handleWithdraw } = useWithdraw({ + amount, + mint, + }) + + const { + needsApproval, + handleApprove, + status: approveStatus, + } = useApprove({ + amount: amount.toString(), + mint, + enabled: direction === ExternalTransferDirection.Deposit, + }) + + const { checkChain } = useCheckChain() + + let buttonText = "" + if (direction === ExternalTransferDirection.Withdraw) { + buttonText = "Withdraw" + } else if (needsApproval) { + if (approveStatus === "pending") { + buttonText = "Confirm in wallet" + } else { + buttonText = "Approve & Deposit" + } + } else { + if (depositStatus === "pending") { + buttonText = "Confirm in wallet" + } else { + buttonText = "Deposit" + } + } + const router = useRouter() + const pathname = usePathname() + const isTradePage = pathname.includes("/trade") + const queryClient = useQueryClient() + // Ensure price is loaded + usePriceQuery(baseToken?.address || "0x") + const { setSide } = useSide() + + async function onSubmit(values: z.infer) { + const isAmountSufficient = checkAmount( + queryClient, + values.amount, + baseToken, + ) + + if (direction === ExternalTransferDirection.Deposit) { + if (!isAmountSufficient) { + form.setError("amount", { + message: `Amount must be greater than or equal to ${MIN_DEPOSIT_AMOUNT} USDC`, + }) + return + } + await checkChain() + const isBalanceSufficient = checkBalance({ + amount: values.amount, + mint: values.mint, + balance: l2Balance, + }) + if (!isBalanceSufficient) { + form.setError("amount", { + message: "Insufficient Arbitrum balance", + }) + return + } + if (needsApproval) { + handleApprove({ + onSuccess: () => { + handleDeposit({ + onSuccess: (data) => { + form.reset() + onSuccess?.() + const message = constructStartToastMessage(UpdateType.Deposit) + toast.loading(message, { + id: data.taskId, + }) + setSide(baseToken?.ticker === "USDC" ? Side.BUY : Side.SELL) + if (isTradePage && baseToken?.ticker !== "USDC") { + router.push(`/trade/${baseToken?.ticker}`) + } + }, + }) + }, + }) + } else { + handleDeposit({ + onSuccess: (data) => { + form.reset() + onSuccess?.() + const message = constructStartToastMessage(UpdateType.Deposit) + toast.loading(message, { + id: data.taskId, + }) + setSide(baseToken?.ticker === "USDC" ? Side.BUY : Side.SELL) + if (isTradePage && baseToken?.ticker !== "USDC") { + router.push(`/trade/${baseToken?.ticker}`) + } + }, + }) + } + } else { + const renegadeBalance = renegadeBalances.get( + values.mint as `0x${string}`, + )?.amount + // User is allowed to withdraw whole balance even if amount is < MIN_TRANSFER_AMOUNT + if ( + !isAmountSufficient && + !isMaxBalance({ + amount: values.amount, + mint: values.mint, + balance: renegadeBalance, + }) + ) { + form.setError("amount", { + message: `Amount must be greater than or equal to ${MIN_DEPOSIT_AMOUNT} USDC`, + }) + return + } + // TODO: Check if balance is sufficient + const isBalanceSufficient = checkBalance({ + amount: values.amount, + mint: values.mint, + balance: renegadeBalance, + }) + if (!isBalanceSufficient) { + form.setError("amount", { + message: "Insufficient Renegade balance", + }) + return + } + + handleWithdraw({ + onSuccess: (data) => { + form.reset() + onSuccess?.() + }, + }) + } + } + + const { data: maintenanceMode } = useMaintenanceMode() + + return ( +
+ +
+
+ ( + + Token + + + + )} + /> +
+
+ ( + + Amount + +
+ + {!hideMaxButton && ( + + )} +
+
+ +
+ )} + /> +
+
+ {direction === ExternalTransferDirection.Deposit + ? "Arbitrum" + : "Renegade"} +  Balance +
+ +
+ {direction === ExternalTransferDirection.Deposit && ( + + )} +
+
+ {isDesktop ? ( + + + + + + + {`Transfers are temporarily disabled${maintenanceMode?.reason ? ` ${maintenanceMode.reason}` : ""}.`} + + + + ) : ( + + + + + + + + + + {`Transfers are temporarily disabled${maintenanceMode?.reason ? ` ${maintenanceMode.reason}` : ""}.`} + + + + )} +
+ + ) +} diff --git a/components/dialogs/transfer/helpers.ts b/components/dialogs/transfer/helpers.ts new file mode 100644 index 00000000..e159fe91 --- /dev/null +++ b/components/dialogs/transfer/helpers.ts @@ -0,0 +1,84 @@ +import { Token } from "@renegade-fi/react" +import { QueryClient } from "@tanstack/react-query" +import { formatUnits } from "viem" +import { z } from "zod" + +import { MIN_DEPOSIT_AMOUNT } from "@/lib/constants/protocol" +import { safeParseUnits } from "@/lib/format" +import { createPriceQueryKey } from "@/lib/query" + +export enum ExternalTransferDirection { + Deposit, + Withdraw, +} + +export const formSchema = z.object({ + amount: z + .string() + .min(1, { message: "Amount is required" }) + .refine( + (value) => { + const num = parseFloat(value) + return !isNaN(num) && num > 0 + }, + { message: "Amount must be greater than zero" }, + ), + mint: z.string().min(1, { + message: "Token is required", + }), +}) + +// Return true if the amount is greater than or equal to the minimum deposit amount (1 USDC) +export function checkAmount( + queryClient: QueryClient, + amount: string, + baseToken?: Token, +) { + if (!baseToken) return false + const usdPrice = queryClient.getQueryData( + createPriceQueryKey("binance", baseToken.address), + ) + if (!usdPrice) return false + const amountInUsd = Number(amount) * usdPrice + return amountInUsd >= MIN_DEPOSIT_AMOUNT +} + +// Returns true if the amount is less than or equal to the balance +// Returns false if the amount is greater than the balance or if the amount is invalid +export function checkBalance({ + amount, + mint, + balance, +}: z.infer & { balance?: bigint }) { + if (!balance) { + return false + } + try { + const token = Token.findByAddress(mint as `0x${string}`) + const parsedAmount = safeParseUnits(amount, token.decimals) + if (parsedAmount instanceof Error) { + return false + } + return parsedAmount <= balance + } catch (error) { + return false + } +} + +// Returns true iff the amount is equal to the balance +export function isMaxBalance({ + amount, + mint, + balance, +}: z.infer & { balance?: bigint }) { + if (!balance) { + return false + } + try { + const token = Token.findByAddress(mint as `0x${string}`) + const formattedAmount = formatUnits(balance, token.decimals) + return amount === formattedAmount + } catch (error) { + return false + } +} diff --git a/components/dialogs/transfer/transfer-dialog.tsx b/components/dialogs/transfer/transfer-dialog.tsx index 742dd311..d8aa8ec9 100644 --- a/components/dialogs/transfer/transfer-dialog.tsx +++ b/components/dialogs/transfer/transfer-dialog.tsx @@ -1,84 +1,23 @@ import * as React from "react" -import { usePathname, useRouter } from "next/navigation" - -import { zodResolver } from "@hookform/resolvers/zod" import { VisuallyHidden } from "@radix-ui/react-visually-hidden" -import { Token, UpdateType, useBalances, usePayFees } from "@renegade-fi/react" -import { QueryClient, useQueryClient } from "@tanstack/react-query" -import { Loader2 } from "lucide-react" -import { useForm, useWatch } from "react-hook-form" -import { toast } from "sonner" -import { formatUnits } from "viem" -import { useAccount } from "wagmi" -import { z } from "zod" +import { usePayFees } from "@renegade-fi/react" -import { TokenSelect } from "@/components/dialogs/token-select" -import { MaxBalancesWarning } from "@/components/dialogs/transfer/max-balances-warning" -import { useIsMaxBalances } from "@/components/dialogs/transfer/use-is-max-balances" -import { NumberInput } from "@/components/number-input" +import { ExternalTransferDirection } from "@/components/dialogs/transfer/helpers" +import { TransferForm } from "@/components/dialogs/transfer/transfer-form" import { Button } from "@/components/ui/button" import { Dialog, - DialogClose, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { - ResponsiveTooltip, - ResponsiveTooltipContent, - ResponsiveTooltipTrigger, -} from "@/components/ui/responsive-tooltip" -import { useApprove } from "@/hooks/use-approve" -import { useCheckChain } from "@/hooks/use-check-chain" -import { useDeposit } from "@/hooks/use-deposit" import { useFeeOnZeroBalance } from "@/hooks/use-fee-on-zero-balance" -import { useMaintenanceMode } from "@/hooks/use-maintenance-mode" import { useMediaQuery } from "@/hooks/use-media-query" -import { usePriceQuery } from "@/hooks/use-price-query" -import { useRefreshOnBlock } from "@/hooks/use-refresh-on-block" -import { useWithdraw } from "@/hooks/use-withdraw" -import { MIN_DEPOSIT_AMOUNT, Side } from "@/lib/constants/protocol" -import { constructStartToastMessage } from "@/lib/constants/task" -import { formatNumber, safeParseUnits } from "@/lib/format" -import { useReadErc20BalanceOf } from "@/lib/generated" -import { createPriceQueryKey } from "@/lib/query" import { cn } from "@/lib/utils" -import { useSide } from "@/providers/side-provider" - -const formSchema = z.object({ - amount: z - .string() - .min(1, { message: "Amount is required" }) - .refine( - (value) => { - const num = parseFloat(value) - return !isNaN(num) && num > 0 - }, - { message: "Amount must be greater than zero" }, - ), - mint: z.string().min(1, { - message: "Token is required", - }), -}) - -export enum ExternalTransferDirection { - Deposit, - Withdraw, -} export function TransferDialog({ mint, @@ -236,463 +175,3 @@ export function TransferDialog({ ) } - -function TransferForm({ - className, - direction, - initialMint, - onSuccess, -}: React.ComponentProps<"form"> & { - direction: ExternalTransferDirection - initialMint?: string - onSuccess: () => void -}) { - const isDesktop = useMediaQuery("(min-width: 1024px)") - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - amount: "", - mint: initialMint ?? "", - }, - }) - - const mint = useWatch({ - control: form.control, - name: "mint", - }) - const baseToken = mint - ? Token.findByAddress(mint as `0x${string}`) - : undefined - const { address } = useAccount() - const isMaxBalances = useIsMaxBalances(mint) - - const renegadeBalances = useBalances() - const renegadeBalance = baseToken - ? renegadeBalances.get(baseToken.address)?.amount - : undefined - - const formattedRenegadeBalance = baseToken - ? formatUnits(renegadeBalance ?? BigInt(0), baseToken.decimals) - : "" - const renegadeBalanceLabel = baseToken - ? formatNumber(renegadeBalance ?? BigInt(0), baseToken.decimals, true) - : "" - - const { data: l2Balance, queryKey } = useReadErc20BalanceOf({ - address: baseToken?.address, - args: [address ?? "0x"], - query: { - enabled: - direction === ExternalTransferDirection.Deposit && - !!baseToken && - !!address, - }, - }) - - useRefreshOnBlock({ queryKey }) - - const formattedL2Balance = baseToken - ? formatUnits(l2Balance ?? BigInt(0), baseToken.decimals) - : "" - const l2BalanceLabel = baseToken - ? formatNumber(l2Balance ?? BigInt(0), baseToken.decimals, true) - : "" - - const balance = - direction === ExternalTransferDirection.Deposit - ? formattedL2Balance - : formattedRenegadeBalance - const balanceLabel = - direction === ExternalTransferDirection.Deposit - ? l2BalanceLabel - : renegadeBalanceLabel - - const amount = useWatch({ - control: form.control, - name: "amount", - }) - const hideMaxButton = - !mint || balance === "0" || amount.toString() === balance - - const { handleDeposit, status: depositStatus } = useDeposit({ - amount, - mint, - }) - - const { handleWithdraw } = useWithdraw({ - amount, - mint, - }) - - const { - needsApproval, - handleApprove, - status: approveStatus, - } = useApprove({ - amount: amount.toString(), - mint, - enabled: direction === ExternalTransferDirection.Deposit, - }) - - const { checkChain } = useCheckChain() - - let buttonText = "" - if (direction === ExternalTransferDirection.Withdraw) { - buttonText = "Withdraw" - } else if (needsApproval) { - if (approveStatus === "pending") { - buttonText = "Confirm in wallet" - } else { - buttonText = "Approve & Deposit" - } - } else { - if (depositStatus === "pending") { - buttonText = "Confirm in wallet" - } else { - buttonText = "Deposit" - } - } - const router = useRouter() - const pathname = usePathname() - const isTradePage = pathname.includes("/trade") - const queryClient = useQueryClient() - // Ensure price is loaded - usePriceQuery(baseToken?.address || "0x") - const { setSide } = useSide() - - async function onSubmit(values: z.infer) { - const isAmountSufficient = checkAmount( - queryClient, - values.amount, - baseToken, - ) - - if (direction === ExternalTransferDirection.Deposit) { - if (!isAmountSufficient) { - form.setError("amount", { - message: `Amount must be greater than or equal to ${MIN_DEPOSIT_AMOUNT} USDC`, - }) - return - } - await checkChain() - const isBalanceSufficient = checkBalance({ - amount: values.amount, - mint: values.mint, - balance: l2Balance, - }) - if (!isBalanceSufficient) { - form.setError("amount", { - message: "Insufficient Arbitrum balance", - }) - return - } - if (needsApproval) { - handleApprove({ - onSuccess: () => { - handleDeposit({ - onSuccess: (data) => { - form.reset() - onSuccess?.() - const message = constructStartToastMessage(UpdateType.Deposit) - toast.success(message, { - id: data.taskId, - icon: , - }) - setSide(baseToken?.ticker === "USDC" ? Side.BUY : Side.SELL) - if (isTradePage && baseToken?.ticker !== "USDC") { - router.push(`/trade/${baseToken?.ticker}`) - } - }, - }) - }, - }) - } else { - handleDeposit({ - onSuccess: (data) => { - form.reset() - onSuccess?.() - const message = constructStartToastMessage(UpdateType.Deposit) - toast.success(message, { - id: data.taskId, - icon: , - }) - setSide(baseToken?.ticker === "USDC" ? Side.BUY : Side.SELL) - if (isTradePage && baseToken?.ticker !== "USDC") { - router.push(`/trade/${baseToken?.ticker}`) - } - }, - }) - } - } else { - const renegadeBalance = renegadeBalances.get( - values.mint as `0x${string}`, - )?.amount - // User is allowed to withdraw whole balance even if amount is < MIN_TRANSFER_AMOUNT - if ( - !isAmountSufficient && - !isMaxBalance({ - amount: values.amount, - mint: values.mint, - balance: renegadeBalance, - }) - ) { - form.setError("amount", { - message: `Amount must be greater than or equal to ${MIN_DEPOSIT_AMOUNT} USDC`, - }) - return - } - // TODO: Check if balance is sufficient - const isBalanceSufficient = checkBalance({ - amount: values.amount, - mint: values.mint, - balance: renegadeBalance, - }) - if (!isBalanceSufficient) { - form.setError("amount", { - message: "Insufficient Renegade balance", - }) - return - } - - handleWithdraw({ - onSuccess: (data) => { - form.reset() - onSuccess?.() - }, - }) - } - } - - const { data: maintenanceMode } = useMaintenanceMode() - - return ( -
- -
-
- ( - - Token - - - - )} - /> -
-
- ( - - Amount - -
- - {!hideMaxButton && ( - - )} -
-
- -
- )} - /> -
-
- {direction === ExternalTransferDirection.Deposit - ? "Arbitrum" - : "Renegade"} -  Balance -
- -
- {direction === ExternalTransferDirection.Deposit && ( - - )} -
-
- {isDesktop ? ( - - - - - - - {`Transfers are temporarily disabled${maintenanceMode?.reason ? ` ${maintenanceMode.reason}` : ""}.`} - - - - ) : ( - - - - - - - - - - {`Transfers are temporarily disabled${maintenanceMode?.reason ? ` ${maintenanceMode.reason}` : ""}.`} - - - - )} -
- - ) -} - -// Return true if the amount is greater than or equal to the minimum deposit amount (1 USDC) -function checkAmount( - queryClient: QueryClient, - amount: string, - baseToken?: Token, -) { - if (!baseToken) return false - const usdPrice = queryClient.getQueryData( - createPriceQueryKey("binance", baseToken.address), - ) - if (!usdPrice) return false - const amountInUsd = Number(amount) * usdPrice - return amountInUsd >= MIN_DEPOSIT_AMOUNT -} - -// Returns true if the amount is less than or equal to the balance -// Returns false if the amount is greater than the balance or if the amount is invalid -function checkBalance({ - amount, - mint, - balance, -}: z.infer & { balance?: bigint }) { - if (!balance) { - return false - } - try { - const token = Token.findByAddress(mint as `0x${string}`) - const parsedAmount = safeParseUnits(amount, token.decimals) - if (parsedAmount instanceof Error) { - return false - } - return parsedAmount <= balance - } catch (error) { - return false - } -} - -// Returns true iff the amount is equal to the balance -function isMaxBalance({ - amount, - mint, - balance, -}: z.infer & { balance?: bigint }) { - if (!balance) { - return false - } - try { - const token = Token.findByAddress(mint as `0x${string}`) - const formattedAmount = formatUnits(balance, token.decimals) - return amount === formattedAmount - } catch (error) { - return false - } -} diff --git a/components/dialogs/transfer/transfer-form.tsx b/components/dialogs/transfer/transfer-form.tsx new file mode 100644 index 00000000..b4cce394 --- /dev/null +++ b/components/dialogs/transfer/transfer-form.tsx @@ -0,0 +1,52 @@ +import * as React from "react" + +import { zodResolver } from "@hookform/resolvers/zod" +import { Token } from "@renegade-fi/react" +import { useForm } from "react-hook-form" +import { z } from "zod" + +import { DefaultForm } from "@/components/dialogs/transfer/default-form" +import { + ExternalTransferDirection, + formSchema, +} from "@/components/dialogs/transfer/helpers" +import { WETHForm } from "@/components/dialogs/transfer/weth-form" + +export function TransferForm({ + className, + direction, + initialMint, + onSuccess, +}: React.ComponentProps<"form"> & { + direction: ExternalTransferDirection + initialMint?: string + onSuccess: () => void +}) { + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + amount: "", + mint: initialMint ?? "", + }, + }) + if ( + direction === ExternalTransferDirection.Deposit && + form.watch("mint") === Token.findByTicker("WETH").address + ) { + return ( + + ) + } + return ( + + ) +} diff --git a/components/dialogs/transfer/weth-form.tsx b/components/dialogs/transfer/weth-form.tsx new file mode 100644 index 00000000..3165d95a --- /dev/null +++ b/components/dialogs/transfer/weth-form.tsx @@ -0,0 +1,495 @@ +import * as React from "react" + +import { usePathname, useRouter } from "next/navigation" + +import { Token, UpdateType } from "@renegade-fi/react" +import { useQueryClient } from "@tanstack/react-query" +import { UseFormReturn, useWatch } from "react-hook-form" +import { toast } from "sonner" +import { formatEther, formatUnits, parseEther } from "viem" +import { useAccount, useBalance } from "wagmi" +import { z } from "zod" + +import { TokenSelect } from "@/components/dialogs/token-select" +import { + ExternalTransferDirection, + checkAmount, + checkBalance, + formSchema, +} from "@/components/dialogs/transfer/helpers" +import { MaxBalancesWarning } from "@/components/dialogs/transfer/max-balances-warning" +import { useIsMaxBalances } from "@/components/dialogs/transfer/use-is-max-balances" +import { NumberInput } from "@/components/number-input" +import { Button } from "@/components/ui/button" +import { DialogClose, DialogFooter } from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + ResponsiveTooltip, + ResponsiveTooltipContent, + ResponsiveTooltipTrigger, +} from "@/components/ui/responsive-tooltip" + +import { useApprove } from "@/hooks/use-approve" +import { useBasePerQuotePrice } from "@/hooks/use-base-per-usd-price" +import { useCheckChain } from "@/hooks/use-check-chain" +import { useDeposit } from "@/hooks/use-deposit" +import { useMaintenanceMode } from "@/hooks/use-maintenance-mode" +import { useMediaQuery } from "@/hooks/use-media-query" +import { usePriceQuery } from "@/hooks/use-price-query" +import { useRefreshOnBlock } from "@/hooks/use-refresh-on-block" +import { useWrapEth } from "@/hooks/use-wrap-eth" +import { MIN_DEPOSIT_AMOUNT, Side } from "@/lib/constants/protocol" +import { constructStartToastMessage } from "@/lib/constants/task" +import { formatNumber } from "@/lib/format" +import { useReadErc20BalanceOf } from "@/lib/generated" +import { cn } from "@/lib/utils" +import { useSide } from "@/providers/side-provider" + +import { WrapEthWarning } from "./wrap-eth-warning" + +// Assume direction is deposit and mint is WETH +export function WETHForm({ + className, + form, + onSuccess, +}: React.ComponentProps<"form"> & { + onSuccess: () => void + form: UseFormReturn> +}) { + const isDesktop = useMediaQuery("(min-width: 1024px)") + + const mint = useWatch({ + control: form.control, + name: "mint", + }) + const baseToken = mint + ? Token.findByAddress(mint as `0x${string}`) + : undefined + const { address } = useAccount() + const isMaxBalances = useIsMaxBalances(mint) + + const { data: wethBalance, queryKey: wethBalanceQueryKey } = + useReadErc20BalanceOf({ + address: baseToken?.address, + args: [address ?? "0x"], + }) + + useRefreshOnBlock({ queryKey: wethBalanceQueryKey }) + + const formattedWethBalance = baseToken + ? formatUnits(wethBalance ?? BigInt(0), baseToken.decimals) + : "" + const wethBalanceLabel = baseToken + ? formatNumber(wethBalance ?? BigInt(0), baseToken.decimals, true) + : "" + + // ETH-specific logic + const { data: ethBalance, queryKey: ethBalanceQueryKey } = useBalance({ + address, + }) + const formattedEthBalance = formatUnits( + ethBalance?.value ?? BigInt(0), + ethBalance?.decimals ?? 18, + ) + const ethBalanceLabel = formatNumber(ethBalance?.value ?? BigInt(0), 18, true) + const basePerQuotePrice = useBasePerQuotePrice(baseToken?.address ?? "0x") + + // Calculate the minimum ETH to keep unwrapped for gas fees + const minEthToKeepUnwrapped = (basePerQuotePrice ?? BigInt(4e15)) * BigInt(5) + + const combinedBalance = + (wethBalance ?? BigInt(0)) + (ethBalance?.value ?? BigInt(0)) + const maxAmountToWrap = combinedBalance - minEthToKeepUnwrapped + + const amount = useWatch({ + control: form.control, + name: "amount", + }) + + const remainingEthBalance = + parseEther(amount) > (wethBalance ?? BigInt(0)) + ? (ethBalance?.value ?? BigInt(0)) + + (wethBalance ?? BigInt(0)) - + parseEther(amount) + : ethBalance?.value ?? BigInt(0) + + const hideMaxButton = + !mint || + formattedWethBalance === "0" || + amount.toString() === formattedWethBalance + + const { handleDeposit, status: depositStatus } = useDeposit({ + amount, + mint, + }) + + const { + needsApproval, + handleApprove, + status: approveStatus, + } = useApprove({ + amount: amount.toString(), + mint, + }) + + // If the amount is greater than the WETH balance, we need to wrap ETH + const needsWrapEth = parseEther(amount) > (wethBalance ?? BigInt(0)) + + const { checkChain } = useCheckChain() + + const router = useRouter() + const pathname = usePathname() + const isTradePage = pathname.includes("/trade") + const queryClient = useQueryClient() + // Ensure price is loaded + usePriceQuery(baseToken?.address || "0x") + const { setSide } = useSide() + + const [totalSteps, setTotalSteps] = React.useState(0) + const [currentStep, setCurrentStep] = React.useState(0) + + async function onSubmit(values: z.infer) { + const isAmountSufficient = checkAmount( + queryClient, + values.amount, + baseToken, + ) + + if (!isAmountSufficient) { + form.setError("amount", { + message: `Amount must be greater than or equal to ${MIN_DEPOSIT_AMOUNT} USDC`, + }) + return + } + await checkChain() + const isBalanceSufficient = checkBalance({ + amount: values.amount, + mint: values.mint, + balance: needsWrapEth ? combinedBalance : wethBalance, + }) + if (!isBalanceSufficient) { + form.setError("amount", { + message: "Insufficient Arbitrum balance", + }) + return + } + + // Calculate total steps + let steps = 1 // Deposit is always required + if (needsWrapEth) steps++ + if (needsApproval) steps++ + setTotalSteps(steps) + setCurrentStep(0) + + if (needsWrapEth) { + wrapEth(values.amount) + } else if (needsApproval) { + handleApprove({ + onSuccess: () => { + setCurrentStep((prev) => prev + 1) + handleDeposit({ + onSuccess: handleDepositSuccess, + }) + }, + }) + } else { + handleDeposit({ + onSuccess: handleDepositSuccess, + }) + } + } + + const handleDepositSuccess = (data: any) => { + setCurrentStep((prev) => prev + 1) + form.reset() + onSuccess?.() + const message = constructStartToastMessage(UpdateType.Deposit) + toast.loading(message, { + id: data.taskId, + }) + setSide(baseToken?.ticker === "USDC" ? Side.BUY : Side.SELL) + if (isTradePage && baseToken?.ticker !== "USDC") { + router.push(`/trade/${baseToken?.ticker}`) + } + } + + const { wrapEth, status: wrapStatus } = useWrapEth({ + onSuccess: () => { + setCurrentStep((prev) => prev + 1) + queryClient.invalidateQueries({ queryKey: ethBalanceQueryKey }) + queryClient.invalidateQueries({ queryKey: wethBalanceQueryKey }) + if (needsApproval) { + handleApprove({ + onSuccess: () => { + setCurrentStep((prev) => prev + 1) + handleDeposit({ + onSuccess: handleDepositSuccess, + }) + }, + }) + } else { + handleDeposit({ + onSuccess: handleDepositSuccess, + }) + } + }, + onError: (error) => { + console.error("Error wrapping ETH:", error) + // Reset steps on error + setTotalSteps(0) + setCurrentStep(0) + }, + }) + + const { data: maintenanceMode } = useMaintenanceMode() + + let buttonText = "" + if (needsWrapEth) { + if (wrapStatus === "pending") { + buttonText = `Confirm in wallet (${currentStep + 1} / ${totalSteps})` + } else if (needsApproval) { + buttonText = "Wrap, Approve & Deposit" + } else { + buttonText = "Wrap ETH & Deposit" + } + } else if (needsApproval) { + if (approveStatus === "pending") { + buttonText = `Confirm in wallet (${currentStep + 1} / ${totalSteps})` + } else { + buttonText = "Approve & Deposit" + } + } else { + if (depositStatus === "pending") { + buttonText = `Confirm in wallet (${currentStep + 1} / ${totalSteps})` + } else { + buttonText = "Deposit" + } + } + + return ( +
+ +
+
+ ( + + Token + + + + )} + /> +
+
+ ( + + Amount + +
+ + {!hideMaxButton && ( + + )} +
+
+ +
+ )} + /> +
+
+ Arbitrum Balance +
+
+ +  +  + + + + + + {ethBalance?.value && + minEthToKeepUnwrapped > ethBalance.value + ? "Not enough ETH to wrap" + : `${formattedEthBalance} ETH`} + + +
+
+ + {needsWrapEth && ( + + )} +
+
+ {isDesktop ? ( + + + + + + + {`Transfers are temporarily disabled${maintenanceMode?.reason ? ` ${maintenanceMode.reason}` : ""}.`} + + + + ) : ( + + + + + + + + + + {`Transfers are temporarily disabled${maintenanceMode?.reason ? ` ${maintenanceMode.reason}` : ""}.`} + + + + )} +
+ + ) +} diff --git a/components/dialogs/transfer/wrap-eth-warning.tsx b/components/dialogs/transfer/wrap-eth-warning.tsx new file mode 100644 index 00000000..4b2a56f7 --- /dev/null +++ b/components/dialogs/transfer/wrap-eth-warning.tsx @@ -0,0 +1,73 @@ +import { Token } from "@renegade-fi/react" +import { AlertTriangle } from "lucide-react" +import { formatUnits } from "viem/utils" + +import { + ResponsiveTooltip, + ResponsiveTooltipContent, + ResponsiveTooltipTrigger, +} from "@/components/ui/responsive-tooltip" + +import { useUSDPrice } from "@/hooks/use-usd-price" +import { formatCurrencyFromString, formatNumber } from "@/lib/format" +import { cn } from "@/lib/utils" + +export function WrapEthWarning({ + remainingEthBalance, + minEthToKeepUnwrapped, +}: { + remainingEthBalance: bigint + minEthToKeepUnwrapped: bigint +}) { + const formattedRemainingEthBalance = formatNumber( + remainingEthBalance, + 18, + true, + ) + + const weth = Token.findByTicker("WETH") + const usdValue = useUSDPrice(weth, remainingEthBalance) + const formattedUsdValue = formatUnits(usdValue, weth.decimals) + const formattedUsdValueLabel = formatCurrencyFromString(formattedUsdValue) + + let className: string + let mainText: string + let tooltipText: string + + if (remainingEthBalance < BigInt(0)) { + className = "bg-[#2A0000] text-red-400" + mainText = "Insufficient ETH balance" + tooltipText = `You don't have enough ETH to cover the transaction and gas fees.` + } else if (remainingEthBalance === BigInt(0)) { + className = "bg-[#2A0000] text-red-500" + mainText = "Transaction will result in 0 ETH balance" + tooltipText = "You will have no ETH left to cover gas fees." + } else if (remainingEthBalance < minEthToKeepUnwrapped) { + className = "bg-[#2A0000] text-red-400" + mainText = "Transaction will result in very low ETH balance" + tooltipText = `You will have ${formattedRemainingEthBalance} ETH (${formattedUsdValueLabel}) after wrapping` + } else { + className = "bg-[#2A1700] text-orange-400" + mainText = `Transaction will wrap ETH to WETH` + tooltipText = `You will have ${formattedRemainingEthBalance} ETH (${formattedUsdValueLabel}) after wrapping` + } + + return ( +
+ + e.preventDefault()}> +
+ + {mainText} +
+
+ {tooltipText} +
+
+ ) +} diff --git a/hooks/use-base-per-usd-price.ts b/hooks/use-base-per-usd-price.ts new file mode 100644 index 00000000..660b8e10 --- /dev/null +++ b/hooks/use-base-per-usd-price.ts @@ -0,0 +1,18 @@ +import { Token } from "@renegade-fi/react" +import { parseUnits } from "viem/utils" + +import { usePriceQuery } from "@/hooks/use-price-query" + +// Returns the price of the base token in quote token terms, denominated in the base token's decimals +export function useBasePerQuotePrice(baseMint: `0x${string}`) { + const token = Token.findByAddress(baseMint) + const { data: usdPerBasePrice } = usePriceQuery(baseMint) + if (!usdPerBasePrice) { + return null + } + + // Use Math.ceil to prevent division by zero + const basePerQuotePrice = + parseUnits("1", token.decimals) / BigInt(Math.ceil(usdPerBasePrice)) + return basePerQuotePrice +} diff --git a/hooks/use-wrap-eth.ts b/hooks/use-wrap-eth.ts new file mode 100644 index 00000000..78558b3c --- /dev/null +++ b/hooks/use-wrap-eth.ts @@ -0,0 +1,67 @@ +import React from "react" + +import { Token } from "@renegade-fi/react" +import { toast } from "sonner" +import { parseEther } from "viem" +import { BaseError, useWaitForTransactionReceipt } from "wagmi" + +import { useWriteWethDeposit } from "@/lib/generated" + +export function useWrapEth({ + onSuccess, + onError, +}: { + onSuccess: () => void + onError: (error: Error) => void +}) { + const weth = Token.findByTicker("WETH") + const [isConfirmed, setIsConfirmed] = React.useState(false) + + const { + writeContract, + data: hash, + status: writeStatus, + } = useWriteWethDeposit({ + mutation: { + onError: (error) => { + console.error("Error wrapping ETH", error) + toast.error( + `Error wrapping ETH: ${(error as BaseError).shortMessage || error.message}`, + ) + onError?.(error) + }, + }, + }) + + const { status: txStatus } = useWaitForTransactionReceipt({ + hash, + }) + + const wrapEth = React.useCallback( + (amount: string) => { + const parsedAmount = parseEther(amount) + console.log("wrapping", parsedAmount) + writeContract({ + address: weth?.address, + value: parsedAmount, + }) + }, + [weth?.address, writeContract], + ) + + React.useEffect(() => { + if (txStatus === "success" && !isConfirmed) { + setIsConfirmed(true) + onSuccess?.() + } + }, [txStatus, isConfirmed, onSuccess]) + + const status = hash ? txStatus : writeStatus + + return { + wrapEth, + status, + hash, + isConfirmed, + } +} diff --git a/lib/generated.ts b/lib/generated.ts index 262469c4..4feeec17 100644 --- a/lib/generated.ts +++ b/lib/generated.ts @@ -43,6 +43,20 @@ export const erc20Abi = [ }, ] as const +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// weth +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const wethAbi = [ + { + type: "function", + inputs: [], + name: "deposit", + outputs: [], + stateMutability: "payable", + }, +] as const + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Action ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -96,6 +110,34 @@ export const simulateErc20Approve = /*#__PURE__*/ createSimulateContract({ functionName: "approve", }) +/** + * Wraps __{@link writeContract}__ with `abi` set to __{@link wethAbi}__ + */ +export const writeWeth = /*#__PURE__*/ createWriteContract({ abi: wethAbi }) + +/** + * Wraps __{@link writeContract}__ with `abi` set to __{@link wethAbi}__ and `functionName` set to `"deposit"` + */ +export const writeWethDeposit = /*#__PURE__*/ createWriteContract({ + abi: wethAbi, + functionName: "deposit", +}) + +/** + * Wraps __{@link simulateContract}__ with `abi` set to __{@link wethAbi}__ + */ +export const simulateWeth = /*#__PURE__*/ createSimulateContract({ + abi: wethAbi, +}) + +/** + * Wraps __{@link simulateContract}__ with `abi` set to __{@link wethAbi}__ and `functionName` set to `"deposit"` + */ +export const simulateWethDeposit = /*#__PURE__*/ createSimulateContract({ + abi: wethAbi, + functionName: "deposit", +}) + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // React ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -152,3 +194,33 @@ export const useSimulateErc20Approve = /*#__PURE__*/ createUseSimulateContract({ abi: erc20Abi, functionName: "approve", }) + +/** + * Wraps __{@link useWriteContract}__ with `abi` set to __{@link wethAbi}__ + */ +export const useWriteWeth = /*#__PURE__*/ createUseWriteContract({ + abi: wethAbi, +}) + +/** + * Wraps __{@link useWriteContract}__ with `abi` set to __{@link wethAbi}__ and `functionName` set to `"deposit"` + */ +export const useWriteWethDeposit = /*#__PURE__*/ createUseWriteContract({ + abi: wethAbi, + functionName: "deposit", +}) + +/** + * Wraps __{@link useSimulateContract}__ with `abi` set to __{@link wethAbi}__ + */ +export const useSimulateWeth = /*#__PURE__*/ createUseSimulateContract({ + abi: wethAbi, +}) + +/** + * Wraps __{@link useSimulateContract}__ with `abi` set to __{@link wethAbi}__ and `functionName` set to `"deposit"` + */ +export const useSimulateWethDeposit = /*#__PURE__*/ createUseSimulateContract({ + abi: wethAbi, + functionName: "deposit", +}) diff --git a/wagmi.config.ts b/wagmi.config.ts index 6ad5c952..20db1dcf 100644 --- a/wagmi.config.ts +++ b/wagmi.config.ts @@ -8,6 +8,8 @@ const abi = parseAbi([ "function allowance(address owner, address spender) view returns (uint256)", ]) +const wethAbi = parseAbi(["function deposit() payable"]) + export default defineConfig({ out: "lib/generated.ts", contracts: [ @@ -15,6 +17,10 @@ export default defineConfig({ name: "erc20", abi, }, + { + name: "weth", + abi: wethAbi, + }, ], plugins: [actions(), react()], })