diff --git a/apps/dashboard/src/@/actions/project-wallet/list-server-wallets.ts b/apps/dashboard/src/@/actions/project-wallet/list-server-wallets.ts new file mode 100644 index 00000000000..ebd2e41e857 --- /dev/null +++ b/apps/dashboard/src/@/actions/project-wallet/list-server-wallets.ts @@ -0,0 +1,70 @@ +"use server"; + +import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk"; +import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs"; +import type { ProjectWalletSummary } from "@/lib/server/project-wallet"; + +interface VaultWalletListItem { + id: string; + address: string; + metadata?: { + label?: string; + projectId?: string; + teamId?: string; + type?: string; + } | null; +} + +export async function listProjectServerWallets(params: { + managementAccessToken: string; + projectId: string; + pageSize?: number; +}): Promise { + const { managementAccessToken, projectId, pageSize = 100 } = params; + + if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) { + return []; + } + + try { + const vaultClient = await createVaultClient({ + baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL, + }); + + const response = await listEoas({ + client: vaultClient, + request: { + auth: { + accessToken: managementAccessToken, + }, + options: { + page: 0, + // @ts-expect-error - Vault SDK expects snake_case pagination fields + page_size: pageSize, + }, + }, + }); + + if (!response.success || !response.data?.items) { + return []; + } + + const items = response.data.items as VaultWalletListItem[]; + + return items + .filter((item) => { + return ( + item.metadata?.projectId === projectId && + (!item.metadata?.type || item.metadata.type === "server-wallet") + ); + }) + .map((item) => ({ + id: item.id, + address: item.address, + label: item.metadata?.label ?? undefined, + })); + } catch (error) { + console.error("Failed to list project server wallets", error); + return []; + } +} diff --git a/apps/dashboard/src/@/actions/project-wallet/send-tokens.ts b/apps/dashboard/src/@/actions/project-wallet/send-tokens.ts new file mode 100644 index 00000000000..6cb4875fb94 --- /dev/null +++ b/apps/dashboard/src/@/actions/project-wallet/send-tokens.ts @@ -0,0 +1,74 @@ +"use server"; + +import { configure, sendTokens } from "@thirdweb-dev/api"; +import { THIRDWEB_API_HOST } from "@/constants/urls"; + +configure({ + override: { + baseUrl: THIRDWEB_API_HOST, + }, +}); + +export async function sendProjectWalletTokens(options: { + walletAddress: string; + recipientAddress: string; + chainId: number; + quantityWei: string; + publishableKey: string; + teamId: string; + tokenAddress?: string; + secretKey: string; + vaultAccessToken?: string; +}) { + const { + walletAddress, + recipientAddress, + chainId, + quantityWei, + publishableKey, + teamId, + tokenAddress, + secretKey, + vaultAccessToken, + } = options; + + if (!secretKey) { + return { + error: "A project secret key is required to send funds.", + ok: false, + } as const; + } + + const response = await sendTokens({ + body: { + chainId, + from: walletAddress, + recipients: [ + { + address: recipientAddress, + quantity: quantityWei, + }, + ], + ...(tokenAddress ? { tokenAddress } : {}), + }, + headers: { + "Content-Type": "application/json", + "x-client-id": publishableKey, + "x-secret-key": secretKey, + "x-team-id": teamId, + ...(vaultAccessToken ? { "x-vault-access-token": vaultAccessToken } : {}), + }, + }); + + if (response.error || !response.data) { + return { + error: response.error || "Failed to submit transfer request.", + ok: false, + } as const; + } + + return { + ok: true, + transactionIds: response.data.result?.transactionIds ?? [], + } as const; +} diff --git a/apps/dashboard/src/@/components/misc/AnnouncementBanner.tsx b/apps/dashboard/src/@/components/misc/AnnouncementBanner.tsx index b5bf7642849..5a13a15652b 100644 --- a/apps/dashboard/src/@/components/misc/AnnouncementBanner.tsx +++ b/apps/dashboard/src/@/components/misc/AnnouncementBanner.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { Button } from "@/components/ui/button"; import { useLocalStorage } from "@/hooks/useLocalStorage"; +// biome-ignore lint/correctness/noUnusedVariables: banner is toggled on-demand via API content changes function AnnouncementBannerUI(props: { href: string; label: string; diff --git a/apps/dashboard/src/@/components/project/create-project-modal/index.tsx b/apps/dashboard/src/@/components/project/create-project-modal/index.tsx index 2034be60fc3..1dec37b0d5d 100644 --- a/apps/dashboard/src/@/components/project/create-project-modal/index.tsx +++ b/apps/dashboard/src/@/components/project/create-project-modal/index.tsx @@ -64,7 +64,7 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => { { const res = await createProjectClient(props.teamId, params); - await createVaultAccountAndAccessToken({ + const vaultTokens = await createVaultAccountAndAccessToken({ project: res.project, projectSecretKey: res.secret, }).catch((error) => { @@ -74,6 +74,13 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => { ); throw error; }); + + const managementAccessToken = vaultTokens.managementToken?.accessToken; + + if (!managementAccessToken) { + throw new Error("Missing management access token for project wallet"); + } + return { project: res.project, secret: res.secret, diff --git a/apps/dashboard/src/@/lib/project-wallet.ts b/apps/dashboard/src/@/lib/project-wallet.ts new file mode 100644 index 00000000000..56dfa889a62 --- /dev/null +++ b/apps/dashboard/src/@/lib/project-wallet.ts @@ -0,0 +1,21 @@ +const PROJECT_WALLET_LABEL_SUFFIX = " Wallet"; +const PROJECT_WALLET_LABEL_MAX_LENGTH = 32; + +/** + * Builds the default label for a project's primary server wallet. + * Ensures a stable naming convention so the wallet can be identified across flows. + */ +export function getProjectWalletLabel(projectName: string | undefined) { + const baseName = projectName?.trim() || "Project"; + const maxBaseLength = Math.max( + 1, + PROJECT_WALLET_LABEL_MAX_LENGTH - PROJECT_WALLET_LABEL_SUFFIX.length, + ); + + const normalizedBase = + baseName.length > maxBaseLength + ? baseName.slice(0, maxBaseLength).trimEnd() + : baseName; + + return `${normalizedBase}${PROJECT_WALLET_LABEL_SUFFIX}`; +} diff --git a/apps/dashboard/src/@/lib/server/project-wallet.ts b/apps/dashboard/src/@/lib/server/project-wallet.ts new file mode 100644 index 00000000000..4597c079add --- /dev/null +++ b/apps/dashboard/src/@/lib/server/project-wallet.ts @@ -0,0 +1,99 @@ +import "server-only"; + +import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk"; +import type { Project } from "@/api/project/projects"; +import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs"; +import { getProjectWalletLabel } from "@/lib/project-wallet"; + +export type ProjectWalletSummary = { + id: string; + address: string; + label?: string; +}; + +type VaultWalletListItem = { + id: string; + address: string; + metadata?: { + label?: string; + projectId?: string; + teamId?: string; + type?: string; + }; +}; + +export async function getProjectWallet( + project: Project, +): Promise { + const engineCloudService = project.services.find( + (service) => service.name === "engineCloud", + ); + + const managementAccessToken = + engineCloudService?.managementAccessToken || undefined; + const projectWalletAddress = ( + engineCloudService as { projectWalletAddress?: string } | undefined + )?.projectWalletAddress; + + if ( + !managementAccessToken || + !NEXT_PUBLIC_THIRDWEB_VAULT_URL || + !projectWalletAddress + ) { + return undefined; + } + + try { + const vaultClient = await createVaultClient({ + baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL, + }); + + const response = await listEoas({ + client: vaultClient, + request: { + auth: { + accessToken: managementAccessToken, + }, + options: { + page: 0, + // @ts-expect-error - SDK expects snake_case for pagination arguments + page_size: 100, + }, + }, + }); + + if (!response.success || !response.data) { + return undefined; + } + + const items = response.data.items as VaultWalletListItem[] | undefined; + + if (!items?.length) { + return undefined; + } + + const defaultLabel = getProjectWalletLabel(project.name); + + const serverWallets = items.filter( + (item) => item.metadata?.projectId === project.id, + ); + + const defaultWallet = serverWallets.find( + (item) => + item.address.toLowerCase() === projectWalletAddress.toLowerCase(), + ); + + if (!defaultWallet) { + return undefined; + } + + return { + id: defaultWallet.id, + address: defaultWallet.address, + label: defaultWallet.metadata?.label ?? defaultLabel, + }; + } catch (error) { + console.error("Failed to load project wallet", error); + return undefined; + } +} diff --git a/apps/dashboard/src/app/(app)/get-started/team/[team_slug]/create-project/_components/create-project-form.tsx b/apps/dashboard/src/app/(app)/get-started/team/[team_slug]/create-project/_components/create-project-form.tsx index d8b8b306f3c..1a7789b5bca 100644 --- a/apps/dashboard/src/app/(app)/get-started/team/[team_slug]/create-project/_components/create-project-form.tsx +++ b/apps/dashboard/src/app/(app)/get-started/team/[team_slug]/create-project/_components/create-project-form.tsx @@ -55,7 +55,7 @@ export function CreateProjectFormOnboarding(props: { { const res = await createProjectClient(props.teamId, params); - await createVaultAccountAndAccessToken({ + const vaultTokens = await createVaultAccountAndAccessToken({ project: res.project, projectSecretKey: res.secret, }).catch((error) => { @@ -65,6 +65,16 @@ export function CreateProjectFormOnboarding(props: { ); throw error; }); + + const managementAccessToken = + vaultTokens.managementToken?.accessToken; + + if (!managementAccessToken) { + throw new Error( + "Missing management access token for project wallet", + ); + } + return { project: res.project, secret: res.secret, 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 9efdf19b331..ec7a9704b41 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 @@ -31,5 +31,6 @@ 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 06764040f08..1f637d9b0e3 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 @@ -7,6 +7,7 @@ import { import Link from "next/link"; import type { Project } from "@/api/project/projects"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { AlertDialogFooter } from "@/components/ui/alert-dialog"; import { CodeServer } from "@/components/ui/code/code.server"; import { UnderlineLink } from "@/components/ui/UnderlineLink"; import { DotNetIcon } from "@/icons/brand-icons/DotNetIcon"; @@ -18,13 +19,34 @@ 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 CreateServerWallet from "../../transactions/server-wallets/components/create-server-wallet.client"; import { ClientIDSection } from "./ClientIDSection"; import { IntegrateAPIKeyCodeTabs } from "./IntegrateAPIKeyCodeTabs"; +import { ProjectWalletControls } from "./ProjectWalletControls.client"; import { SecretKeySection } from "./SecretKeySection"; -export function ProjectFTUX(props: { project: Project; teamSlug: string }) { +export async function ProjectFTUX(props: { + project: Project; + teamSlug: string; + wallet?: ProjectWalletSummary | undefined; + managementAccessToken: string | undefined; +}) { + const projectWallet = props.wallet ?? (await getProjectWallet(props.project)); + return (
+ +
+
+
+
+ +
+
+

+ Project Wallet +

+

+ Default managed server wallet for this project. Use it for + deployments, payments, and API integrations. +

+
+
+ + {walletAddress ? ( + <> + +
+ + View Transactions + + +
+ + ) : ( + + + No default project wallet set + + Set a default project wallet to use for dashboard and API + integrations. + + + + + + )} +
+
+ + ); +} + // Integrate API key section ------------------------------------------------------------ function IntegrateAPIKeySection({ 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 new file mode 100644 index 00000000000..97fb9d328f2 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/ProjectWalletControls.client.tsx @@ -0,0 +1,725 @@ +"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 { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Spinner } from "@/components/ui/Spinner"; +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("Default 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. +

+ ) : ( + setSelectedWalletId(value)} + value={selectedWalletId} + > + {serverWallets.map((wallet) => { + const isCurrent = + wallet.address.toLowerCase() === currentWalletAddressLower; + const isSelected = wallet.id === selectedWalletId; + + return ( + + ); + })} + + )} +
+ + + + + +
+
+
+ ); +} + +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)/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/page.tsx index be9e0dce2f0..8f749f2eb52 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 @@ -29,6 +29,7 @@ import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-fi import { ProjectAvatar } from "@/components/blocks/avatar/project-avatar"; import { ProjectPage } from "@/components/blocks/project-page/project-page"; import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import { getProjectWallet } from "@/lib/server/project-wallet"; import { getFiltersFromSearchParams } from "@/lib/time"; import type { InAppWalletStats, @@ -38,7 +39,10 @@ import type { import { loginRedirect } from "@/utils/redirects"; import { PieChartCard } from "../../../components/Analytics/PieChartCard"; import { EngineCloudChartCardAsync } from "./components/EngineCloudChartCard"; -import { ProjectFTUX } from "./components/ProjectFTUX/ProjectFTUX"; +import { + ProjectFTUX, + ProjectWalletSection, +} from "./components/ProjectFTUX/ProjectFTUX"; import { RpcMethodBarChartCardAsync } from "./components/RpcMethodBarChartCard"; import { TransactionsChartCardAsync } from "./components/Transactions"; import { ProjectHighlightsCard } from "./overview/highlights-card"; @@ -104,6 +108,11 @@ export default async function ProjectOverviewPage(props: PageProps) { teamId: project.teamId, }); + const projectWallet = await getProjectWallet(project); + const managementAccessToken = + project.services?.find((service) => service.name === "engineCloud") + ?.managementAccessToken ?? undefined; + return ( {isActive ? (
+
) : ( - + )}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/QuickstartSection.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/QuickstartSection.client.tsx index eda409817e6..6bd0d297176 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/QuickstartSection.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/QuickstartSection.client.tsx @@ -15,6 +15,7 @@ export function QuickStartSection(props: { projectSlug: string; clientId: string; teamId: string; + projectWalletAddress?: string; }) { return (
@@ -43,6 +44,7 @@ export function QuickStartSection(props: { action={ + )} +
)} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/links/components/PaymentLinksTable.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/links/components/PaymentLinksTable.client.tsx index de1f2cf9a07..f8a48b52df1 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/links/components/PaymentLinksTable.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/links/components/PaymentLinksTable.client.tsx @@ -40,7 +40,11 @@ import { ErrorState } from "../../components/ErrorState"; import { formatTokenAmount } from "../../components/format"; import { CreatePaymentLinkButton } from "./CreatePaymentLinkButton.client"; -export function PaymentLinksTable(props: { clientId: string; teamId: string }) { +export function PaymentLinksTable(props: { + clientId: string; + teamId: string; + projectWalletAddress?: string; +}) { return (
@@ -49,12 +53,20 @@ export function PaymentLinksTable(props: { clientId: string; teamId: string }) { The payments you have created in this project

- +
); } -function PaymentLinksTableInner(props: { clientId: string; teamId: string }) { +function PaymentLinksTableInner(props: { + clientId: string; + teamId: string; + projectWalletAddress?: string; +}) { const paymentLinksQuery = useQuery({ queryFn: async () => { return getPaymentLinks({ @@ -120,6 +132,7 @@ function PaymentLinksTableInner(props: { clientId: string; teamId: string }) { + )} + )} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/payments/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/payments/page.tsx index eb0fb508508..61201dd3204 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/payments/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/payments/page.tsx @@ -3,6 +3,7 @@ import { getAuthToken } from "@/api/auth-token"; import { getProject } from "@/api/project/projects"; import { getTeamBySlug } from "@/api/team/get-team"; import { getFees } from "@/api/universal-bridge/developer"; +import { getProjectWallet } from "@/lib/server/project-wallet"; import { loginRedirect } from "@/utils/redirects"; import { ProjectSettingsBreadcrumb } from "../_components/project-settings-breadcrumb"; import { PayConfig } from "./PayConfig"; @@ -31,6 +32,8 @@ export default async function Page(props: { redirect(`/team/${team_slug}`); } + const projectWallet = await getProjectWallet(project); + let fees = await getFees({ clientId: project.publishableKey, teamId: team.id, @@ -64,6 +67,7 @@ export default async function Page(props: { fees={fees} project={project} teamId={team.id} + projectWalletAddress={projectWallet?.address} teamSlug={team_slug} /> diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts index 2fdea9dc5df..14e89a3ec36 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts @@ -3,14 +3,17 @@ import { encrypt } from "@thirdweb-dev/service-utils"; import { createAccessToken, + createEoa, createServiceAccount, createVaultClient, rotateServiceAccount, type VaultClient, } from "@thirdweb-dev/vault-sdk"; +import { engineCloudProxy } from "@/actions/proxies"; import type { Project } from "@/api/project/projects"; import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs"; import { updateProjectClient } from "@/hooks/useApi"; +import { getProjectWalletLabel } from "@/lib/project-wallet"; const SERVER_WALLET_ACCESS_TOKEN_PURPOSE = "Access Token for All Server Wallets"; @@ -126,6 +129,92 @@ export async function createVaultAccountAndAccessToken(props: { } } +export async function createProjectServerWallet(props: { + project: Project; + managementAccessToken: string; + label?: string; + setAsProjectWallet?: boolean; +}) { + const vaultClient = await initVaultClient(); + + const walletLabel = props.label?.trim() + ? props.label.trim() + : getProjectWalletLabel(props.project.name); + + const eoa = await createEoa({ + client: vaultClient, + request: { + auth: { + accessToken: props.managementAccessToken, + }, + options: { + metadata: { + label: walletLabel, + projectId: props.project.id, + teamId: props.project.teamId, + type: "server-wallet", + }, + }, + }, + }); + + if (!eoa.success) { + throw new Error(eoa.error?.message || "Failed to create server wallet"); + } + + engineCloudProxy({ + body: JSON.stringify({ + signerAddress: eoa.data.address, + }), + headers: { + "Content-Type": "application/json", + "x-client-id": props.project.publishableKey, + "x-team-id": props.project.teamId, + }, + method: "POST", + pathname: "/cache/smart-account", + }).catch((err) => { + console.warn("failed to cache server wallet", err); + }); + + if (props.setAsProjectWallet) { + await updateDefaultProjectWallet({ + project: props.project, + projectWalletAddress: eoa.data.address, + }); + } + + return eoa.data; +} + +export async function updateDefaultProjectWallet(props: { + project: Project; + projectWalletAddress: string; +}) { + const services = props.project.services.filter( + (service) => service.name !== "engineCloud", + ); + const engineCloudService = props.project.services.find( + (service) => service.name === "engineCloud", + ); + if (engineCloudService) { + const engineCloudServiceWithProjectWallet = { + ...engineCloudService, + projectWalletAddress: props.projectWalletAddress, + }; + + await updateProjectClient( + { + projectId: props.project.id, + teamId: props.project.teamId, + }, + { + services: [...services, engineCloudServiceWithProjectWallet], + }, + ); + } +} + async function createAndEncryptVaultAccessTokens(props: { project: Project; vaultClient: VaultClient; @@ -157,6 +246,13 @@ async function createAndEncryptVaultAccessTokens(props: { const managementToken = managementTokenResult.data; const walletToken = walletTokenResult.data; + // create a default project server wallet + const defaultProjectServerWallet = await createProjectServerWallet({ + project, + managementAccessToken: managementToken.accessToken, + label: getProjectWalletLabel(project.name), + }); + if (projectSecretKey) { // verify that the project secret key is valid const projectSecretKeyHash = await hashSecretKey(projectSecretKey); @@ -182,7 +278,9 @@ async function createAndEncryptVaultAccessTokens(props: { }, { services: [ - ...props.project.services, + ...props.project.services.filter( + (service) => service.name !== "engineCloud", + ), { name: "engineCloud", actions: [], @@ -191,6 +289,7 @@ async function createAndEncryptVaultAccessTokens(props: { encryptedAdminKey, encryptedWalletAccessToken, rotationCode: rotationCode, + projectWalletAddress: defaultProjectServerWallet.address, }, ], }, @@ -204,7 +303,9 @@ async function createAndEncryptVaultAccessTokens(props: { }, { services: [ - ...props.project.services, + ...props.project.services.filter( + (service) => service.name !== "engineCloud", + ), { name: "engineCloud", actions: [], @@ -213,6 +314,7 @@ async function createAndEncryptVaultAccessTokens(props: { encryptedAdminKey: null, encryptedWalletAccessToken: null, rotationCode: rotationCode, + projectWalletAddress: defaultProjectServerWallet.address, }, ], }, diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/server-wallets/components/create-server-wallet.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/server-wallets/components/create-server-wallet.client.tsx index e529e71e2be..e5f0439ce29 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/server-wallets/components/create-server-wallet.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/server-wallets/components/create-server-wallet.client.tsx @@ -1,10 +1,8 @@ "use client"; import { useMutation } from "@tanstack/react-query"; -import { createEoa } from "@thirdweb-dev/vault-sdk"; import { PlusIcon } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; -import { engineCloudProxy } from "@/actions/proxies"; import type { Project } from "@/api/project/projects"; import { Button } from "@/components/ui/button"; import { @@ -17,12 +15,13 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Spinner } from "@/components/ui/Spinner"; import { useDashboardRouter } from "@/lib/DashboardRouter"; -import { initVaultClient } from "../../lib/vault.client"; +import { createProjectServerWallet } from "../../lib/vault.client"; export default function CreateServerWallet(props: { project: Project; teamSlug: string; managementAccessToken: string | undefined; + setAsProjectWallet?: boolean; }) { const router = useDashboardRouter(); const [label, setLabel] = useState(""); @@ -36,49 +35,17 @@ export default function CreateServerWallet(props: { managementAccessToken: string; label: string; }) => { - const vaultClient = await initVaultClient(); - - const eoa = await createEoa({ - client: vaultClient, - request: { - auth: { - accessToken: managementAccessToken, - }, - options: { - metadata: { - label, - projectId: props.project.id, - teamId: props.project.teamId, - type: "server-wallet", - }, - }, - }, - }); - - if (!eoa.success) { - throw new Error("Failed to create eoa"); - } - - // no need to await this, it's not blocking - engineCloudProxy({ - body: JSON.stringify({ - signerAddress: eoa.data.address, - }), - headers: { - "Content-Type": "application/json", - "x-client-id": props.project.publishableKey, - "x-team-id": props.project.teamId, - }, - method: "POST", - pathname: "/cache/smart-account", - }).catch((err) => { - console.warn("failed to cache server wallet", err); + const wallet = await createProjectServerWallet({ + label, + managementAccessToken, + project: props.project, + setAsProjectWallet: props.setAsProjectWallet, }); router.refresh(); setModalOpen(false); - - return eoa; + setLabel(""); + return wallet; }, onError: (error) => { toast.error(error.message); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/server-wallets/wallet-table/wallet-table-ui.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/server-wallets/wallet-table/wallet-table-ui.client.tsx index 06ee075cf87..e7f8f6bafe4 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/server-wallets/wallet-table/wallet-table-ui.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/server-wallets/wallet-table/wallet-table-ui.client.tsx @@ -1,8 +1,9 @@ "use client"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { format, formatDistanceToNowStrict } from "date-fns"; import { + CheckIcon, MoreVerticalIcon, RefreshCcwIcon, SendIcon, @@ -10,6 +11,7 @@ import { } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; +import { toast } from "sonner"; import type { ThirdwebClient } from "thirdweb"; import { useWalletBalance } from "thirdweb/react"; import { @@ -20,6 +22,7 @@ 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 { DropdownMenu, @@ -50,7 +53,9 @@ import { import { ToolTipLabel } from "@/components/ui/tooltip"; import { useV5DashboardChain } from "@/hooks/chains/v5-adapter"; import { WalletProductIcon } from "@/icons/WalletProductIcon"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; import { cn } from "@/lib/utils"; +import { updateDefaultProjectWallet } from "../../lib/vault.client"; import CreateServerWallet from "../components/create-server-wallet.client"; import type { Wallet } from "./types"; @@ -287,18 +292,34 @@ function ServerWalletTableRow(props: { queryKey: ["smart-account-address", wallet.address, chainId], }); + // Get the default project wallet address + const engineCloudService = project.services.find( + (service) => service.name === "engineCloud", + ); + const defaultWalletAddress = engineCloudService?.projectWalletAddress; + const isDefaultWallet = + defaultWalletAddress && + wallet.address.toLowerCase() === defaultWalletAddress.toLowerCase(); + return ( {/* Label */} - + + {wallet.metadata.label || "N/A"} + + {isDefaultWallet && ( + + default + )} - > - {wallet.metadata.label || "N/A"} - + {/* Address */} @@ -391,6 +412,25 @@ function WalletActionsDropdown(props: { chainId: number; }) { const [showFundModal, setShowFundModal] = useState(false); + const router = useDashboardRouter(); + + const setAsDefaultMutation = useMutation({ + mutationFn: async () => { + await updateDefaultProjectWallet({ + project: props.project, + projectWalletAddress: props.wallet.address, + }); + }, + onSuccess: () => { + toast.success("Wallet set as default project wallet"); + router.refresh(); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to set default wallet", + ); + }, + }); return ( <> @@ -420,6 +460,14 @@ function WalletActionsDropdown(props: { Fund wallet )} + setAsDefaultMutation.mutate()} + disabled={setAsDefaultMutation.isPending} + className="flex items-center gap-2 h-9 rounded-lg" + > + + Set as default + diff --git a/packages/service-utils/src/core/api.ts b/packages/service-utils/src/core/api.ts index 59d8a328a02..58b6e5a8ff0 100644 --- a/packages/service-utils/src/core/api.ts +++ b/packages/service-utils/src/core/api.ts @@ -241,6 +241,7 @@ export type ProjectService = rotationCode?: string | null; encryptedAdminKey?: string | null; encryptedWalletAccessToken?: string | null; + projectWalletAddress?: string | null; } | ProjectBundlerService | ProjectEmbeddedWalletsService;