diff --git a/apps/dashboard/src/@/components/ui/decimal-input.tsx b/apps/dashboard/src/@/components/ui/decimal-input.tsx index 2c9f251c3c5..ee942675c15 100644 --- a/apps/dashboard/src/@/components/ui/decimal-input.tsx +++ b/apps/dashboard/src/@/components/ui/decimal-input.tsx @@ -5,6 +5,7 @@ export function DecimalInput(props: { maxValue?: number; id?: string; className?: string; + placeholder?: string; }) { return ( { const number = Number(e.target.value); // ignore if string becomes invalid number diff --git a/apps/dashboard/src/app/nebula-app/(app)/components/ChatPageLayout.tsx b/apps/dashboard/src/app/nebula-app/(app)/components/ChatPageLayout.tsx index 428132cf200..7c224c3712a 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/components/ChatPageLayout.tsx +++ b/apps/dashboard/src/app/nebula-app/(app)/components/ChatPageLayout.tsx @@ -1,5 +1,8 @@ import { cn } from "@/lib/utils"; +import { TWAutoConnect } from "../../../(app)/components/autoconnect"; +import { nebulaAAOptions } from "../../login/account-abstraction"; import type { TruncatedSessionInfo } from "../api/types"; +import { nebulaAppThirdwebClient } from "../utils/nebulaThirdwebClient"; import { ChatSidebar } from "./ChatSidebar"; import { MobileNav } from "./NebulaMobileNav"; @@ -27,6 +30,11 @@ export function ChatPageLayout(props: { + + {props.children} ); diff --git a/apps/dashboard/src/app/nebula-app/move-funds/connect-button.tsx b/apps/dashboard/src/app/nebula-app/move-funds/connect-button.tsx new file mode 100644 index 00000000000..e4d060ad406 --- /dev/null +++ b/apps/dashboard/src/app/nebula-app/move-funds/connect-button.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { ConnectButton } from "thirdweb/react"; +import { inAppWallet } from "thirdweb/wallets"; +import { getSDKTheme } from "../../(app)/components/sdk-component-theme"; +import { getClientThirdwebClient } from "../../../@/constants/thirdweb-client.client"; +import { nebulaAAOptions } from "../login/account-abstraction"; + +// use dashboard client to allow users to connect to their original wallet and move funds to a different wallet +const dashboardClient = getClientThirdwebClient(); + +// since only the inApp and smart wallets were affected, only show in-app option +const loginOptions = [ + inAppWallet({ + auth: { + options: [ + "google", + "apple", + "facebook", + "github", + "email", + "phone", + "passkey", + ], + }, + }), +]; + +// Note: This component has autoConnect enabled +export function MoveFundsConnectButton(props: { + btnClassName?: string; + connectLabel?: string; +}) { + const { theme } = useTheme(); + + return ( + + ); +} diff --git a/apps/dashboard/src/app/nebula-app/move-funds/move-funds.tsx b/apps/dashboard/src/app/nebula-app/move-funds/move-funds.tsx new file mode 100644 index 00000000000..acacedf8d06 --- /dev/null +++ b/apps/dashboard/src/app/nebula-app/move-funds/move-funds.tsx @@ -0,0 +1,1005 @@ +"use client"; +import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; +import { Button } from "@/components/ui/button"; +import { DecimalInput } from "@/components/ui/decimal-input"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { isProd } from "@/constants/env-utils"; +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import { cn } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + CheckIcon, + ChevronDownIcon, + WalletIcon as LucideWalletIcon, + PlusIcon, + SearchIcon, + TrashIcon, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { + type Chain, + NATIVE_TOKEN_ADDRESS, + defineChain, + getAddress, + getContract, + isAddress, + prepareTransaction, + toWei, +} from "thirdweb"; +import { transfer } from "thirdweb/extensions/erc20"; +import { + AccountAvatar, + AccountBlobbie, + AccountProvider, + Blobbie, + TokenIcon, + TokenProvider, + WalletIcon, + WalletName, + WalletProvider, + useActiveAccount, + useActiveWallet, + useActiveWalletChain, + useConnectedWallets, + useSendAndConfirmTransaction, + useSendBatchTransaction, + useSetActiveWallet, + useWalletBalance, +} from "thirdweb/react"; +import { shortenAddress, toTokens } from "thirdweb/utils"; +import { type Account, type Wallet, getWalletBalance } from "thirdweb/wallets"; +import { z } from "zod"; +import { ScrollShadow } from "../../../@/components/ui/ScrollShadow/ScrollShadow"; +import { Label } from "../../../@/components/ui/label"; +import { TabButtons } from "../../../@/components/ui/tabs"; +import { ToolTipLabel } from "../../../@/components/ui/tooltip"; +import { TransactionButton } from "../../../components/buttons/TransactionButton"; +import { parseError } from "../../../utils/errorParser"; +import { MoveFundsConnectButton } from "./connect-button"; + +// This is the important part - using dashboard client instead of nebula client to allow users to +// to connect to their original wallet and move funds to a different wallet +const dashboardClient = getClientThirdwebClient(); + +export function MoveFundsPage() { + const activeChain = useActiveWalletChain(); + const activeAccount = useActiveAccount(); + const activeWallet = useActiveWallet(); + + return ( +
+
+ {/* header */} +
+
+
+ +
+
+

+ Move funds +

+

+ We've updated Nebula login to be separate from thirdweb dashboard on + May 30, 2025, 12:00 PM PST
+ Users using social login are now assigned a different wallet address + in Nebula app
+

+

+ If you added any funds to the generated In-App wallet or Smart + wallet before this update, you can still access them and move funds + to a different wallet using this page +

+
+ + {!activeWallet && ( +
+ +

+ Sign in to continue +

+
+ )} + + {activeWallet && activeChain && activeAccount && ( +
+ +
+ )} +
+
+ ); +} + +function WalletDetails(props: { + wallet: Wallet; + account: Account; +}) { + const accountBlobbie = ; + const accountAvatarFallback = ( + + ); + + return ( + + +
+ +
+
+ {shortenAddress(props.account.address)} +
+
+ {props.wallet.id === "smart" ? ( + Smart Wallet + ) : ( + Wallet} + loadingComponent={Wallet} + /> + )} +
+
+
+
+
+ ); +} + +const formSchema = z.object({ + chainId: z.number(), + tokens: z.array( + z.object({ + token: z + .object({ + token_address: z.string(), + balance: z.string(), + name: z.string(), + symbol: z.string(), + decimals: z.number(), + }) + .nullable(), + amount: z.string().refine((val) => { + const amount = Number.parseFloat(val); + return !Number.isNaN(amount) && amount > 0; + }, "Amount must be a positive number"), + }), + ), + receiverAddress: z.string().refine((val) => { + if (isAddress(val)) { + return true; + } + return false; + }, "Invalid address"), +}); + +type FormValues = z.infer; + +function SendFunds(props: { + accountAddress: string; + initialChainId: number; +}) { + const activeWallet = useActiveWallet(); + const activeAccount = useActiveAccount(); + const setActiveWallet = useSetActiveWallet(); + const connectedWallets = useConnectedWallets(); + const sendBatchTransactions = useSendBatchTransaction(); + const queryClient = useQueryClient(); + + // since only the inApp and smart wallets were affected, filter out all other wallets + const filteredConnectedWallets = connectedWallets; + // .filter( + // (w) => w.id === "inApp" || w.id === "smart", + // );; + + const sendAndConfirmTransaction = useSendAndConfirmTransaction(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + chainId: props.initialChainId, + tokens: [{ token: null, amount: "" }], + receiverAddress: "", + }, + }); + + function getTokenTransferTransaction(params: { + token: WalletToken; + amount: string; + chain: Chain; + receiverAddress: string; + }) { + const { token, amount, chain, receiverAddress } = params; + + const isNativeToken = + getAddress(token.token_address) === getAddress(NATIVE_TOKEN_ADDRESS); + + if (isNativeToken) { + return prepareTransaction({ + chain, + client: dashboardClient, + to: receiverAddress, + value: toWei(amount), + }); + } + + const erc20Contract = getContract({ + address: token.token_address, + chain, + client: dashboardClient, + }); + + return transfer({ + to: receiverAddress, + amount: amount, + contract: erc20Contract, + }); + } + + async function handleBatchSubmit(params: { + tokens: { token: WalletToken; amount: string }[]; + receiverAddress: string; + chain: Chain; + activeAccount: Account; + }) { + const { tokens, chain, receiverAddress } = params; + toast.loading(`Sending ${tokens.length} Tokens`); + + const transactions = tokens.map(({ token, amount }) => { + return getTokenTransferTransaction({ + token, + amount, + chain, + receiverAddress, + }); + }); + + try { + await sendBatchTransactions.mutateAsync(transactions); + toast.success("Tokens sent successfully", { + duration: 10000, + }); + } catch (e) { + toast.error("Failed to send tokens", { + description: parseError(e), + duration: 10000, + }); + console.error(e); + } + } + + async function handleSingleSubmit(params: { + tokens: { token: WalletToken; amount: string }[]; + receiverAddress: string; + chain: Chain; + activeAccount: Account; + }) { + const { tokens, chain, receiverAddress } = params; + let successCount = 0; + + for (const { token, amount } of tokens.values()) { + try { + const tx = getTokenTransferTransaction({ + token, + amount, + chain, + receiverAddress, + }); + + toast.loading(`Sending Token ${token.name}`); + await sendAndConfirmTransaction.mutateAsync(tx); + + toast.success(`${token.name} sent successfully`, { + duration: 10000, + }); + queryClient.invalidateQueries({ + queryKey: ["walletBalance"], + }); + + successCount++; + } catch (e) { + toast.error(`Failed to send ${token.name}`, { + description: parseError(e), + duration: 10000, + }); + } + } + + if (tokens.length > 1) { + if (successCount === tokens.length) { + toast.success("All tokens sent successfully", { + id: "batch-send", + duration: 10000, + }); + } else { + toast.error(`Failed to send ${tokens.length - successCount} tokens`, { + id: "batch-send", + duration: 10000, + }); + } + } + } + + async function handleSubmit(values: FormValues) { + if (!activeAccount) { + toast.error("Wallet is not connected"); + return; + } + + // Filter out tokens that are not selected + const validTokens = values.tokens.filter( + (t): t is { token: WalletToken; amount: string } => t.token !== null, + ); + + if (validTokens.length === 0) { + toast.error("Please select at least one token"); + return; + } + + // eslint-disable-next-line no-restricted-syntax + const chain = defineChain(values.chainId); + + if (activeAccount.sendBatchTransaction) { + await handleBatchSubmit({ + tokens: validTokens, + chain, + activeAccount, + receiverAddress: values.receiverAddress, + }); + } else { + await handleSingleSubmit({ + tokens: validTokens, + chain, + activeAccount, + receiverAddress: values.receiverAddress, + }); + } + + queryClient.invalidateQueries({ + queryKey: ["walletBalance"], + }); + } + + const isPending = + sendAndConfirmTransaction.isPending || sendBatchTransactions.isPending; + + return ( +
+
+ +
+ + +
+ + ( + + Chain + + { + field.onChange(chain); + form.setValue("tokens", [{ token: null, amount: "" }]); + }} + className="bg-background" + /> + + + + )} + /> + +
+ +
+ {form.watch("tokens").map((tokenForm, index) => ( +
+
+ ( + + Amount + +
+ +
+ { + form.getValues(`tokens.${index}.token`) + ?.symbol + } +
+
+
+ + +
+ )} + /> + + ( + + Token + + { + field.onChange(token); + }} + /> + + + + )} + /> + + {form.watch("tokens").length > 1 && ( + + + + )} +
+ + {form.getValues(`tokens.${index}.token`) && ( +
+ Balance:{" "} + +
+ )} +
+ ))} +
+
+ +
+ +
+ + ( + + Send to + + + + + + )} + /> + +
+ + Move Funds + +
+ + +
+ ); +} + +function TokenSelectorPopover(props: { + token: WalletToken | null; + setToken: (token: WalletToken) => void; + chainId: number; + accountAddress: string; +}) { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + return ( + + + + + + { + props.setToken(token); + setIsPopoverOpen(false); + }} + selectedToken={props.token || undefined} + /> + + + ); +} + +function RenderTokenBalance(props: { + accountAddress: string; + tokenAddress: string; + chainId: number; + className?: string; +}) { + const balanceQuery = useWalletBalance({ + address: props.accountAddress, + // eslint-disable-next-line no-restricted-syntax + chain: defineChain(props.chainId), + client: dashboardClient, + tokenAddress: + getAddress(props.tokenAddress) === getAddress(NATIVE_TOKEN_ADDRESS) + ? undefined + : props.tokenAddress, + }); + + if (!balanceQuery.data) { + return ; + } + + return ( +
+ {toTokens(balanceQuery.data.value, balanceQuery.data.decimals)}{" "} + {balanceQuery.data.symbol} +
+ ); +} + +function TokenSelectorPopoverContent(props: { + accountAddress: string; + chainId: number; + onSelect: (token: WalletToken) => void; + selectedToken: WalletToken | undefined; +}) { + const [search, setSearch] = useState(""); + const [manualAddress, setManualAddress] = useState(""); + const walletTokensQuery = useWalletTokens({ + address: props.accountAddress, + chainId: props.chainId, + }); + + const manualTokenQuery = useQuery({ + queryKey: [ + "manual-token", + manualAddress, + props.chainId, + props.accountAddress, + ], + queryFn: async () => { + const balance = await getWalletBalance({ + address: props.accountAddress, + // eslint-disable-next-line no-restricted-syntax + chain: defineChain(props.chainId), + client: dashboardClient, + tokenAddress: + getAddress(manualAddress) === getAddress(NATIVE_TOKEN_ADDRESS) + ? undefined + : manualAddress, + }); + + const tokenInfo: WalletToken = { + token_address: manualAddress, + balance: balance.value.toString(), + name: balance.name, + symbol: balance.symbol, + decimals: balance.decimals, + }; + + return tokenInfo; + }, + enabled: isAddress(manualAddress), + }); + + const tokensToShow = useMemo(() => { + return (walletTokensQuery.data || []).filter((token) => { + return ( + token.name.toLowerCase().includes(search.toLowerCase()) || + token.symbol.toLowerCase().includes(search.toLowerCase()) + ); + }); + }, [search, walletTokensQuery.data]); + + const [tab, setTab] = useState<"owned" | "custom">("owned"); + + return ( +
+ setTab("owned"), + isActive: tab === "owned", + }, + { + name: "Custom", + onClick: () => setTab("custom"), + isActive: tab === "custom", + }, + ]} + /> + + {tab === "custom" && ( +
+
+ + setManualAddress(e.target.value)} + /> +
+ +
+ {manualAddress && !isAddress(manualAddress) && ( +
+

Invalid address

+
+ )} + +
+ {manualTokenQuery.data && ( + + )} + + {manualTokenQuery.isFetching && !manualTokenQuery.data && ( + + )} +
+
+
+ )} + + {tab === "owned" && ( +
+
+ + setSearch(e.target.value)} + /> +
+ + + +
+ )} +
+ ); +} + +function TokenList(props: { + accountAddress: string; + chainId: number; + className?: string; + onSelect: (token: WalletToken) => void; + selectedToken: WalletToken | undefined; + isPending: boolean; + tokens: WalletToken[]; +}) { + return ( +
+ {props.isPending && + new Array(10).fill(0).map((_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: + + ))} + + {!props.isPending && ( +
+ {props.tokens.length > 0 ? ( + props.tokens.map((token) => { + return ( + + ); + }) + ) : ( +
+ No tokens found +
+ )} +
+ )} +
+ ); +} + +function TokenDetails(props: { + token: WalletToken; + chainId: number; + showBalance: boolean; + iconClassName?: string; + className?: string; + accountAddress: string; +}) { + const blobbie = ( + + ); + + return ( +
+ + + +
+
{props.token.name}
+ {props.showBalance && ( + + )} +
+
+ ); +} + +function TokenSkeletonRow() { + return ( +
+ +
+ + +
+
+ ); +} + +type WalletToken = { + // chain_id: number; + token_address: string; + // owner_address: string; + balance: string; + name: string; + symbol: string; + decimals: number; +}; + +function useWalletTokens(params: { + address: string; + chainId: number; +}) { + return useQuery({ + queryKey: ["v1/tokens", params], + queryFn: async () => { + const url = new URL( + `https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/tokens`, + ); + url.searchParams.set("owner_address", params.address); + url.searchParams.set("chain_id", params.chainId.toString()); + url.searchParams.set("clientId", dashboardClient.clientId); + url.searchParams.set("limit", "100"); + url.searchParams.set("metadata", "true"); + url.searchParams.set("resolve_metadata_links", "true"); + url.searchParams.set("include_spam", "false"); + url.searchParams.set("include_native", "true"); + + const response = await fetch(url); + const json = (await response.json()) as { + data: WalletToken[]; + }; + + // There's a bug in v1/tokens endpoint where it returns token multiple times + // This is a temporary fix to remove duplicates + const uniqueTokens = json.data.filter( + (token, index, self) => + index === + self.findIndex( + (t) => + t.token_address === token.token_address && + t.name === token.name && + t.symbol === token.symbol, + ), + ); + + return uniqueTokens; + }, + }); +} diff --git a/apps/dashboard/src/app/nebula-app/move-funds/page.tsx b/apps/dashboard/src/app/nebula-app/move-funds/page.tsx new file mode 100644 index 00000000000..13a06db7ef5 --- /dev/null +++ b/apps/dashboard/src/app/nebula-app/move-funds/page.tsx @@ -0,0 +1,36 @@ +import { ToggleThemeButton } from "@/components/color-mode-toggle"; +import Link from "next/link"; +import { NebulaIcon } from "../(app)/icons/NebulaIcon"; +import { MoveFundsConnectButton } from "./connect-button"; +import { MoveFundsPage } from "./move-funds"; + +export default function RecoverPage() { + return ( +
+
+
+
+ + Nebula +
+ +
+ + + + Support + + + +
+
+
+ + +
+ ); +} diff --git a/apps/dashboard/src/app/nebula-app/providers.tsx b/apps/dashboard/src/app/nebula-app/providers.tsx index ff53d0258b0..532bb20ab06 100644 --- a/apps/dashboard/src/app/nebula-app/providers.tsx +++ b/apps/dashboard/src/app/nebula-app/providers.tsx @@ -6,10 +6,7 @@ import { ThemeProvider, useTheme } from "next-themes"; import { useMemo } from "react"; import { Toaster } from "sonner"; import { ThirdwebProvider, useActiveAccount } from "thirdweb/react"; -import { TWAutoConnect } from "../(app)/components/autoconnect"; import { NebulaConnectWallet } from "./(app)/components/NebulaConnectButton"; -import { nebulaAppThirdwebClient } from "./(app)/utils/nebulaThirdwebClient"; -import { nebulaAAOptions } from "./login/account-abstraction"; const queryClient = new QueryClient(); @@ -17,10 +14,6 @@ export function NebulaProviders(props: { children: React.ReactNode }) { return ( - & { isPending: boolean; checkBalance?: boolean; client: ThirdwebClient; + disableNoFundsPopup: boolean; }; export const MismatchButton = forwardRef< @@ -98,6 +99,7 @@ export const MismatchButton = forwardRef< isLoggedIn, isPending, checkBalance = true, + disableNoFundsPopup, ...buttonProps } = props; const account = useActiveAccount(); @@ -162,6 +164,7 @@ export const MismatchButton = forwardRef< } const isBalanceRequired = + !disableNoFundsPopup && checkBalance && (wallet.id === "smart" ? false : !GAS_FREE_CHAINS.includes(txChainId)); @@ -175,6 +178,7 @@ export const MismatchButton = forwardRef< (!showSwitchChainPopover && txChainBalance.isPending && isBalanceRequired); const showSpinner = isPending || switchNetworkMutation.isPending; + const showNoFundsPopup = notEnoughBalance && !disableNoFundsPopup; return ( <> @@ -196,7 +200,7 @@ export const MismatchButton = forwardRef< className={cn("gap-2 disabled:opacity-100", buttonProps.className)} disabled={disabled} type={ - showSwitchChainPopover || notEnoughBalance + showSwitchChainPopover || showNoFundsPopup ? "button" : buttonProps.type } @@ -208,7 +212,7 @@ export const MismatchButton = forwardRef< return; } - if (notEnoughBalance) { + if (notEnoughBalance && !props.disableNoFundsPopup) { trackEvent({ category: "no-funds", action: "popover", diff --git a/apps/dashboard/src/components/buttons/TransactionButton.tsx b/apps/dashboard/src/components/buttons/TransactionButton.tsx index 70042e8eefa..47b94337276 100644 --- a/apps/dashboard/src/components/buttons/TransactionButton.tsx +++ b/apps/dashboard/src/components/buttons/TransactionButton.tsx @@ -32,6 +32,7 @@ type TransactionButtonProps = Omit & { isLoggedIn: boolean; checkBalance?: boolean; client: ThirdwebClient; + disableNoFundsPopup?: boolean; }; export const TransactionButton: React.FC = ({ @@ -43,6 +44,7 @@ export const TransactionButton: React.FC = ({ isLoggedIn, checkBalance, client, + disableNoFundsPopup, ...restButtonProps }) => { const activeWallet = useActiveWallet(); @@ -68,6 +70,9 @@ export const TransactionButton: React.FC = ({