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 (
-
-
-
-
-
-
-
Label
-
-
- {label}
-
- {canChangeWallet && (
-
- )}
-
-
-
-
-
-
-
Balance
-
-
- {balanceQuery.isLoading ? (
-
- ) : balanceDisplay ? (
-
- {balanceDisplay}
-
- ) : (
- N/A
- )}
-
-
-
-
-
-
- setIsSendOpen(true)}
- >
-
- Send funds
-
- setIsReceiveOpen(true)}
- >
-
- Receive funds
-
-
-
-
-
-
-
-
-
-
-
setIsSendOpen(false)}
- onSuccess={() => balanceQuery.refetch()}
- open={isSendOpen}
- publishableKey={project.publishableKey}
- teamId={project.teamId}
- walletAddress={walletAddress}
- />
-
-
-
-
-
- );
-}
-
-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 (
-
- );
-}
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 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 (
+
+ );
+}
+
+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.
+
+
+
+
+
+
+ );
+}
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 ? (
+
+
+
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ );
+}
+
+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 (
<>