diff --git a/apps/dashboard/src/@/components/blocks/wallet-address.tsx b/apps/dashboard/src/@/components/blocks/wallet-address.tsx index 82db53e6476..163c9b95ce9 100644 --- a/apps/dashboard/src/@/components/blocks/wallet-address.tsx +++ b/apps/dashboard/src/@/components/blocks/wallet-address.tsx @@ -51,17 +51,12 @@ export function WalletAddressUI( }; }, ) { - // default back to zero address if no address provided - const address = useMemo(() => props.address || ZERO_ADDRESS, [props.address]); - - const [shortenedAddress, _lessShortenedAddress] = useMemo(() => { - return [ - props.shortenAddress !== false - ? `${address.slice(0, 6)}...${address.slice(-4)}` - : address, - `${address.slice(0, 14)}...${address.slice(-12)}`, - ]; - }, [address, props.shortenAddress]); + const address = props.address || ZERO_ADDRESS; + + const shortenedAddress = + props.shortenAddress !== false + ? `${address.slice(0, 6)}...${address.slice(-4)}` + : address; if (!isAddress(address)) { return ( diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/ProjectFTUX.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/ProjectFTUX.stories.tsx index ec7a9704b41..e48d6d82575 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/ProjectFTUX.stories.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/ProjectFTUX.stories.tsx @@ -4,9 +4,14 @@ import { ProjectFTUX } from "./ProjectFTUX"; const meta = { component: ProjectFTUX, + parameters: { + nextjs: { + appDirectory: true, + }, + }, decorators: [ (Story) => ( -
+
), @@ -31,6 +36,5 @@ export const Default: Story = { ], }, teamSlug: "bar", - managementAccessToken: undefined, }, }; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/ProjectFTUX.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/ProjectFTUX.tsx index 144415b5441..f780934040f 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/ProjectFTUX.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/ProjectFTUX.tsx @@ -15,33 +15,12 @@ import { UnrealIcon } from "@/icons/brand-icons/UnrealIcon"; import { ContractIcon } from "@/icons/ContractIcon"; import { InsightIcon } from "@/icons/InsightIcon"; import { PayIcon } from "@/icons/PayIcon"; -import { WalletProductIcon } from "@/icons/WalletProductIcon"; -import { getProjectWalletLabel } from "@/lib/project-wallet"; -import { - getProjectWallet, - type ProjectWalletSummary, -} from "@/lib/server/project-wallet"; import { ClientIDSection } from "./ClientIDSection"; -import { ProjectWalletControls } from "./ProjectWalletControls.client"; -import { ProjectWalletSetup } from "./ProjectWalletSetup.client"; import { SecretKeySection } from "./SecretKeySection"; -export async function ProjectFTUX(props: { - project: Project; - teamSlug: string; - wallet?: ProjectWalletSummary | undefined; - managementAccessToken: string | undefined; -}) { - const projectWallet = props.wallet ?? (await getProjectWallet(props.project)); - +export function ProjectFTUX(props: { project: Project; teamSlug: string }) { return (
- -
-
-
-
- -
-
-

- Project Wallet -

-

- Use it for deployments, payments, and API integrations. -

-
-
- - {walletAddress ? ( - <> - -
- - View Transactions - - -
- - ) : ( - - )} -
-
- - ); -} - // Integrate API key section ------------------------------------------------------------ function IntegrateAPIKeySection({ project }: { project: Project }) { diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/ProjectWalletControls.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/ProjectWalletControls.client.tsx deleted file mode 100644 index eb9ea652eba..00000000000 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/ProjectWalletControls.client.tsx +++ /dev/null @@ -1,747 +0,0 @@ -"use client"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - EllipsisVerticalIcon, - RefreshCcwIcon, - SendIcon, - ShuffleIcon, - WalletIcon, -} from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "sonner"; -import { toWei } from "thirdweb"; -import { useWalletBalance } from "thirdweb/react"; -import { isAddress } from "thirdweb/utils"; -import { z } from "zod"; -import { listProjectServerWallets } from "@/actions/project-wallet/list-server-wallets"; -import { sendProjectWalletTokens } from "@/actions/project-wallet/send-tokens"; -import type { Project } from "@/api/project/projects"; -import { FundWalletModal } from "@/components/blocks/fund-wallets-modal"; -import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; -import { WalletAddress } from "@/components/blocks/wallet-address"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { Spinner } from "@/components/ui/Spinner"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; -import { useV5DashboardChain } from "@/hooks/chains/v5-adapter"; -import { useDashboardRouter } from "@/lib/DashboardRouter"; -import type { ProjectWalletSummary } from "@/lib/server/project-wallet"; -import { cn } from "@/lib/utils"; -import { updateDefaultProjectWallet } from "../../transactions/lib/vault.client"; - -type ProjectWalletControlsProps = { - walletAddress: string; - label: string; - project: Pick< - Project, - "id" | "publishableKey" | "teamId" | "services" | "name" - >; - defaultChainId?: number; -}; - -export function ProjectWalletControls(props: ProjectWalletControlsProps) { - const { walletAddress, label, project, defaultChainId } = props; - const [isSendOpen, setIsSendOpen] = useState(false); - const [isReceiveOpen, setIsReceiveOpen] = useState(false); - const [selectedChainId, setSelectedChainId] = useState(defaultChainId ?? 1); - const [isChangeWalletOpen, setIsChangeWalletOpen] = useState(false); - const [selectedWalletId, setSelectedWalletId] = useState( - undefined, - ); - - const client = useMemo(() => getClientThirdwebClient(), []); - const chain = useV5DashboardChain(selectedChainId); - const queryClient = useQueryClient(); - const router = useDashboardRouter(); - - const engineCloudService = useMemo( - () => project.services?.find((service) => service.name === "engineCloud"), - [project.services], - ); - const managementAccessToken = - engineCloudService?.managementAccessToken ?? undefined; - const isManagedVault = !!engineCloudService?.encryptedAdminKey; - - const serverWalletsQuery = useQuery({ - enabled: !!managementAccessToken, - queryFn: async () => { - if (!managementAccessToken) { - return [] as ProjectWalletSummary[]; - } - - return listProjectServerWallets({ - managementAccessToken, - projectId: project.id, - }); - }, - queryKey: ["project", project.id, "server-wallets", managementAccessToken], - staleTime: 60_000, - }); - - const serverWallets = serverWalletsQuery.data ?? []; - const currentWalletAddressLower = walletAddress.toLowerCase(); - const otherWallets = useMemo(() => { - return serverWallets.filter((wallet) => { - return wallet.address.toLowerCase() !== currentWalletAddressLower; - }); - }, [serverWallets, currentWalletAddressLower]); - - const canChangeWallet = otherWallets.length > 0; - - const handleOpenChangeWallet = () => { - const currentWallet = serverWallets.find( - (wallet) => wallet.address.toLowerCase() === currentWalletAddressLower, - ); - - const nextWalletId = - otherWallets[0]?.id ?? currentWallet?.id ?? serverWallets[0]?.id; - - setSelectedWalletId(nextWalletId); - setIsChangeWalletOpen(true); - }; - - const handleCloseChangeWallet = () => { - setIsChangeWalletOpen(false); - setSelectedWalletId(undefined); - }; - - const selectedWallet = useMemo(() => { - if (!selectedWalletId) { - return undefined; - } - - return serverWallets.find((wallet) => wallet.id === selectedWalletId); - }, [selectedWalletId, serverWallets]); - - const isSelectionDifferent = Boolean( - selectedWallet && - selectedWallet.address.toLowerCase() !== currentWalletAddressLower, - ); - - const changeWalletMutation = useMutation({ - mutationFn: async (wallet: ProjectWalletSummary) => { - await updateDefaultProjectWallet({ - project: project as Project, - projectWalletAddress: wallet.address, - }); - }, - onError: (error) => { - const message = - error instanceof Error - ? error.message - : "Failed to update project wallet"; - toast.error(message); - }, - onSuccess: async (_, wallet) => { - const descriptionLabel = wallet.label ?? wallet.address; - toast.success("Project wallet updated", { - description: `Now pointing to ${descriptionLabel}`, - }); - await queryClient.invalidateQueries({ - queryKey: [ - "project", - project.id, - "server-wallets", - managementAccessToken, - ], - }); - handleCloseChangeWallet(); - router.refresh(); - }, - }); - - const balanceQuery = useWalletBalance({ - address: walletAddress, - chain, - client, - }); - - const balanceDisplay = balanceQuery.data - ? `${balanceQuery.data.displayValue} ${balanceQuery.data.symbol}` - : undefined; - - return ( -
-
-
-
-
-

Address

- -
-
-

Label

-
-

- {label} -

- {canChangeWallet && ( - - )} -
-
-
- -
-
-

Balance

-
- - {balanceQuery.isLoading ? ( - - ) : balanceDisplay ? ( - - {balanceDisplay} - - ) : ( - N/A - )} - - - - - - - setIsSendOpen(true)} - > - - Send funds - - setIsReceiveOpen(true)} - > - - Receive funds - - - -
-
-
-

Network

- -
-
-
-
- - setIsSendOpen(false)} - onSuccess={() => balanceQuery.refetch()} - open={isSendOpen} - publishableKey={project.publishableKey} - teamId={project.teamId} - walletAddress={walletAddress} - /> - - - - { - if (!nextOpen) { - handleCloseChangeWallet(); - } - }} - open={isChangeWalletOpen} - > - - - Change default wallet - - Choose another server wallet to use as the default for this - project. - - - -
- {serverWalletsQuery.isLoading ? ( -
- -
- ) : serverWallets.length <= 1 ? ( -

- You need at least two server wallets to pick a different - default. -

- ) : ( -
- -
- )} -
- - - - - -
-
-
- ); -} - -const createSendFormSchema = (secretKeyLabel: string) => - z.object({ - chainId: z.number({ - required_error: "Select a network", - }), - toAddress: z - .string() - .trim() - .min(1, "Destination address is required") - .refine((value) => Boolean(isAddress(value)), { - message: "Enter a valid wallet address", - }), - amount: z.string().trim().min(1, "Amount is required"), - secretKey: z.string().trim().min(1, `${secretKeyLabel} is required`), - vaultAccessToken: z.string().trim(), - }); - -const SECRET_KEY_LABEL = "Project secret key"; - -type SendFormValues = z.infer>; - -function SendProjectWalletModal(props: { - open: boolean; - onClose: () => void; - onSuccess: () => void; - walletAddress: string; - publishableKey: string; - teamId: string; - chainId: number; - label: string; - client: ReturnType; - isManagedVault: boolean; -}) { - const { - open, - onClose, - onSuccess, - walletAddress, - publishableKey, - teamId, - chainId, - label, - client, - isManagedVault, - } = props; - - const secretKeyLabel = SECRET_KEY_LABEL; - const secretKeyPlaceholder = "Enter your project secret key"; - const secretKeyHelper = - "Your project secret key was generated when you created your project. If you lost it, regenerate one from Project settings."; - const vaultAccessTokenHelper = - "Vault access tokens are optional credentials with server wallet permissions. Manage them in Vault settings."; - - const formSchema = useMemo(() => createSendFormSchema(SECRET_KEY_LABEL), []); - - const form = useForm({ - defaultValues: { - amount: "0", - chainId, - secretKey: "", - vaultAccessToken: "", - toAddress: "", - }, - mode: "onChange", - resolver: zodResolver(formSchema), - }); - - const selectedChain = useV5DashboardChain(form.watch("chainId")); - - // eslint-disable-next-line no-restricted-syntax -- form submission chainId must track selector state - useEffect(() => { - form.setValue("chainId", chainId); - }, [chainId, form]); - - // eslint-disable-next-line no-restricted-syntax -- reset cached inputs when modal closes to avoid leaking state - useEffect(() => { - if (!open) { - const currentValues = form.getValues(); - form.reset({ - amount: "0", - chainId, - secretKey: currentValues.secretKey ?? "", - vaultAccessToken: currentValues.vaultAccessToken ?? "", - toAddress: "", - }); - } - }, [open, chainId, form]); - - const sendMutation = useMutation({ - mutationFn: async (values: SendFormValues) => { - const quantityWei = toWei(values.amount).toString(); - const secretKeyValue = values.secretKey.trim(); - const vaultAccessTokenValue = values.vaultAccessToken.trim(); - - const result = await sendProjectWalletTokens({ - chainId: values.chainId, - publishableKey, - quantityWei, - recipientAddress: values.toAddress, - teamId, - walletAddress, - secretKey: secretKeyValue, - ...(vaultAccessTokenValue - ? { vaultAccessToken: vaultAccessTokenValue } - : {}), - }); - - if (!result.ok) { - const errorMessage = - typeof result.error === "string" - ? result.error - : "Failed to send funds"; - throw new Error(errorMessage); - } - - return result.transactionIds; - }, - onError: (error) => { - toast.error(error.message); - }, - onSuccess: (transactionIds) => { - toast.success("Transfer submitted", { - description: - transactionIds && transactionIds.length > 0 - ? `Transaction ID: ${transactionIds[0]}` - : undefined, - }); - onSuccess(); - onClose(); - }, - }); - - return ( - { - if (!nextOpen) { - onClose(); - } - }} - open={open} - > - - - Send from {label} - - Execute a one-off transfer using your server wallet. - - - -
- { - void sendMutation.mutateAsync(values); - })} - > -
- ( - - Network - - { - field.onChange(nextChainId); - }} - placeholder="Select network" - /> - - - - )} - /> - - ( - - Send to - - - - - - )} - /> - - ( - - Amount - - - - - Sending native token - {selectedChain?.nativeCurrency?.symbol - ? ` (${selectedChain.nativeCurrency.symbol})` - : ""} - - - - )} - /> - - ( - - {secretKeyLabel} - - - - {secretKeyHelper} - - - )} - /> - - {!isManagedVault && ( - ( - - - Vault access token - - {" "} - (optional) - - - - - - - {vaultAccessTokenHelper} - - - - )} - /> - )} -
- -
- - -
-
- -
-
- ); -} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/ProjectWalletSetup.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/ProjectWalletSetup.client.tsx deleted file mode 100644 index e04a35fc92f..00000000000 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/ProjectWalletSetup.client.tsx +++ /dev/null @@ -1,215 +0,0 @@ -"use client"; - -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { CircleAlertIcon } from "lucide-react"; -import { useMemo, useState } from "react"; -import { toast } from "sonner"; -import { listProjectServerWallets } from "@/actions/project-wallet/list-server-wallets"; -import type { Project } from "@/api/project/projects"; -import { WalletAddress } from "@/components/blocks/wallet-address"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { Spinner } from "@/components/ui/Spinner"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; -import { useDashboardRouter } from "@/lib/DashboardRouter"; -import type { ProjectWalletSummary } from "@/lib/server/project-wallet"; -import { updateDefaultProjectWallet } from "../../transactions/lib/vault.client"; -import CreateServerWallet from "../../transactions/server-wallets/components/create-server-wallet.client"; - -export function ProjectWalletSetup(props: { - project: Project; - teamSlug: string; - managementAccessToken: string | undefined; -}) { - const { project, teamSlug, managementAccessToken } = props; - const router = useDashboardRouter(); - const queryClient = useQueryClient(); - const client = useMemo(() => getClientThirdwebClient(), []); - const [selectedWalletId, setSelectedWalletId] = useState( - undefined, - ); - - const serverWalletsQuery = useQuery({ - enabled: Boolean(managementAccessToken), - queryFn: async () => { - if (!managementAccessToken) { - return [] as ProjectWalletSummary[]; - } - - return listProjectServerWallets({ - managementAccessToken, - projectId: project.id, - }); - }, - queryKey: ["project", project.id, "server-wallets", managementAccessToken], - staleTime: 60_000, - }); - - const serverWallets = serverWalletsQuery.data ?? []; - - const effectiveSelectedWalletId = useMemo(() => { - if ( - selectedWalletId && - serverWallets.some((wallet) => wallet.id === selectedWalletId) - ) { - return selectedWalletId; - } - - return serverWallets[0]?.id; - }, [selectedWalletId, serverWallets]); - - const selectedWallet = useMemo(() => { - if (!effectiveSelectedWalletId) { - return undefined; - } - - return serverWallets.find( - (wallet) => wallet.id === effectiveSelectedWalletId, - ); - }, [effectiveSelectedWalletId, serverWallets]); - - const setDefaultWalletMutation = useMutation({ - mutationFn: async (wallet: ProjectWalletSummary) => { - await updateDefaultProjectWallet({ - project, - projectWalletAddress: wallet.address, - }); - }, - onError: (error) => { - const message = - error instanceof Error - ? error.message - : "Failed to update project wallet"; - toast.error(message); - }, - onSuccess: async (_, wallet) => { - const descriptionLabel = wallet.label ?? wallet.address; - toast.success("Project wallet updated", { - description: `Now pointing to ${descriptionLabel}`, - }); - await queryClient.invalidateQueries({ - queryKey: [ - "project", - project.id, - "server-wallets", - managementAccessToken, - ], - }); - router.refresh(); - }, - }); - - const canSelectExisting = - Boolean(managementAccessToken) && serverWallets.length > 0; - - return ( - - - No project wallet set - - Set a project wallet to use for dashboard and API integrations. - - -
- {serverWalletsQuery.isLoading ? ( -
- - Loading existing server wallets… -
- ) : canSelectExisting ? ( -
-
-

- Choose an existing server wallet -

-

- These wallets were already created for this project. Pick one to - set as the default. -

- -
- -
- - -
-
- ) : ( -
-

- Create a server wallet to start using transactions and other - project features. -

- -
- )} -
-
- ); -} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/project-wallet/project-wallet-details.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/project-wallet/project-wallet-details.tsx new file mode 100644 index 00000000000..819d0c652f4 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/project-wallet/project-wallet-details.tsx @@ -0,0 +1,736 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ArrowLeftRightIcon, + EllipsisVerticalIcon, + RefreshCcwIcon, + SendIcon, + ShuffleIcon, + WalletIcon, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { type ThirdwebClient, toWei } from "thirdweb"; +import { useWalletBalance } from "thirdweb/react"; +import { isAddress } from "thirdweb/utils"; +import { z } from "zod"; +import { sendProjectWalletTokens } from "@/actions/project-wallet/send-tokens"; +import type { Project } from "@/api/project/projects"; +import { FundWalletModal } from "@/components/blocks/fund-wallets-modal"; +import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; +import { WalletAddress } from "@/components/blocks/wallet-address"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { CopyTextButton } from "@/components/ui/CopyTextButton"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/Spinner"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import { useV5DashboardChain } from "@/hooks/chains/v5-adapter"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import type { ProjectWalletSummary } from "@/lib/server/project-wallet"; +import { cn } from "@/lib/utils"; +import { updateDefaultProjectWallet } from "../../transactions/lib/vault.client"; + +type GetProjectServerWallets = (params: { + managementAccessToken: string; + projectId: string; +}) => Promise; + +type ProjectWalletControlsProps = { + projectWallet: ProjectWalletSummary; + project: Project; + defaultChainId?: number; + teamSlug: string; + getProjectServerWallets: GetProjectServerWallets; + client: ThirdwebClient; +}; + +export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) { + const { projectWallet, project, defaultChainId } = props; + const [isSendOpen, setIsSendOpen] = useState(false); + const [isReceiveOpen, setIsReceiveOpen] = useState(false); + const [selectedChainId, setSelectedChainId] = useState(defaultChainId ?? 1); + const [isChangeWalletOpen, setIsChangeWalletOpen] = useState(false); + + const chain = useV5DashboardChain(selectedChainId); + + const engineCloudService = useMemo( + () => project.services?.find((service) => service.name === "engineCloud"), + [project.services], + ); + + const managementAccessToken = + engineCloudService?.managementAccessToken ?? undefined; + + const isManagedVault = !!engineCloudService?.encryptedAdminKey; + + const serverWalletsQuery = useQuery({ + enabled: !!managementAccessToken, + queryFn: async () => { + if (!managementAccessToken) { + return [] as ProjectWalletSummary[]; + } + + return props.getProjectServerWallets({ + managementAccessToken, + projectId: project.id, + }); + }, + queryKey: ["project", project.id, "server-wallets", managementAccessToken], + staleTime: 60_000, + }); + + const balanceQuery = useWalletBalance({ + address: projectWallet.address, + chain, + client: props.client, + }); + + const canChangeWallet = + serverWalletsQuery.data && serverWalletsQuery.data.length > 1; + + const router = useDashboardRouter(); + + return ( +
+
+ + + + + + setIsSendOpen(true)} + > + + Send funds + + setIsReceiveOpen(true)} + > + + Receive funds + + + + router.push( + `/team/${props.teamSlug}/${props.project.slug}/transactions`, + ) + } + > + + View transactions + + + {canChangeWallet && ( + setIsChangeWalletOpen(true)} + > + + Change project wallet + + )} + + + +
+
+

Wallet Address

+ +
+ +
+

Wallet Label

+
+

+ {projectWallet.label || "N/A"} +

+
+
+
+ +
+
+

Balance

+
+ {balanceQuery.isFetching || !balanceQuery.data ? ( + + ) : ( + + {balanceQuery.data.displayValue} {balanceQuery.data.symbol} + + )} + + +
+
+ + +
+
+ + setIsSendOpen(false)} + onSuccess={() => balanceQuery.refetch()} + open={isSendOpen} + publishableKey={project.publishableKey} + teamId={project.teamId} + walletAddress={projectWallet.address} + /> + + + + + + + + +
+ ); +} + +function ChangeProjectWalletDialogContent(props: { + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; + serverWallets: { + data: ProjectWalletSummary[]; + isPending: boolean; + }; + projectWallet: ProjectWalletSummary; + managementAccessToken: string | undefined; + project: Project; + client: ThirdwebClient; +}) { + const queryClient = useQueryClient(); + const router = useDashboardRouter(); + + const [selectedWalletId, setSelectedWalletId] = useState( + props.projectWallet.id, + ); + + const serverWallets = props.serverWallets.data; + + const currentWalletAddressLower = props.projectWallet.address.toLowerCase(); + + const selectedWallet = useMemo(() => { + if (!selectedWalletId) { + return undefined; + } + + return serverWallets.find((wallet) => wallet.id === selectedWalletId); + }, [selectedWalletId, serverWallets]); + + const isSelectionDifferent = Boolean( + selectedWallet && + selectedWallet.address.toLowerCase() !== currentWalletAddressLower, + ); + + const changeWalletMutation = useMutation({ + mutationFn: async (wallet: ProjectWalletSummary) => { + await updateDefaultProjectWallet({ + project: props.project, + projectWalletAddress: wallet.address, + }); + }, + onError: (error) => { + const message = + error instanceof Error + ? error.message + : "Failed to update project wallet"; + toast.error(message); + }, + onSuccess: async () => { + toast.success("Project wallet updated"); + await queryClient.invalidateQueries({ + queryKey: [ + "project", + props.project.id, + "server-wallets", + props.managementAccessToken, + ], + }); + router.refresh(); + }, + }); + + return ( +
+ + Change Project Wallet + + Choose a server wallet to use as project wallet + + + +
+ {props.serverWallets.isPending ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ +
+ + +
+
+ ); +} + +const createSendFormSchema = (secretKeyLabel: string) => + z.object({ + chainId: z.number({ + required_error: "Select a network", + }), + toAddress: z + .string() + .trim() + .min(1, "Destination address is required") + .refine((value) => Boolean(isAddress(value)), { + message: "Enter a valid wallet address", + }), + amount: z.string().trim().min(1, "Amount is required"), + secretKey: z.string().trim().min(1, `${secretKeyLabel} is required`), + vaultAccessToken: z.string().trim(), + }); + +const SECRET_KEY_LABEL = "Project secret key"; + +type SendFormValues = z.infer>; + +type SendProjectWalletModalProps = { + open: boolean; + onClose: () => void; + onSuccess: () => void; + walletAddress: string; + publishableKey: string; + teamId: string; + chainId: number; + label: string; + client: ReturnType; + isManagedVault: boolean; +}; + +const secretKeyLabel = SECRET_KEY_LABEL; +const secretKeyPlaceholder = "Enter your project secret key"; +const secretKeyHelper = + "Your project secret key was generated when you created your project. If you lost it, regenerate one from Project settings."; +const vaultAccessTokenHelper = + "Vault access tokens are optional credentials with server wallet permissions. Manage them in Vault settings."; + +function SendProjectWalletModal(props: SendProjectWalletModalProps) { + return ( + { + if (!nextOpen) { + props.onClose(); + } + }} + open={props.open} + > + + + + + ); +} + +function SendProjectWalletModalContent(props: SendProjectWalletModalProps) { + const { + onClose, + onSuccess, + walletAddress, + publishableKey, + teamId, + chainId, + label, + client, + isManagedVault, + } = props; + + const formSchema = useMemo(() => createSendFormSchema(SECRET_KEY_LABEL), []); + + const form = useForm({ + defaultValues: { + amount: "0", + chainId, + secretKey: "", + vaultAccessToken: "", + toAddress: "", + }, + mode: "onChange", + resolver: zodResolver(formSchema), + }); + + const selectedChain = useV5DashboardChain(form.watch("chainId")); + + const sendMutation = useMutation({ + mutationFn: async (values: SendFormValues) => { + const quantityWei = toWei(values.amount).toString(); + const secretKeyValue = values.secretKey.trim(); + const vaultAccessTokenValue = values.vaultAccessToken.trim(); + + const result = await sendProjectWalletTokens({ + chainId: values.chainId, + publishableKey, + quantityWei, + recipientAddress: values.toAddress, + teamId, + walletAddress, + secretKey: secretKeyValue, + ...(vaultAccessTokenValue + ? { vaultAccessToken: vaultAccessTokenValue } + : {}), + }); + + if (!result.ok) { + const errorMessage = + typeof result.error === "string" + ? result.error + : "Failed to send funds"; + throw new Error(errorMessage); + } + + return result.transactionIds; + }, + onError: (error) => { + toast.error(error.message); + }, + onSuccess: (transactionIds) => { + toast.success("Transfer submitted", { + description: + transactionIds && transactionIds.length > 0 + ? `Transaction ID: ${transactionIds[0]}` + : undefined, + }); + onSuccess(); + onClose(); + }, + }); + + return ( +
+ + Send from {label} + + Execute a one-off transfer using your server wallet. + + + +
+ { + void sendMutation.mutateAsync(values); + })} + > +
+ ( + + Network + + { + field.onChange(nextChainId); + }} + placeholder="Select network" + /> + + + + )} + /> + + ( + + Send to + + + + + + )} + /> + + ( + + Amount + + + + + Sending native token + {selectedChain?.nativeCurrency?.symbol + ? ` (${selectedChain.nativeCurrency.symbol})` + : ""} + + + + )} + /> + + ( + + {secretKeyLabel} + + + + {secretKeyHelper} + + + )} + /> + + {!isManagedVault && ( + ( + + + Vault access token + (optional) + + + + + {vaultAccessTokenHelper} + + + )} + /> + )} +
+ +
+ + +
+
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/project-wallet/project-wallet.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/project-wallet/project-wallet.stories.tsx new file mode 100644 index 00000000000..750c51f3328 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/project-wallet/project-wallet.stories.tsx @@ -0,0 +1,126 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import type { ProjectWalletSummary } from "@/lib/server/project-wallet"; +import { projectStub } from "@/storybook/stubs"; +import { storybookThirdwebClient } from "@/storybook/utils"; +import { ProjectWalletSectionUI } from "./project-wallet"; + +const meta = { + component: ProjectWalletSectionUI, + title: "Project/ProjectWalletSection", + parameters: { + nextjs: { + appDirectory: true, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const projectWithManagedAccessToken = projectStub("foo", "bar"); +projectWithManagedAccessToken.services = [ + { + name: "engineCloud", + actions: [], + managementAccessToken: "managed-access-token", + }, +]; + +const projectWithoutManagedAccessToken = projectStub("foo", "bar"); + +const projectWallet1: ProjectWalletSummary = { + id: "server-wallet-id", + address: "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37", + label: "Project Wallet 1", +}; + +const projectWallet2: ProjectWalletSummary = { + id: "server-wallet-id-2", + address: "0x83Dd93fA5D8343094f850f90B3fb90088C1bB425", + label: "Project Wallet 2", +}; + +export const NoProjectWalletSetNoManagedAccessToken: Story = { + args: { + project: projectWithoutManagedAccessToken, + client: storybookThirdwebClient, + teamSlug: "bar", + projectWallet: undefined, + getProjectServerWallets: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return []; + }, + }, +}; + +export const NoProjectWalletSetWithManagedAccessToken: Story = { + args: { + project: projectWithManagedAccessToken, + client: storybookThirdwebClient, + teamSlug: "bar", + projectWallet: undefined, + getProjectServerWallets: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return []; + }, + }, +}; + +export const NoProjectWalletSetWithManagedAccessTokenAndServerWallets: Story = { + args: { + project: projectWithManagedAccessToken, + teamSlug: "bar", + client: storybookThirdwebClient, + projectWallet: undefined, + getProjectServerWallets: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return [projectWallet1, projectWallet2]; + }, + }, +}; + +export const NoProjectWalletSetLoading: Story = { + args: { + project: projectWithManagedAccessToken, + teamSlug: "bar", + client: storybookThirdwebClient, + projectWallet: undefined, + getProjectServerWallets: async () => { + await new Promise((resolve) => setTimeout(resolve, 100000)); + return [projectWallet1, projectWallet2]; + }, + }, +}; + +export const ProjectWalletSetMultipleServerWallets: Story = { + args: { + project: projectWithManagedAccessToken, + teamSlug: "bar", + client: storybookThirdwebClient, + projectWallet: projectWallet1, + getProjectServerWallets: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return [projectWallet1, projectWallet2]; + }, + }, +}; + +export const ProjectWalletSetSingleServerWallet: Story = { + args: { + project: projectWithManagedAccessToken, + teamSlug: "bar", + projectWallet: projectWallet1, + client: storybookThirdwebClient, + getProjectServerWallets: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return [projectWallet1]; + }, + }, +}; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/project-wallet/project-wallet.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/project-wallet/project-wallet.tsx new file mode 100644 index 00000000000..e2a44ce2208 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/project-wallet/project-wallet.tsx @@ -0,0 +1,331 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { UnderlineLink } from "@workspace/ui/components/UnderlineLink"; +import { ChevronDownIcon, XIcon } from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import type { ThirdwebClient } from "thirdweb"; +import { listProjectServerWallets } from "@/actions/project-wallet/list-server-wallets"; +import type { Project } from "@/api/project/projects"; +import { WalletAddress } from "@/components/blocks/wallet-address"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Spinner } from "@/components/ui/Spinner"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import type { ProjectWalletSummary } from "@/lib/server/project-wallet"; +import { updateDefaultProjectWallet } from "../../transactions/lib/vault.client"; +import { CreateServerWallet } from "../../transactions/server-wallets/components/create-server-wallet.client"; +import { ProjectWalletDetailsSection } from "./project-wallet-details"; + +type GetProjectServerWallets = (params: { + managementAccessToken: string; + projectId: string; +}) => Promise; + +function CreateProjectWalletSection(props: { + project: Project; + teamSlug: string; + getProjectServerWallets: GetProjectServerWallets; +}) { + const { project, teamSlug } = props; + const router = useDashboardRouter(); + const queryClient = useQueryClient(); + const client = useMemo(() => getClientThirdwebClient(), []); + const [selectedWalletId, setSelectedWalletId] = useState( + undefined, + ); + const [isSelectDialogOpen, setIsSelectDialogOpen] = useState(false); + + const managementAccessToken = + props.project.services?.find((service) => service.name === "engineCloud") + ?.managementAccessToken ?? undefined; + + const serverWalletsQuery = useQuery({ + enabled: Boolean(managementAccessToken), + queryFn: async () => { + if (!managementAccessToken) { + return [] as ProjectWalletSummary[]; + } + + return props.getProjectServerWallets({ + managementAccessToken, + projectId: project.id, + }); + }, + queryKey: ["project", project.id, "server-wallets", managementAccessToken], + refetchOnWindowFocus: false, + }); + + const serverWallets = serverWalletsQuery.data ?? []; + + const effectiveSelectedWalletId = useMemo(() => { + if ( + selectedWalletId && + serverWallets.some((wallet) => wallet.id === selectedWalletId) + ) { + return selectedWalletId; + } + + return serverWallets[0]?.id; + }, [selectedWalletId, serverWallets]); + + const selectedWallet = useMemo(() => { + if (!effectiveSelectedWalletId) { + return undefined; + } + + return serverWallets.find( + (wallet) => wallet.id === effectiveSelectedWalletId, + ); + }, [effectiveSelectedWalletId, serverWallets]); + + const setDefaultWalletMutation = useMutation({ + mutationFn: async (wallet: ProjectWalletSummary) => { + await updateDefaultProjectWallet({ + project, + projectWalletAddress: wallet.address, + }); + }, + onError: (error) => { + const message = + error instanceof Error + ? error.message + : "Failed to update project wallet"; + toast.error(message); + }, + onSuccess: async (_, wallet) => { + const descriptionLabel = wallet.label ?? wallet.address; + toast.success("Project wallet updated", { + description: `Now pointing to ${descriptionLabel}`, + }); + await queryClient.invalidateQueries({ + queryKey: [ + "project", + project.id, + "server-wallets", + managementAccessToken, + ], + }); + setIsSelectDialogOpen(false); + router.refresh(); + }, + }); + + const canSelectExisting = + Boolean(managementAccessToken) && serverWallets.length > 0; + + return ( +
+
+ +
+

+ No Project Wallet set +

+

+ Set a project wallet to set the default sender in thirdweb API. +

+ +
+ {serverWalletsQuery.isLoading ? ( + + ) : canSelectExisting ? ( +
+ + + + + { + if (!nextOpen) { + setIsSelectDialogOpen(false); + } + }} + open={isSelectDialogOpen} + > + + + Select project wallet + + Choose a server wallet to use as the default for this + project. + + + +
+ {serverWalletsQuery.isLoading ? ( +
+ +
+ ) : serverWallets.length === 0 ? ( +

+ No server wallets found. +

+ ) : ( +
+ +
+ )} +
+ +
+ + +
+
+
+
+ ) : ( +
+ +
+ )} +
+
+ ); +} + +export function ProjectWalletSectionUI(props: { + project: Project; + teamSlug: string; + projectWallet: ProjectWalletSummary | undefined; + getProjectServerWallets: GetProjectServerWallets; + client: ThirdwebClient; +}) { + return ( +
+
+

+ Project Wallet +

+

+ The server wallet to be used as the default sender in{" "} + + thirdweb API + +

+
+ + {props.projectWallet ? ( + + ) : ( + + )} +
+ ); +} + +export function ProjectWalletSection(props: { + project: Project; + teamSlug: string; + projectWallet: ProjectWalletSummary | undefined; + client: ThirdwebClient; +}) { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/page.tsx index d3a4e3540c8..f2bdf920278 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/page.tsx @@ -39,10 +39,8 @@ import type { import { loginRedirect } from "@/utils/redirects"; import { PieChartCard } from "../../../components/Analytics/PieChartCard"; import { EngineCloudChartCardAsync } from "./components/EngineCloudChartCard"; -import { - ProjectFTUX, - ProjectWalletSection, -} from "./components/ProjectFTUX/ProjectFTUX"; +import { ProjectFTUX } from "./components/ProjectFTUX/ProjectFTUX"; +import { ProjectWalletSection } from "./components/project-wallet/project-wallet"; import { RpcMethodBarChartCardAsync } from "./components/RpcMethodBarChartCard"; import { TransactionsChartCardAsync } from "./components/Transactions"; import { ProjectHighlightsCard } from "./overview/highlights-card"; @@ -112,9 +110,6 @@ export default async function ProjectOverviewPage(props: PageProps) { }); const projectWallet = await getProjectWallet(project); - const managementAccessToken = - project.services?.find((service) => service.name === "engineCloud") - ?.managementAccessToken ?? undefined; return ( @@ -135,14 +130,17 @@ export default async function ProjectOverviewPage(props: PageProps) { actions: null, }} > + + +
+ {isActive ? (
-
) : ( - + )} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/ftux.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/ftux.client.tsx index fdde607af18..83b7dde763f 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/ftux.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/ftux.client.tsx @@ -3,14 +3,13 @@ import { useMemo } from "react"; import type { ThirdwebClient } from "thirdweb"; import type { Project } from "@/api/project/projects"; import { type Step, StepsCard } from "@/components/blocks/StepsCard"; -import CreateServerWallet from "../server-wallets/components/create-server-wallet.client"; +import { CreateServerWallet } from "../server-wallets/components/create-server-wallet.client"; import type { Wallet } from "../server-wallets/wallet-table/types"; import type { SolanaWallet } from "../solana-wallets/wallet-table/types"; import { SendTestSolanaTransaction } from "./send-test-solana-tx.client"; import { SendTestTransaction } from "./send-test-tx.client"; interface Props { - managementAccessToken: string | undefined; project: Project; wallets: Wallet[]; solanaWallets: SolanaWallet[]; @@ -29,11 +28,12 @@ export const EngineChecklist: React.FC = (props) => { const steps: Step[] = []; steps.push({ children: ( - +
+ +
), completed: props.wallets.length > 0 || props.hasTransactions, description: @@ -61,7 +61,6 @@ export const EngineChecklist: React.FC = (props) => { }); return steps; }, [ - props.managementAccessToken, props.project, props.wallets, props.hasTransactions, @@ -113,19 +112,3 @@ export const EngineChecklist: React.FC = (props) => { /> ); }; - -function CreateServerWalletStep(props: { - project: Project; - teamSlug: string; - managementAccessToken: string | undefined; -}) { - return ( -
- -
- ); -} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/components/server-wallets-table.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/components/server-wallets-table.client.tsx index c8b124715fe..bfcf5590f83 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/components/server-wallets-table.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/components/server-wallets-table.client.tsx @@ -58,7 +58,7 @@ import { WalletProductIcon } from "@/icons/WalletProductIcon"; import { useDashboardRouter } from "@/lib/DashboardRouter"; import { cn } from "@/lib/utils"; import { updateDefaultProjectWallet } from "../lib/vault.client"; -import CreateServerWallet from "../server-wallets/components/create-server-wallet.client"; +import { CreateServerWallet } from "../server-wallets/components/create-server-wallet.client"; import type { Wallet as EVMWallet } from "../server-wallets/wallet-table/types"; import { CreateSolanaWallet } from "../solana-wallets/components/create-solana-wallet.client"; import { UpgradeSolanaPermissions } from "../solana-wallets/components/upgrade-solana-permissions.client"; @@ -77,7 +77,6 @@ interface ServerWalletsTableProps { solanaTotalPages: number; project: Project; teamSlug: string; - managementAccessToken: string | undefined; client: ThirdwebClient; solanaPermissionError?: boolean; } @@ -88,7 +87,6 @@ export function ServerWalletsTable(props: ServerWalletsTableProps) { solanaWallets, project, teamSlug, - managementAccessToken, evmTotalRecords, evmCurrentPage, evmTotalPages, @@ -134,11 +132,7 @@ export function ServerWalletsTable(props: ServerWalletsTableProps) {
{activeChain === "evm" && ( <> - + service.name === "engineCloud") + ?.managementAccessToken ?? undefined; + const createEoaMutation = useMutation({ mutationFn: async ({ managementAccessToken, @@ -53,12 +61,12 @@ export default function CreateServerWallet(props: { }); const handleCreateServerWallet = async () => { - if (!props.managementAccessToken) { + if (!managementAccessToken) { router.push(`/team/${props.teamSlug}/${props.project.slug}/vault`); } else { await createEoaMutation.mutateAsync({ label, - managementAccessToken: props.managementAccessToken, + managementAccessToken: managementAccessToken, }); } }; @@ -68,21 +76,32 @@ export default function CreateServerWallet(props: { return ( <>