From b48949c32f50a1c61e18b067e4c8fd8e3ce6000e Mon Sep 17 00:00:00 2001 From: 0xFirekeeper <0xFirekeeper@gmail.com> Date: Tue, 7 Oct 2025 22:10:38 +0700 Subject: [PATCH 01/13] Project Wallet (Default Server Wallet) Introduces logic to automatically create a default server wallet when a project is created, using a consistent label. Adds a new utility for generating wallet labels, updates project creation flows to provision the wallet, and enhances the ProjectFTUX component to display wallet details. Refactors server wallet creation logic into a reusable function and updates the server wallet creation UI to use this abstraction. Closes BLD-372 --- .../project/create-project-modal/index.tsx | 24 ++- apps/dashboard/src/@/lib/project-wallet.ts | 21 ++ .../_components/create-project-form.tsx | 26 ++- .../components/ProjectFTUX/ProjectFTUX.tsx | 182 +++++++++++++++++- .../transactions/lib/vault.client.ts | 53 +++++ .../create-server-wallet.client.tsx | 49 +---- 6 files changed, 308 insertions(+), 47 deletions(-) create mode 100644 apps/dashboard/src/@/lib/project-wallet.ts 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..b0f899904c5 100644 --- a/apps/dashboard/src/@/components/project/create-project-modal/index.tsx +++ b/apps/dashboard/src/@/components/project/create-project-modal/index.tsx @@ -36,9 +36,13 @@ import { Spinner } from "@/components/ui/Spinner"; import { Textarea } from "@/components/ui/textarea"; import { createProjectClient } from "@/hooks/useApi"; import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { getProjectWalletLabel } from "@/lib/project-wallet"; import { projectDomainsSchema, projectNameSchema } from "@/schema/validations"; import { toArrFromList } from "@/utils/string"; -import { createVaultAccountAndAccessToken } from "../../../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client"; +import { + createProjectServerWallet, + createVaultAccountAndAccessToken, +} from "../../../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client"; const ALL_PROJECT_SERVICES = SERVICES.filter( (srv) => srv.name !== "relayer" && srv.name !== "chainsaw", @@ -64,7 +68,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 +78,22 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => { ); throw error; }); + + const managementAccessToken = vaultTokens.managementToken?.accessToken; + + if (!managementAccessToken) { + throw new Error("Missing management access token for project wallet"); + } + + await createProjectServerWallet({ + label: getProjectWalletLabel(res.project.name), + managementAccessToken, + project: res.project, + }).catch((error) => { + console.error("Failed to create default project wallet", error); + throw error; + }); + 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/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..129a93c098a 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 @@ -31,9 +31,13 @@ import { Spinner } from "@/components/ui/Spinner"; import { Textarea } from "@/components/ui/textarea"; import { createProjectClient } from "@/hooks/useApi"; import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { getProjectWalletLabel } from "@/lib/project-wallet"; import { projectDomainsSchema, projectNameSchema } from "@/schema/validations"; import { toArrFromList } from "@/utils/string"; -import { createVaultAccountAndAccessToken } from "../../../../../team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client"; +import { + createProjectServerWallet, + createVaultAccountAndAccessToken, +} from "../../../../../team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client"; const ALL_PROJECT_SERVICES = SERVICES.filter( (srv) => srv.name !== "relayer" && srv.name !== "chainsaw", @@ -55,7 +59,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 +69,24 @@ export function CreateProjectFormOnboarding(props: { ); throw error; }); + + const managementAccessToken = + vaultTokens.managementToken?.accessToken; + + if (!managementAccessToken) { + throw new Error( + "Missing management access token for project wallet", + ); + } + + await createProjectServerWallet({ + label: getProjectWalletLabel(res.project.name), + managementAccessToken, + project: res.project, + }).catch((error) => { + console.error("Failed to create default project wallet", error); + throw error; + }); return { project: res.project, secret: res.secret, 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..fc51ef6cddb 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 @@ -1,3 +1,4 @@ +import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk"; import { ArrowLeftRightIcon, ChevronRightIcon, @@ -7,8 +8,10 @@ import { import Link from "next/link"; import type { Project } from "@/api/project/projects"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { CopyTextButton } from "@/components/ui/CopyTextButton"; import { CodeServer } from "@/components/ui/code/code.server"; import { UnderlineLink } from "@/components/ui/UnderlineLink"; +import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs"; import { DotNetIcon } from "@/icons/brand-icons/DotNetIcon"; import { GithubIcon } from "@/icons/brand-icons/GithubIcon"; import { ReactIcon } from "@/icons/brand-icons/ReactIcon"; @@ -18,13 +21,31 @@ 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 { ClientIDSection } from "./ClientIDSection"; import { IntegrateAPIKeyCodeTabs } from "./IntegrateAPIKeyCodeTabs"; import { SecretKeySection } from "./SecretKeySection"; -export function ProjectFTUX(props: { project: Project; teamSlug: string }) { +type ProjectWalletSummary = { + id: string; + address: string; + label?: string; +}; + +export async function ProjectFTUX(props: { + project: Project; + teamSlug: string; +}) { + const projectWallet = await fetchProjectWallet(props.project); + return (
+ +

+ Project Wallet +

+ +
+
+
+
+ +
+
+

{label}

+

+ Default managed server wallet for this project. Use it for + deployments, payments, and other automated flows. +

+
+
+ + {walletAddress ? ( +
+
+

+ Wallet address +

+

{walletAddress}

+
+ +
+ ) : ( + + + Project wallet unavailable + + We could not load the default wallet for this project. Visit the + Transactions page to create or refresh your server wallets. + + + )} + +
+

+ Manage balances, gas sponsorship, and smart account settings in + Transactions. +

+ + Open Transactions + + +
+
+
+ + ); +} + +type VaultWalletListItem = { + id: string; + address: string; + metadata?: { + label?: string; + projectId?: string; + teamId?: string; + type?: string; + }; +}; + +async function fetchProjectWallet( + project: Project, +): Promise { + const engineCloudService = project.services.find( + (service) => service.name === "engineCloud", + ); + + const managementAccessToken = + engineCloudService?.managementAccessToken || undefined; + + if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) { + 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: 25, + }, + }, + }); + + if (!response.success || !response.data) { + return undefined; + } + + const items = response.data.items as VaultWalletListItem[] | undefined; + + if (!items?.length) { + return undefined; + } + + const expectedLabel = getProjectWalletLabel(project.name); + + const serverWallets = items.filter( + (item) => item.metadata?.projectId === project.id, + ); + + const defaultWallet = + serverWallets.find((item) => item.metadata?.label === expectedLabel) || + serverWallets.find((item) => item.metadata?.type === "server-wallet") || + serverWallets[0]; + + if (!defaultWallet) { + return undefined; + } + + return { + id: defaultWallet.id, + address: defaultWallet.address, + label: defaultWallet.metadata?.label, + }; + } catch (error) { + console.error("Failed to load project wallet", error); + return undefined; + } +} + // Integrate API key section ------------------------------------------------------------ function IntegrateAPIKeySection({ 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..cc1b3bdc52d 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,56 @@ export async function createVaultAccountAndAccessToken(props: { } } +export async function createProjectServerWallet(props: { + project: Project; + managementAccessToken: string; + label?: string; +}) { + 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); + }); + + return eoa.data; +} + async function createAndEncryptVaultAccessTokens(props: { project: Project; vaultClient: VaultClient; 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..6791ff355b2 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,7 +15,7 @@ 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; @@ -36,49 +34,16 @@ 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, }); router.refresh(); setModalOpen(false); - - return eoa; + setLabel(""); + return wallet; }, onError: (error) => { toast.error(error.message); From 945ac3d878301506dff23f4c70b5cc6031d0df7c Mon Sep 17 00:00:00 2001 From: 0xFirekeeper <0xFirekeeper@gmail.com> Date: Tue, 7 Oct 2025 22:29:26 +0700 Subject: [PATCH 02/13] Add project wallet integration to payments UI Introduces a server utility to fetch the project wallet and propagates the project wallet address to payment-related components. Updates payment link creation and payout address forms to allow users to autofill with the project wallet address, improving usability and consistency across the dashboard. --- .../src/@/lib/server/project-wallet.ts | 92 ++++++++++++++++++ .../components/ProjectFTUX/ProjectFTUX.tsx | 94 +------------------ .../components/QuickstartSection.client.tsx | 2 + .../CreatePaymentLinkButton.client.tsx | 51 ++++++++-- .../components/PaymentLinksTable.client.tsx | 19 +++- .../(sidebar)/payments/page.tsx | 6 ++ .../(sidebar)/settings/payments/PayConfig.tsx | 34 ++++++- .../(sidebar)/settings/payments/page.tsx | 4 + 8 files changed, 200 insertions(+), 102 deletions(-) create mode 100644 apps/dashboard/src/@/lib/server/project-wallet.ts 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..b88b93f12e3 --- /dev/null +++ b/apps/dashboard/src/@/lib/server/project-wallet.ts @@ -0,0 +1,92 @@ +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; + + if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) { + 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: 25, + }, + }, + }); + + if (!response.success || !response.data) { + return undefined; + } + + const items = response.data.items as VaultWalletListItem[] | undefined; + + if (!items?.length) { + return undefined; + } + + const expectedLabel = getProjectWalletLabel(project.name); + + const serverWallets = items.filter( + (item) => item.metadata?.projectId === project.id, + ); + + const defaultWallet = + serverWallets.find((item) => item.metadata?.label === expectedLabel) ?? + serverWallets.find((item) => item.metadata?.type === "server-wallet") ?? + serverWallets[0]; + + if (!defaultWallet) { + return undefined; + } + + return { + id: defaultWallet.id, + address: defaultWallet.address, + label: defaultWallet.metadata?.label, + }; + } catch (error) { + console.error("Failed to load project wallet", error); + return 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 fc51ef6cddb..85eb280f6b4 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 @@ -1,4 +1,3 @@ -import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk"; import { ArrowLeftRightIcon, ChevronRightIcon, @@ -11,7 +10,6 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { CopyTextButton } from "@/components/ui/CopyTextButton"; import { CodeServer } from "@/components/ui/code/code.server"; import { UnderlineLink } from "@/components/ui/UnderlineLink"; -import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs"; import { DotNetIcon } from "@/icons/brand-icons/DotNetIcon"; import { GithubIcon } from "@/icons/brand-icons/GithubIcon"; import { ReactIcon } from "@/icons/brand-icons/ReactIcon"; @@ -23,21 +21,19 @@ 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 { IntegrateAPIKeyCodeTabs } from "./IntegrateAPIKeyCodeTabs"; import { SecretKeySection } from "./SecretKeySection"; -type ProjectWalletSummary = { - id: string; - address: string; - label?: string; -}; - export async function ProjectFTUX(props: { project: Project; teamSlug: string; }) { - const projectWallet = await fetchProjectWallet(props.project); + const projectWallet = await getProjectWallet(props.project); return (
@@ -139,86 +135,6 @@ function ProjectWalletSection(props: { ); } -type VaultWalletListItem = { - id: string; - address: string; - metadata?: { - label?: string; - projectId?: string; - teamId?: string; - type?: string; - }; -}; - -async function fetchProjectWallet( - project: Project, -): Promise { - const engineCloudService = project.services.find( - (service) => service.name === "engineCloud", - ); - - const managementAccessToken = - engineCloudService?.managementAccessToken || undefined; - - if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) { - 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: 25, - }, - }, - }); - - if (!response.success || !response.data) { - return undefined; - } - - const items = response.data.items as VaultWalletListItem[] | undefined; - - if (!items?.length) { - return undefined; - } - - const expectedLabel = getProjectWalletLabel(project.name); - - const serverWallets = items.filter( - (item) => item.metadata?.projectId === project.id, - ); - - const defaultWallet = - serverWallets.find((item) => item.metadata?.label === expectedLabel) || - serverWallets.find((item) => item.metadata?.type === "server-wallet") || - serverWallets[0]; - - if (!defaultWallet) { - return undefined; - } - - return { - id: defaultWallet.id, - address: defaultWallet.address, - label: defaultWallet.metadata?.label, - }; - } catch (error) { - console.error("Failed to load project wallet", error); - return undefined; - } -} - // Integrate API key section ------------------------------------------------------------ function IntegrateAPIKeySection({ 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} /> From 1926e9e499e924f79d43f6eda7854c537f9fdb41 Mon Sep 17 00:00:00 2001 From: 0xFirekeeper <0xFirekeeper@gmail.com> Date: Wed, 8 Oct 2025 01:24:51 +0700 Subject: [PATCH 03/13] Add project wallet controls with send/receive actions Introduces ProjectWalletControls component to manage project wallet actions, including copying the address, sending, and receiving funds. Adds a modal for sending tokens with form validation and credential caching. Refactors ProjectFTUX to use the new controls and implements the sendProjectWalletTokens server action. --- .../@/actions/project-wallet/send-tokens.ts | 72 +++ .../components/ProjectFTUX/ProjectFTUX.tsx | 30 +- .../ProjectWalletControls.client.tsx | 597 ++++++++++++++++++ 3 files changed, 680 insertions(+), 19 deletions(-) create mode 100644 apps/dashboard/src/@/actions/project-wallet/send-tokens.ts create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/ProjectWalletControls.client.tsx 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..013276d448b --- /dev/null +++ b/apps/dashboard/src/@/actions/project-wallet/send-tokens.ts @@ -0,0 +1,72 @@ +"use server"; + +import { apiServerProxy } from "@/actions/proxies"; + +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 apiServerProxy<{ + result?: { transactionIds: string[] }; + error?: { message?: string }; + }>({ + body: JSON.stringify({ + chainId, + from: walletAddress, + recipients: [ + { + address: recipientAddress, + quantity: quantityWei, + }, + ], + ...(tokenAddress ? { tokenAddress } : {}), + }), + headers: { + "Content-Type": "application/json", + "x-client-id": publishableKey, + "x-team-id": teamId, + ...(secretKey ? { "x-secret-key": secretKey } : {}), + ...(vaultAccessToken ? { "x-vault-access-token": vaultAccessToken } : {}), + }, + method: "POST", + pathname: "/v1/wallets/send", + }); + + if (!response.ok) { + 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/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 85eb280f6b4..c12dd8cf0ff 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,7 +7,6 @@ import { import Link from "next/link"; import type { Project } from "@/api/project/projects"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { CopyTextButton } from "@/components/ui/CopyTextButton"; import { CodeServer } from "@/components/ui/code/code.server"; import { UnderlineLink } from "@/components/ui/UnderlineLink"; import { DotNetIcon } from "@/icons/brand-icons/DotNetIcon"; @@ -27,6 +26,7 @@ import { } from "@/lib/server/project-wallet"; import { ClientIDSection } from "./ClientIDSection"; import { IntegrateAPIKeyCodeTabs } from "./IntegrateAPIKeyCodeTabs"; +import { ProjectWalletControls } from "./ProjectWalletControls.client"; import { SecretKeySection } from "./SecretKeySection"; export async function ProjectFTUX(props: { @@ -64,9 +64,6 @@ function ProjectWalletSection(props: { const defaultLabel = getProjectWalletLabel(props.project.name); const walletAddress = props.wallet?.address; const label = props.wallet?.label ?? defaultLabel; - const shortenedAddress = walletAddress - ? `${walletAddress.slice(0, 6)}...${walletAddress.slice(-4)}` - : undefined; return (
@@ -90,21 +87,16 @@ function ProjectWalletSection(props: { {walletAddress ? ( -
-
-

- Wallet address -

-

{walletAddress}

-
- -
+ ) : ( 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..c63310438e5 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/ProjectWalletControls.client.tsx @@ -0,0 +1,597 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { + CopyIcon, + EllipsisVerticalIcon, + RefreshCcwIcon, + SendIcon, + WalletIcon, +} from "lucide-react"; +import { useCallback, 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 { 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 { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + 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 { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import { useV5DashboardChain } from "@/hooks/chains/v5-adapter"; +import { cn } from "@/lib/utils"; + +type ProjectWalletControlsProps = { + walletAddress: string; + label: string; + project: Pick; + 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 client = useMemo(() => getClientThirdwebClient(), []); + const chain = useV5DashboardChain(selectedChainId); + + const engineCloudService = useMemo( + () => project.services?.find((service) => service.name === "engineCloud"), + [project.services], + ); + const isManagedVault = !!engineCloudService?.encryptedAdminKey; + + const balanceQuery = useWalletBalance({ + address: walletAddress, + chain, + client, + }); + + const balanceDisplay = balanceQuery.data + ? `${balanceQuery.data.displayValue} ${balanceQuery.data.symbol}` + : undefined; + + const handleCopyAddress = useCallback(async () => { + try { + await navigator.clipboard.writeText(walletAddress); + toast.success("Wallet address copied"); + } catch (error) { + console.error("Failed to copy wallet address", error); + toast.error("Unable to copy the address"); + } + }, [walletAddress]); + + return ( +
+
+
+
+
+

+ Wallet address +

+

{walletAddress}

+
+ + + + + + { + void handleCopyAddress(); + }} + > + + Copy address + + + setIsSendOpen(true)} + > + + Send funds + + setIsReceiveOpen(true)} + > + + Receive funds + + + +
+ +
+
+

+ Balance +

+
+ + {balanceQuery.isLoading ? ( + + ) : balanceDisplay ? ( + + {balanceDisplay} + + ) : ( + N/A + )} + +
+
+ +
+
+
+ + setIsSendOpen(false)} + onSuccess={() => balanceQuery.refetch()} + open={isSendOpen} + projectId={project.id} + 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") + .refine((value) => { + const parsed = Number(value); + return !Number.isNaN(parsed) && parsed > 0; + }, "Amount must be greater than 0"), + 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; + projectId: string; + chainId: number; + label: string; + client: ReturnType; + isManagedVault: boolean; +}) { + const { + open, + onClose, + onSuccess, + walletAddress, + publishableKey, + teamId, + chainId, + label, + client, + isManagedVault, + projectId, + } = 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.1", + chainId, + secretKey: "", + vaultAccessToken: "", + toAddress: "", + }, + mode: "onChange", + resolver: zodResolver(formSchema), + }); + + const credentialStorageKey = useMemo(() => { + const baseKey = projectId ?? publishableKey; + return `thirdweb-dashboard:project-wallet-credentials:${baseKey}`; + }, [projectId, publishableKey]); + + const selectedChain = useV5DashboardChain(form.watch("chainId")); + + useEffect(() => { + form.setValue("chainId", chainId); + }, [chainId, form]); + + useEffect(() => { + if (!open) { + const currentValues = form.getValues(); + form.reset({ + amount: "0.1", + chainId, + secretKey: currentValues.secretKey ?? "", + vaultAccessToken: currentValues.vaultAccessToken ?? "", + toAddress: "", + }); + } + }, [open, chainId, form]); + + useEffect(() => { + if (!open || typeof window === "undefined") { + return; + } + + try { + const raw = window.localStorage.getItem(credentialStorageKey); + if (!raw) { + return; + } + + const cached = JSON.parse(raw) as { + secretKey?: string; + vaultAccessToken?: string; + } | null; + + if (cached?.secretKey) { + form.setValue("secretKey", cached.secretKey, { + shouldDirty: false, + shouldValidate: true, + }); + } + + if (!isManagedVault && cached?.vaultAccessToken) { + form.setValue("vaultAccessToken", cached.vaultAccessToken, { + shouldDirty: false, + shouldValidate: false, + }); + } + } catch (error) { + console.error("Failed to restore cached wallet credentials", error); + } + }, [open, credentialStorageKey, form, isManagedVault]); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const subscription = form.watch((values, { name }) => { + if (!name || (name !== "secretKey" && name !== "vaultAccessToken")) { + return; + } + + const secretKeyValue = values.secretKey?.trim() ?? ""; + const vaultTokenValue = values.vaultAccessToken?.trim() ?? ""; + + if (!secretKeyValue && !vaultTokenValue) { + window.localStorage.removeItem(credentialStorageKey); + return; + } + + try { + const payload: Record = {}; + if (secretKeyValue) { + payload.secretKey = secretKeyValue; + } + + if (!isManagedVault && vaultTokenValue) { + payload.vaultAccessToken = vaultTokenValue; + } + + window.localStorage.setItem( + credentialStorageKey, + JSON.stringify(payload), + ); + } catch (error) { + console.error("Failed to cache wallet credentials", error); + } + }); + + return () => subscription.unsubscribe(); + }, [form, credentialStorageKey, isManagedVault]); + + 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) { + throw new Error(result.error ?? "Failed to send funds"); + } + + return result.transactionIds; + }, + onError: (error) => { + toast.error(error.message); + }, + onSuccess: (transactionIds) => { + toast.success("Transfer submitted", { + description: + transactionIds && transactionIds.length > 0 + ? `Reference 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} + + + + )} + /> + )} +
+ +
+ + +
+
+ +
+
+ ); +} From 496ecb7a79fceeaf365c42b47bfc94b5386b08cf Mon Sep 17 00:00:00 2001 From: 0xFirekeeper <0xFirekeeper@gmail.com> Date: Wed, 8 Oct 2025 01:29:34 +0700 Subject: [PATCH 04/13] Remove positive number validation for amount field The amount field in the send form schema no longer enforces a check for values greater than 0, only requiring a non-empty string. Default amount values are also changed from '0.1' to '0'. --- .../ProjectFTUX/ProjectWalletControls.client.tsx | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) 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 index c63310438e5..029fe9f278d 100644 --- 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 @@ -229,14 +229,7 @@ const createSendFormSchema = (secretKeyLabel: string) => .refine((value) => Boolean(isAddress(value)), { message: "Enter a valid wallet address", }), - amount: z - .string() - .trim() - .min(1, "Amount is required") - .refine((value) => { - const parsed = Number(value); - return !Number.isNaN(parsed) && parsed > 0; - }, "Amount must be greater than 0"), + amount: z.string().trim().min(1, "Amount is required"), secretKey: z.string().trim().min(1, `${secretKeyLabel} is required`), vaultAccessToken: z.string().trim(), }); @@ -283,7 +276,7 @@ function SendProjectWalletModal(props: { const form = useForm({ defaultValues: { - amount: "0.1", + amount: "0", chainId, secretKey: "", vaultAccessToken: "", @@ -308,7 +301,7 @@ function SendProjectWalletModal(props: { if (!open) { const currentValues = form.getValues(); form.reset({ - amount: "0.1", + amount: "0", chainId, secretKey: currentValues.secretKey ?? "", vaultAccessToken: currentValues.vaultAccessToken ?? "", From 248ca0bbf3591711e89bf55e05fb13a4038b047c Mon Sep 17 00:00:00 2001 From: 0xFirekeeper <0xFirekeeper@gmail.com> Date: Wed, 8 Oct 2025 01:46:38 +0700 Subject: [PATCH 05/13] Refactor send tokens action to use thirdweb API Replaces the custom proxy with thirdweb's sendTokens API and configures the base URL. Updates error handling and response parsing in sendProjectWalletTokens. Also improves error messaging and transaction ID display in ProjectWalletControls. --- .../@/actions/project-wallet/send-tokens.ts | 26 ++++++++++--------- .../ProjectWalletControls.client.tsx | 8 ++++-- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/apps/dashboard/src/@/actions/project-wallet/send-tokens.ts b/apps/dashboard/src/@/actions/project-wallet/send-tokens.ts index 013276d448b..6cb4875fb94 100644 --- a/apps/dashboard/src/@/actions/project-wallet/send-tokens.ts +++ b/apps/dashboard/src/@/actions/project-wallet/send-tokens.ts @@ -1,6 +1,13 @@ "use server"; -import { apiServerProxy } from "@/actions/proxies"; +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; @@ -32,11 +39,8 @@ export async function sendProjectWalletTokens(options: { } as const; } - const response = await apiServerProxy<{ - result?: { transactionIds: string[] }; - error?: { message?: string }; - }>({ - body: JSON.stringify({ + const response = await sendTokens({ + body: { chainId, from: walletAddress, recipients: [ @@ -46,19 +50,17 @@ export async function sendProjectWalletTokens(options: { }, ], ...(tokenAddress ? { tokenAddress } : {}), - }), + }, headers: { "Content-Type": "application/json", "x-client-id": publishableKey, + "x-secret-key": secretKey, "x-team-id": teamId, - ...(secretKey ? { "x-secret-key": secretKey } : {}), ...(vaultAccessToken ? { "x-vault-access-token": vaultAccessToken } : {}), }, - method: "POST", - pathname: "/v1/wallets/send", }); - if (!response.ok) { + if (response.error || !response.data) { return { error: response.error || "Failed to submit transfer request.", ok: false, @@ -67,6 +69,6 @@ export async function sendProjectWalletTokens(options: { return { ok: true, - transactionIds: response.data?.result?.transactionIds ?? [], + transactionIds: response.data.result?.transactionIds ?? [], } as const; } 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 index 029fe9f278d..2854c953f18 100644 --- 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 @@ -404,7 +404,11 @@ function SendProjectWalletModal(props: { }); if (!result.ok) { - throw new Error(result.error ?? "Failed to send funds"); + const errorMessage = + typeof result.error === "string" + ? result.error + : "Failed to send funds"; + throw new Error(errorMessage); } return result.transactionIds; @@ -416,7 +420,7 @@ function SendProjectWalletModal(props: { toast.success("Transfer submitted", { description: transactionIds && transactionIds.length > 0 - ? `Reference ID: ${transactionIds[0]}` + ? `Transaction ID: ${transactionIds[0]}` : undefined, }); onSuccess(); From e5b63c7f0e7e61b839981667e80a84ceff9dce6c Mon Sep 17 00:00:00 2001 From: 0xFirekeeper <0xFirekeeper@gmail.com> Date: Wed, 8 Oct 2025 01:58:22 +0700 Subject: [PATCH 06/13] sorry --- .../components/ProjectFTUX/ProjectWalletControls.client.tsx | 4 ++++ 1 file changed, 4 insertions(+) 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 index 2854c953f18..6be2bc6b6b2 100644 --- 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 @@ -293,10 +293,12 @@ function SendProjectWalletModal(props: { 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(); @@ -310,6 +312,7 @@ function SendProjectWalletModal(props: { } }, [open, chainId, form]); + // eslint-disable-next-line no-restricted-syntax -- restoring credentials from localStorage requires side effects useEffect(() => { if (!open || typeof window === "undefined") { return; @@ -344,6 +347,7 @@ function SendProjectWalletModal(props: { } }, [open, credentialStorageKey, form, isManagedVault]); + // eslint-disable-next-line no-restricted-syntax -- persist credential updates while modal is open useEffect(() => { if (typeof window === "undefined") { return; From b883eb3c86c1ff7f4547c7b96e17f5db84b3f359 Mon Sep 17 00:00:00 2001 From: 0xFirekeeper <0xFirekeeper@gmail.com> Date: Wed, 8 Oct 2025 03:41:43 +0700 Subject: [PATCH 07/13] Refactor ProjectFTUX to accept wallet prop and update usage Refactored ProjectFTUX and ProjectWalletSection to accept an optional wallet prop, allowing the wallet to be fetched once and passed down. Updated the project overview page to fetch the project wallet and pass it to both ProjectWalletSection and ProjectFTUX, reducing redundant API calls and improving efficiency. Also added a lint ignore comment in AnnouncementBanner.tsx. --- .../@/components/misc/AnnouncementBanner.tsx | 1 + .../components/ProjectFTUX/ProjectFTUX.tsx | 965 +++++++++--------- .../[project_slug]/(sidebar)/page.tsx | 19 +- 3 files changed, 501 insertions(+), 484 deletions(-) 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/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 c12dd8cf0ff..80d8ab65b5d 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 @@ -1,8 +1,8 @@ import { - ArrowLeftRightIcon, - ChevronRightIcon, - CircleAlertIcon, - ExternalLinkIcon, + ArrowLeftRightIcon, + ChevronRightIcon, + CircleAlertIcon, + ExternalLinkIcon, } from "lucide-react"; import Link from "next/link"; import type { Project } from "@/api/project/projects"; @@ -21,8 +21,8 @@ import { PayIcon } from "@/icons/PayIcon"; import { WalletProductIcon } from "@/icons/WalletProductIcon"; import { getProjectWalletLabel } from "@/lib/project-wallet"; import { - getProjectWallet, - type ProjectWalletSummary, + getProjectWallet, + type ProjectWalletSummary, } from "@/lib/server/project-wallet"; import { ClientIDSection } from "./ClientIDSection"; import { IntegrateAPIKeyCodeTabs } from "./IntegrateAPIKeyCodeTabs"; @@ -30,268 +30,269 @@ import { ProjectWalletControls } from "./ProjectWalletControls.client"; import { SecretKeySection } from "./SecretKeySection"; export async function ProjectFTUX(props: { - project: Project; - teamSlug: string; + project: Project; + teamSlug: string; + wallet?: ProjectWalletSummary | undefined; }) { - const projectWallet = await getProjectWallet(props.project); - - return ( -
- - - - - -
- ); + const projectWallet = props.wallet ?? (await getProjectWallet(props.project)); + + return ( +
+ + + + + +
+ ); } -function ProjectWalletSection(props: { - project: Project; - teamSlug: string; - wallet: ProjectWalletSummary | undefined; +export function ProjectWalletSection(props: { + project: Project; + teamSlug: string; + wallet: ProjectWalletSummary | undefined; }) { - const defaultLabel = getProjectWalletLabel(props.project.name); - const walletAddress = props.wallet?.address; - const label = props.wallet?.label ?? defaultLabel; - - return ( -
-

- Project Wallet -

- -
-
-
-
- -
-
-

{label}

-

- Default managed server wallet for this project. Use it for - deployments, payments, and other automated flows. -

-
-
- - {walletAddress ? ( - - ) : ( - - - Project wallet unavailable - - We could not load the default wallet for this project. Visit the - Transactions page to create or refresh your server wallets. - - - )} - -
-

- Manage balances, gas sponsorship, and smart account settings in - Transactions. -

- - Open Transactions - - -
-
-
-
- ); + const defaultLabel = getProjectWalletLabel(props.project.name); + const walletAddress = props.wallet?.address; + const label = props.wallet?.label ?? defaultLabel; + + return ( +
+

+ Project Wallet +

+ +
+
+
+
+ +
+
+

{label}

+

+ Default managed server wallet for this project. Use it for + deployments, payments, and other automated flows. +

+
+
+ + {walletAddress ? ( + + ) : ( + + + Project wallet unavailable + + We could not load the default wallet for this project. Visit the + Transactions page to create or refresh your server wallets. + + + )} + +
+

+ Manage balances, gas sponsorship, and smart account settings in + Transactions. +

+ + Open Transactions + + +
+
+
+
+ ); } // Integrate API key section ------------------------------------------------------------ function IntegrateAPIKeySection({ - project, - teamSlug, + project, + teamSlug, }: { - project: Project; - teamSlug: string; + project: Project; + teamSlug: string; }) { - const secretKeyMasked = project.secretKeys[0]?.masked; - const clientId = project.publishableKey; - - return ( -
-

- Integrate API key -

- -
-
- - {secretKeyMasked && ( - - )} -
- -
- -
-
- ); + const secretKeyMasked = project.secretKeys[0]?.masked; + const clientId = project.publishableKey; + + return ( +
+

+ Integrate API key +

+ +
+
+ + {secretKeyMasked && ( + + )} +
+ +
+ +
+
+ ); } function IntegrationCodeExamples(props: { - project: Project; - teamSlug: string; + project: Project; + teamSlug: string; }) { - return ( - - - - - - Configure your app's bundle ID in "Allowed Bundle IDs" in - Project - - - Go to{" "} - - Project settings - {" "} - and add your app's bundle ID to the "Allowed Bundle IDs" list. - - - - ), - react: ( - - ), - "react-native": ( - - ), - ts: ( - - ), - unity: ( - - - - Configure Client ID in Thirdweb Manager prefab - - - Configure "Client ID" and "Bundle ID" in{" "} - - Thirdweb Manager prefab - - - Make sure to configure your app's bundle ID in "Allowed Bundle - IDs" in{" "} - - Project settings - - - - - ), - unreal: ( - - - - Configure Client ID in Thirdweb Unreal Plugin{" "} - - - Configure "Client ID" and "Bundle ID" in{" "} - - thirdweb plugin settings - - - Make sure to configure your app's bundle ID in "Allowed Bundle - IDs" in{" "} - - Project settings - - - - - ), - api: ( - - ), - curl: ( - - ), - }} - /> - ); + return ( + + + + + + Configure your app's bundle ID in "Allowed Bundle IDs" in + Project + + + Go to{" "} + + Project settings + {" "} + and add your app's bundle ID to the "Allowed Bundle IDs" list. + + + + ), + react: ( + + ), + "react-native": ( + + ), + ts: ( + + ), + unity: ( + + + + Configure Client ID in Thirdweb Manager prefab + + + Configure "Client ID" and "Bundle ID" in{" "} + + Thirdweb Manager prefab + + + Make sure to configure your app's bundle ID in "Allowed Bundle + IDs" in{" "} + + Project settings + + + + + ), + unreal: ( + + + + Configure Client ID in Thirdweb Unreal Plugin{" "} + + + Configure "Client ID" and "Bundle ID" in{" "} + + thirdweb plugin settings + + + Make sure to configure your app's bundle ID in "Allowed Bundle + IDs" in{" "} + + Project settings + + + + + ), + api: ( + + ), + curl: ( + + ), + }} + /> + ); } const typescriptCodeExample = (project: Project) => `\ @@ -348,265 +349,265 @@ curl https://api.thirdweb.com/v1/wallets/server \\ // products section ------------------------------------------------------------ function ProductsSection(props: { teamSlug: string; projectSlug: string }) { - const products: Array<{ - title: string; - description: string; - href: string; - icon: React.FC<{ className?: string }>; - }> = [ - { - description: - "Scale your application with a backend server to read, write, and deploy contracts at production-grade.", - href: `/team/${props.teamSlug}/${props.projectSlug}/transactions`, - icon: ArrowLeftRightIcon, - title: "Transactions", - }, - { - description: - "Deploy your own contracts or leverage existing solutions for onchain implementation", - href: `/team/${props.teamSlug}/${props.projectSlug}/contracts`, - icon: ContractIcon, - title: "Contracts", - }, - { - description: - "Add indexing capabilities to retrieve real-time onchain data", - href: `/team/${props.teamSlug}/${props.projectSlug}/insight`, - icon: InsightIcon, - title: "Insight", - }, - { - description: - "Bridge, swap, and purchase cryptocurrencies with any fiat options or tokens via cross-chain routing", - href: `/team/${props.teamSlug}/${props.projectSlug}/payments`, - icon: PayIcon, - title: "Payments", - }, - ]; - - return ( -
-

- Complete your full-stack application -

-

- Tools to build frontend, backend, and onchain with built-in - infrastructure and analytics. -

- -
- - {/* Feature Cards */} -
- {products.map((product) => ( - - ))} -
-
- ); + const products: Array<{ + title: string; + description: string; + href: string; + icon: React.FC<{ className?: string }>; + }> = [ + { + description: + "Scale your application with a backend server to read, write, and deploy contracts at production-grade.", + href: `/team/${props.teamSlug}/${props.projectSlug}/transactions`, + icon: ArrowLeftRightIcon, + title: "Transactions", + }, + { + description: + "Deploy your own contracts or leverage existing solutions for onchain implementation", + href: `/team/${props.teamSlug}/${props.projectSlug}/contracts`, + icon: ContractIcon, + title: "Contracts", + }, + { + description: + "Add indexing capabilities to retrieve real-time onchain data", + href: `/team/${props.teamSlug}/${props.projectSlug}/insight`, + icon: InsightIcon, + title: "Insight", + }, + { + description: + "Bridge, swap, and purchase cryptocurrencies with any fiat options or tokens via cross-chain routing", + href: `/team/${props.teamSlug}/${props.projectSlug}/payments`, + icon: PayIcon, + title: "Payments", + }, + ]; + + return ( +
+

+ Complete your full-stack application +

+

+ Tools to build frontend, backend, and onchain with built-in + infrastructure and analytics. +

+ +
+ + {/* Feature Cards */} +
+ {products.map((product) => ( + + ))} +
+
+ ); } function ProductCard(props: { - title: string; - description: string; - href: string; - icon: React.FC<{ className?: string }>; + title: string; + description: string; + href: string; + icon: React.FC<{ className?: string }>; }) { - return ( -
-
- -
-

- - {props.title} - -

-

{props.description}

-
- ); + return ( +
+
+ +
+

+ + {props.title} + +

+

{props.description}

+
+ ); } // sdk section ------------------------------------------------------------ type SDKCardProps = { - name: string; - href: string; - icon: React.FC<{ className?: string }>; - trackingLabel: string; + name: string; + href: string; + icon: React.FC<{ className?: string }>; + trackingLabel: string; }; const sdks: SDKCardProps[] = [ - { - href: "https://portal.thirdweb.com/sdk/typescript", - icon: TypeScriptIcon, - name: "TypeScript", - trackingLabel: "typescript", - }, - { - href: "https://portal.thirdweb.com/react/v5", - icon: ReactIcon, - name: "React", - trackingLabel: "react", - }, - { - href: "https://portal.thirdweb.com/react-native/v5", - icon: ReactIcon, - name: "React Native", - trackingLabel: "react_native", - }, - { - href: "https://portal.thirdweb.com/unity/v5", - icon: UnityIcon, - name: "Unity", - trackingLabel: "unity", - }, - { - href: "https://portal.thirdweb.com/unreal-engine", - icon: UnrealIcon, - name: "Unreal Engine", - trackingLabel: "unreal", - }, - { - href: "https://portal.thirdweb.com/dotnet", - icon: DotNetIcon, - name: ".NET", - trackingLabel: "dotnet", - }, + { + href: "https://portal.thirdweb.com/sdk/typescript", + icon: TypeScriptIcon, + name: "TypeScript", + trackingLabel: "typescript", + }, + { + href: "https://portal.thirdweb.com/react/v5", + icon: ReactIcon, + name: "React", + trackingLabel: "react", + }, + { + href: "https://portal.thirdweb.com/react-native/v5", + icon: ReactIcon, + name: "React Native", + trackingLabel: "react_native", + }, + { + href: "https://portal.thirdweb.com/unity/v5", + icon: UnityIcon, + name: "Unity", + trackingLabel: "unity", + }, + { + href: "https://portal.thirdweb.com/unreal-engine", + icon: UnrealIcon, + name: "Unreal Engine", + trackingLabel: "unreal", + }, + { + href: "https://portal.thirdweb.com/dotnet", + icon: DotNetIcon, + name: ".NET", + trackingLabel: "dotnet", + }, ]; function SDKSection() { - return ( -
-

Client SDKs

-
- {sdks.map((sdk) => ( - - ))} -
-
- ); + return ( +
+

Client SDKs

+
+ {sdks.map((sdk) => ( + + ))} +
+
+ ); } function SDKCard(props: SDKCardProps) { - return ( -
-
- -
-
-

- - {props.name} - -

-

- View Docs - -

-
-
- ); + return ( +
+
+ +
+
+

+ + {props.name} + +

+

+ View Docs + +

+
+
+ ); } // starter kits section ------------------------------------------------------------ type StartedKitCardProps = { - name: string; - href: string; - trackingLabel: string; + name: string; + href: string; + trackingLabel: string; }; const startedKits: StartedKitCardProps[] = [ - { - href: "https://github.com/thirdweb-example/next-starter", - name: "Next Starter", - trackingLabel: "next_starter", - }, - { - href: "https://github.com/thirdweb-example/vite-starter", - name: "Vite Starter", - trackingLabel: "vite_starter", - }, - { - href: "https://github.com/thirdweb-example/expo-starter", - name: "Expo Starter", - trackingLabel: "expo_starter", - }, - { - href: "https://github.com/thirdweb-example/node-starter", - name: "Node Starter", - trackingLabel: "node_starter", - }, + { + href: "https://github.com/thirdweb-example/next-starter", + name: "Next Starter", + trackingLabel: "next_starter", + }, + { + href: "https://github.com/thirdweb-example/vite-starter", + name: "Vite Starter", + trackingLabel: "vite_starter", + }, + { + href: "https://github.com/thirdweb-example/expo-starter", + name: "Expo Starter", + trackingLabel: "expo_starter", + }, + { + href: "https://github.com/thirdweb-example/node-starter", + name: "Node Starter", + trackingLabel: "node_starter", + }, ]; function StarterKitsSection() { - return ( -
-
-

Starter Kits

- - View all - -
- -
- {startedKits.map((kit) => ( - - ))} -
-
- ); + return ( +
+
+

Starter Kits

+ + View all + +
+ +
+ {startedKits.map((kit) => ( + + ))} +
+
+ ); } function StarterKitCard(props: StartedKitCardProps) { - return ( -
-
- -
- -
- - {props.name} - -

- View Repo - -

-
-
- ); + return ( +
+
+ +
+ +
+ + {props.name} + +

+ View Repo + +

+
+
+ ); } 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..72c3a2a05d0 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,8 @@ export default async function ProjectOverviewPage(props: PageProps) { teamId: project.teamId, }); + const projectWallet = await getProjectWallet(project); + return ( {isActive ? (
+
) : ( - + )}
From 8db30115a660f7c9c4d3d81520af6ba48d4070ad Mon Sep 17 00:00:00 2001 From: 0xFirekeeper <0xFirekeeper@gmail.com> Date: Wed, 8 Oct 2025 03:46:09 +0700 Subject: [PATCH 08/13] fmt --- .../components/ProjectFTUX/ProjectFTUX.tsx | 964 +++++++++--------- 1 file changed, 482 insertions(+), 482 deletions(-) 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 80d8ab65b5d..658a8d52a65 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 @@ -1,8 +1,8 @@ import { - ArrowLeftRightIcon, - ChevronRightIcon, - CircleAlertIcon, - ExternalLinkIcon, + ArrowLeftRightIcon, + ChevronRightIcon, + CircleAlertIcon, + ExternalLinkIcon, } from "lucide-react"; import Link from "next/link"; import type { Project } from "@/api/project/projects"; @@ -21,8 +21,8 @@ import { PayIcon } from "@/icons/PayIcon"; import { WalletProductIcon } from "@/icons/WalletProductIcon"; import { getProjectWalletLabel } from "@/lib/project-wallet"; import { - getProjectWallet, - type ProjectWalletSummary, + getProjectWallet, + type ProjectWalletSummary, } from "@/lib/server/project-wallet"; import { ClientIDSection } from "./ClientIDSection"; import { IntegrateAPIKeyCodeTabs } from "./IntegrateAPIKeyCodeTabs"; @@ -30,269 +30,269 @@ import { ProjectWalletControls } from "./ProjectWalletControls.client"; import { SecretKeySection } from "./SecretKeySection"; export async function ProjectFTUX(props: { - project: Project; - teamSlug: string; - wallet?: ProjectWalletSummary | undefined; + project: Project; + teamSlug: string; + wallet?: ProjectWalletSummary | undefined; }) { - const projectWallet = props.wallet ?? (await getProjectWallet(props.project)); - - return ( -
- - - - - -
- ); + const projectWallet = props.wallet ?? (await getProjectWallet(props.project)); + + return ( +
+ + + + + +
+ ); } export function ProjectWalletSection(props: { - project: Project; - teamSlug: string; - wallet: ProjectWalletSummary | undefined; + project: Project; + teamSlug: string; + wallet: ProjectWalletSummary | undefined; }) { - const defaultLabel = getProjectWalletLabel(props.project.name); - const walletAddress = props.wallet?.address; - const label = props.wallet?.label ?? defaultLabel; - - return ( -
-

- Project Wallet -

- -
-
-
-
- -
-
-

{label}

-

- Default managed server wallet for this project. Use it for - deployments, payments, and other automated flows. -

-
-
- - {walletAddress ? ( - - ) : ( - - - Project wallet unavailable - - We could not load the default wallet for this project. Visit the - Transactions page to create or refresh your server wallets. - - - )} - -
-

- Manage balances, gas sponsorship, and smart account settings in - Transactions. -

- - Open Transactions - - -
-
-
-
- ); + const defaultLabel = getProjectWalletLabel(props.project.name); + const walletAddress = props.wallet?.address; + const label = props.wallet?.label ?? defaultLabel; + + return ( +
+

+ Project Wallet +

+ +
+
+
+
+ +
+
+

{label}

+

+ Default managed server wallet for this project. Use it for + deployments, payments, and other automated flows. +

+
+
+ + {walletAddress ? ( + + ) : ( + + + Project wallet unavailable + + We could not load the default wallet for this project. Visit the + Transactions page to create or refresh your server wallets. + + + )} + +
+

+ Manage balances, gas sponsorship, and smart account settings in + Transactions. +

+ + Open Transactions + + +
+
+
+
+ ); } // Integrate API key section ------------------------------------------------------------ function IntegrateAPIKeySection({ - project, - teamSlug, + project, + teamSlug, }: { - project: Project; - teamSlug: string; + project: Project; + teamSlug: string; }) { - const secretKeyMasked = project.secretKeys[0]?.masked; - const clientId = project.publishableKey; - - return ( -
-

- Integrate API key -

- -
-
- - {secretKeyMasked && ( - - )} -
- -
- -
-
- ); + const secretKeyMasked = project.secretKeys[0]?.masked; + const clientId = project.publishableKey; + + return ( +
+

+ Integrate API key +

+ +
+
+ + {secretKeyMasked && ( + + )} +
+ +
+ +
+
+ ); } function IntegrationCodeExamples(props: { - project: Project; - teamSlug: string; + project: Project; + teamSlug: string; }) { - return ( - - - - - - Configure your app's bundle ID in "Allowed Bundle IDs" in - Project - - - Go to{" "} - - Project settings - {" "} - and add your app's bundle ID to the "Allowed Bundle IDs" list. - - -
- ), - react: ( - - ), - "react-native": ( - - ), - ts: ( - - ), - unity: ( - - - - Configure Client ID in Thirdweb Manager prefab - - - Configure "Client ID" and "Bundle ID" in{" "} - - Thirdweb Manager prefab - - - Make sure to configure your app's bundle ID in "Allowed Bundle - IDs" in{" "} - - Project settings - - - - - ), - unreal: ( - - - - Configure Client ID in Thirdweb Unreal Plugin{" "} - - - Configure "Client ID" and "Bundle ID" in{" "} - - thirdweb plugin settings - - - Make sure to configure your app's bundle ID in "Allowed Bundle - IDs" in{" "} - - Project settings - - - - - ), - api: ( - - ), - curl: ( - - ), - }} - /> - ); + return ( + + + + + + Configure your app's bundle ID in "Allowed Bundle IDs" in + Project + + + Go to{" "} + + Project settings + {" "} + and add your app's bundle ID to the "Allowed Bundle IDs" list. + + +
+ ), + react: ( + + ), + "react-native": ( + + ), + ts: ( + + ), + unity: ( + + + + Configure Client ID in Thirdweb Manager prefab + + + Configure "Client ID" and "Bundle ID" in{" "} + + Thirdweb Manager prefab + + + Make sure to configure your app's bundle ID in "Allowed Bundle + IDs" in{" "} + + Project settings + + + + + ), + unreal: ( + + + + Configure Client ID in Thirdweb Unreal Plugin{" "} + + + Configure "Client ID" and "Bundle ID" in{" "} + + thirdweb plugin settings + + + Make sure to configure your app's bundle ID in "Allowed Bundle + IDs" in{" "} + + Project settings + + + + + ), + api: ( + + ), + curl: ( + + ), + }} + /> + ); } const typescriptCodeExample = (project: Project) => `\ @@ -349,265 +349,265 @@ curl https://api.thirdweb.com/v1/wallets/server \\ // products section ------------------------------------------------------------ function ProductsSection(props: { teamSlug: string; projectSlug: string }) { - const products: Array<{ - title: string; - description: string; - href: string; - icon: React.FC<{ className?: string }>; - }> = [ - { - description: - "Scale your application with a backend server to read, write, and deploy contracts at production-grade.", - href: `/team/${props.teamSlug}/${props.projectSlug}/transactions`, - icon: ArrowLeftRightIcon, - title: "Transactions", - }, - { - description: - "Deploy your own contracts or leverage existing solutions for onchain implementation", - href: `/team/${props.teamSlug}/${props.projectSlug}/contracts`, - icon: ContractIcon, - title: "Contracts", - }, - { - description: - "Add indexing capabilities to retrieve real-time onchain data", - href: `/team/${props.teamSlug}/${props.projectSlug}/insight`, - icon: InsightIcon, - title: "Insight", - }, - { - description: - "Bridge, swap, and purchase cryptocurrencies with any fiat options or tokens via cross-chain routing", - href: `/team/${props.teamSlug}/${props.projectSlug}/payments`, - icon: PayIcon, - title: "Payments", - }, - ]; - - return ( -
-

- Complete your full-stack application -

-

- Tools to build frontend, backend, and onchain with built-in - infrastructure and analytics. -

- -
- - {/* Feature Cards */} -
- {products.map((product) => ( - - ))} -
-
- ); + const products: Array<{ + title: string; + description: string; + href: string; + icon: React.FC<{ className?: string }>; + }> = [ + { + description: + "Scale your application with a backend server to read, write, and deploy contracts at production-grade.", + href: `/team/${props.teamSlug}/${props.projectSlug}/transactions`, + icon: ArrowLeftRightIcon, + title: "Transactions", + }, + { + description: + "Deploy your own contracts or leverage existing solutions for onchain implementation", + href: `/team/${props.teamSlug}/${props.projectSlug}/contracts`, + icon: ContractIcon, + title: "Contracts", + }, + { + description: + "Add indexing capabilities to retrieve real-time onchain data", + href: `/team/${props.teamSlug}/${props.projectSlug}/insight`, + icon: InsightIcon, + title: "Insight", + }, + { + description: + "Bridge, swap, and purchase cryptocurrencies with any fiat options or tokens via cross-chain routing", + href: `/team/${props.teamSlug}/${props.projectSlug}/payments`, + icon: PayIcon, + title: "Payments", + }, + ]; + + return ( +
+

+ Complete your full-stack application +

+

+ Tools to build frontend, backend, and onchain with built-in + infrastructure and analytics. +

+ +
+ + {/* Feature Cards */} +
+ {products.map((product) => ( + + ))} +
+
+ ); } function ProductCard(props: { - title: string; - description: string; - href: string; - icon: React.FC<{ className?: string }>; + title: string; + description: string; + href: string; + icon: React.FC<{ className?: string }>; }) { - return ( -
-
- -
-

- - {props.title} - -

-

{props.description}

-
- ); + return ( +
+
+ +
+

+ + {props.title} + +

+

{props.description}

+
+ ); } // sdk section ------------------------------------------------------------ type SDKCardProps = { - name: string; - href: string; - icon: React.FC<{ className?: string }>; - trackingLabel: string; + name: string; + href: string; + icon: React.FC<{ className?: string }>; + trackingLabel: string; }; const sdks: SDKCardProps[] = [ - { - href: "https://portal.thirdweb.com/sdk/typescript", - icon: TypeScriptIcon, - name: "TypeScript", - trackingLabel: "typescript", - }, - { - href: "https://portal.thirdweb.com/react/v5", - icon: ReactIcon, - name: "React", - trackingLabel: "react", - }, - { - href: "https://portal.thirdweb.com/react-native/v5", - icon: ReactIcon, - name: "React Native", - trackingLabel: "react_native", - }, - { - href: "https://portal.thirdweb.com/unity/v5", - icon: UnityIcon, - name: "Unity", - trackingLabel: "unity", - }, - { - href: "https://portal.thirdweb.com/unreal-engine", - icon: UnrealIcon, - name: "Unreal Engine", - trackingLabel: "unreal", - }, - { - href: "https://portal.thirdweb.com/dotnet", - icon: DotNetIcon, - name: ".NET", - trackingLabel: "dotnet", - }, + { + href: "https://portal.thirdweb.com/sdk/typescript", + icon: TypeScriptIcon, + name: "TypeScript", + trackingLabel: "typescript", + }, + { + href: "https://portal.thirdweb.com/react/v5", + icon: ReactIcon, + name: "React", + trackingLabel: "react", + }, + { + href: "https://portal.thirdweb.com/react-native/v5", + icon: ReactIcon, + name: "React Native", + trackingLabel: "react_native", + }, + { + href: "https://portal.thirdweb.com/unity/v5", + icon: UnityIcon, + name: "Unity", + trackingLabel: "unity", + }, + { + href: "https://portal.thirdweb.com/unreal-engine", + icon: UnrealIcon, + name: "Unreal Engine", + trackingLabel: "unreal", + }, + { + href: "https://portal.thirdweb.com/dotnet", + icon: DotNetIcon, + name: ".NET", + trackingLabel: "dotnet", + }, ]; function SDKSection() { - return ( -
-

Client SDKs

-
- {sdks.map((sdk) => ( - - ))} -
-
- ); + return ( +
+

Client SDKs

+
+ {sdks.map((sdk) => ( + + ))} +
+
+ ); } function SDKCard(props: SDKCardProps) { - return ( -
-
- -
-
-

- - {props.name} - -

-

- View Docs - -

-
-
- ); + return ( +
+
+ +
+
+

+ + {props.name} + +

+

+ View Docs + +

+
+
+ ); } // starter kits section ------------------------------------------------------------ type StartedKitCardProps = { - name: string; - href: string; - trackingLabel: string; + name: string; + href: string; + trackingLabel: string; }; const startedKits: StartedKitCardProps[] = [ - { - href: "https://github.com/thirdweb-example/next-starter", - name: "Next Starter", - trackingLabel: "next_starter", - }, - { - href: "https://github.com/thirdweb-example/vite-starter", - name: "Vite Starter", - trackingLabel: "vite_starter", - }, - { - href: "https://github.com/thirdweb-example/expo-starter", - name: "Expo Starter", - trackingLabel: "expo_starter", - }, - { - href: "https://github.com/thirdweb-example/node-starter", - name: "Node Starter", - trackingLabel: "node_starter", - }, + { + href: "https://github.com/thirdweb-example/next-starter", + name: "Next Starter", + trackingLabel: "next_starter", + }, + { + href: "https://github.com/thirdweb-example/vite-starter", + name: "Vite Starter", + trackingLabel: "vite_starter", + }, + { + href: "https://github.com/thirdweb-example/expo-starter", + name: "Expo Starter", + trackingLabel: "expo_starter", + }, + { + href: "https://github.com/thirdweb-example/node-starter", + name: "Node Starter", + trackingLabel: "node_starter", + }, ]; function StarterKitsSection() { - return ( -
-
-

Starter Kits

- - View all - -
- -
- {startedKits.map((kit) => ( - - ))} -
-
- ); + return ( +
+
+

Starter Kits

+ + View all + +
+ +
+ {startedKits.map((kit) => ( + + ))} +
+
+ ); } function StarterKitCard(props: StartedKitCardProps) { - return ( -
-
- -
- -
- - {props.name} - -

- View Repo - -

-
-
- ); + return ( +
+
+ +
+ +
+ + {props.name} + +

+ View Repo + +

+
+
+ ); } From 3b32b8a8dedb246e011d8d8688c4506c340a79c6 Mon Sep 17 00:00:00 2001 From: 0xFirekeeper <0xFirekeeper@gmail.com> Date: Wed, 8 Oct 2025 03:53:48 +0700 Subject: [PATCH 09/13] Remove unused projectId prop from SendProjectWalletModal The projectId prop was removed from the SendProjectWalletModal component and its usage, as it was not being used in the modal logic. This simplifies the component's interface and avoids passing unnecessary props. --- .../ProjectWalletControls.client.tsx | 1022 ++++++++--------- 1 file changed, 469 insertions(+), 553 deletions(-) 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 index 6be2bc6b6b2..6688c6d72dd 100644 --- 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 @@ -3,11 +3,11 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation } from "@tanstack/react-query"; import { - CopyIcon, - EllipsisVerticalIcon, - RefreshCcwIcon, - SendIcon, - WalletIcon, + CopyIcon, + EllipsisVerticalIcon, + RefreshCcwIcon, + SendIcon, + WalletIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; @@ -22,27 +22,27 @@ import { FundWalletModal } from "@/components/blocks/fund-wallets-modal"; import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; import { Button } from "@/components/ui/button"; import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, } from "@/components/ui/dialog"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/Spinner"; @@ -51,548 +51,464 @@ import { useV5DashboardChain } from "@/hooks/chains/v5-adapter"; import { cn } from "@/lib/utils"; type ProjectWalletControlsProps = { - walletAddress: string; - label: string; - project: Pick; - defaultChainId?: number; + walletAddress: string; + label: string; + project: Pick; + 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 client = useMemo(() => getClientThirdwebClient(), []); - const chain = useV5DashboardChain(selectedChainId); - - const engineCloudService = useMemo( - () => project.services?.find((service) => service.name === "engineCloud"), - [project.services], - ); - const isManagedVault = !!engineCloudService?.encryptedAdminKey; - - const balanceQuery = useWalletBalance({ - address: walletAddress, - chain, - client, - }); - - const balanceDisplay = balanceQuery.data - ? `${balanceQuery.data.displayValue} ${balanceQuery.data.symbol}` - : undefined; - - const handleCopyAddress = useCallback(async () => { - try { - await navigator.clipboard.writeText(walletAddress); - toast.success("Wallet address copied"); - } catch (error) { - console.error("Failed to copy wallet address", error); - toast.error("Unable to copy the address"); - } - }, [walletAddress]); - - return ( -
-
-
-
-
-

- Wallet address -

-

{walletAddress}

-
- - - - - - { - void handleCopyAddress(); - }} - > - - Copy address - - - setIsSendOpen(true)} - > - - Send funds - - setIsReceiveOpen(true)} - > - - Receive funds - - - -
- -
-
-

- Balance -

-
- - {balanceQuery.isLoading ? ( - - ) : balanceDisplay ? ( - - {balanceDisplay} - - ) : ( - N/A - )} - -
-
- -
-
-
- - setIsSendOpen(false)} - onSuccess={() => balanceQuery.refetch()} - open={isSendOpen} - projectId={project.id} - publishableKey={project.publishableKey} - teamId={project.teamId} - walletAddress={walletAddress} - /> - - -
- ); + const { walletAddress, label, project, defaultChainId } = props; + const [isSendOpen, setIsSendOpen] = useState(false); + const [isReceiveOpen, setIsReceiveOpen] = useState(false); + const [selectedChainId, setSelectedChainId] = useState(defaultChainId ?? 1); + + const client = useMemo(() => getClientThirdwebClient(), []); + const chain = useV5DashboardChain(selectedChainId); + + const engineCloudService = useMemo( + () => project.services?.find((service) => service.name === "engineCloud"), + [project.services], + ); + const isManagedVault = !!engineCloudService?.encryptedAdminKey; + + const balanceQuery = useWalletBalance({ + address: walletAddress, + chain, + client, + }); + + const balanceDisplay = balanceQuery.data + ? `${balanceQuery.data.displayValue} ${balanceQuery.data.symbol}` + : undefined; + + const handleCopyAddress = useCallback(async () => { + try { + await navigator.clipboard.writeText(walletAddress); + toast.success("Wallet address copied"); + } catch (error) { + console.error("Failed to copy wallet address", error); + toast.error("Unable to copy the address"); + } + }, [walletAddress]); + + return ( +
+
+
+
+
+

+ Wallet address +

+

{walletAddress}

+
+ + + + + + { + void handleCopyAddress(); + }} + > + + Copy address + + + setIsSendOpen(true)} + > + + Send funds + + setIsReceiveOpen(true)} + > + + Receive funds + + + +
+ +
+
+

+ Balance +

+
+ + {balanceQuery.isLoading ? ( + + ) : balanceDisplay ? ( + + {balanceDisplay} + + ) : ( + N/A + )} + +
+
+ +
+
+
+ + 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(), - }); + 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; - projectId: string; - chainId: number; - label: string; - client: ReturnType; - isManagedVault: boolean; + 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, - projectId, - } = 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 credentialStorageKey = useMemo(() => { - const baseKey = projectId ?? publishableKey; - return `thirdweb-dashboard:project-wallet-credentials:${baseKey}`; - }, [projectId, publishableKey]); - - 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]); - - // eslint-disable-next-line no-restricted-syntax -- restoring credentials from localStorage requires side effects - useEffect(() => { - if (!open || typeof window === "undefined") { - return; - } - - try { - const raw = window.localStorage.getItem(credentialStorageKey); - if (!raw) { - return; - } - - const cached = JSON.parse(raw) as { - secretKey?: string; - vaultAccessToken?: string; - } | null; - - if (cached?.secretKey) { - form.setValue("secretKey", cached.secretKey, { - shouldDirty: false, - shouldValidate: true, - }); - } - - if (!isManagedVault && cached?.vaultAccessToken) { - form.setValue("vaultAccessToken", cached.vaultAccessToken, { - shouldDirty: false, - shouldValidate: false, - }); - } - } catch (error) { - console.error("Failed to restore cached wallet credentials", error); - } - }, [open, credentialStorageKey, form, isManagedVault]); - - // eslint-disable-next-line no-restricted-syntax -- persist credential updates while modal is open - useEffect(() => { - if (typeof window === "undefined") { - return; - } - - const subscription = form.watch((values, { name }) => { - if (!name || (name !== "secretKey" && name !== "vaultAccessToken")) { - return; - } - - const secretKeyValue = values.secretKey?.trim() ?? ""; - const vaultTokenValue = values.vaultAccessToken?.trim() ?? ""; - - if (!secretKeyValue && !vaultTokenValue) { - window.localStorage.removeItem(credentialStorageKey); - return; - } - - try { - const payload: Record = {}; - if (secretKeyValue) { - payload.secretKey = secretKeyValue; - } - - if (!isManagedVault && vaultTokenValue) { - payload.vaultAccessToken = vaultTokenValue; - } - - window.localStorage.setItem( - credentialStorageKey, - JSON.stringify(payload), - ); - } catch (error) { - console.error("Failed to cache wallet credentials", error); - } - }); - - return () => subscription.unsubscribe(); - }, [form, credentialStorageKey, isManagedVault]); - - 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} - - - - )} - /> - )} -
- -
- - -
-
- -
-
- ); + 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} + + + + )} + /> + )} +
+ +
+ + +
+
+ +
+
+ ); } From b8b70c7f998e9c212e0c519cf87cddc08cacf80f Mon Sep 17 00:00:00 2001 From: 0xFirekeeper <0xFirekeeper@gmail.com> Date: Wed, 8 Oct 2025 04:00:42 +0700 Subject: [PATCH 10/13] fmt --- .../ProjectWalletControls.client.tsx | 938 +++++++++--------- 1 file changed, 469 insertions(+), 469 deletions(-) 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 index 6688c6d72dd..90d878f9957 100644 --- 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 @@ -3,11 +3,11 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation } from "@tanstack/react-query"; import { - CopyIcon, - EllipsisVerticalIcon, - RefreshCcwIcon, - SendIcon, - WalletIcon, + CopyIcon, + EllipsisVerticalIcon, + RefreshCcwIcon, + SendIcon, + WalletIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; @@ -22,27 +22,27 @@ import { FundWalletModal } from "@/components/blocks/fund-wallets-modal"; import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; import { Button } from "@/components/ui/button"; import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, } from "@/components/ui/dialog"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/Spinner"; @@ -51,464 +51,464 @@ import { useV5DashboardChain } from "@/hooks/chains/v5-adapter"; import { cn } from "@/lib/utils"; type ProjectWalletControlsProps = { - walletAddress: string; - label: string; - project: Pick; - defaultChainId?: number; + walletAddress: string; + label: string; + project: Pick; + 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 client = useMemo(() => getClientThirdwebClient(), []); - const chain = useV5DashboardChain(selectedChainId); - - const engineCloudService = useMemo( - () => project.services?.find((service) => service.name === "engineCloud"), - [project.services], - ); - const isManagedVault = !!engineCloudService?.encryptedAdminKey; - - const balanceQuery = useWalletBalance({ - address: walletAddress, - chain, - client, - }); - - const balanceDisplay = balanceQuery.data - ? `${balanceQuery.data.displayValue} ${balanceQuery.data.symbol}` - : undefined; - - const handleCopyAddress = useCallback(async () => { - try { - await navigator.clipboard.writeText(walletAddress); - toast.success("Wallet address copied"); - } catch (error) { - console.error("Failed to copy wallet address", error); - toast.error("Unable to copy the address"); - } - }, [walletAddress]); - - return ( -
-
-
-
-
-

- Wallet address -

-

{walletAddress}

-
- - - - - - { - void handleCopyAddress(); - }} - > - - Copy address - - - setIsSendOpen(true)} - > - - Send funds - - setIsReceiveOpen(true)} - > - - Receive funds - - - -
- -
-
-

- Balance -

-
- - {balanceQuery.isLoading ? ( - - ) : balanceDisplay ? ( - - {balanceDisplay} - - ) : ( - N/A - )} - -
-
- -
-
-
- - setIsSendOpen(false)} - onSuccess={() => balanceQuery.refetch()} - open={isSendOpen} - publishableKey={project.publishableKey} - teamId={project.teamId} - walletAddress={walletAddress} - /> - - -
- ); + const { walletAddress, label, project, defaultChainId } = props; + const [isSendOpen, setIsSendOpen] = useState(false); + const [isReceiveOpen, setIsReceiveOpen] = useState(false); + const [selectedChainId, setSelectedChainId] = useState(defaultChainId ?? 1); + + const client = useMemo(() => getClientThirdwebClient(), []); + const chain = useV5DashboardChain(selectedChainId); + + const engineCloudService = useMemo( + () => project.services?.find((service) => service.name === "engineCloud"), + [project.services], + ); + const isManagedVault = !!engineCloudService?.encryptedAdminKey; + + const balanceQuery = useWalletBalance({ + address: walletAddress, + chain, + client, + }); + + const balanceDisplay = balanceQuery.data + ? `${balanceQuery.data.displayValue} ${balanceQuery.data.symbol}` + : undefined; + + const handleCopyAddress = useCallback(async () => { + try { + await navigator.clipboard.writeText(walletAddress); + toast.success("Wallet address copied"); + } catch (error) { + console.error("Failed to copy wallet address", error); + toast.error("Unable to copy the address"); + } + }, [walletAddress]); + + return ( +
+
+
+
+
+

+ Wallet address +

+

{walletAddress}

+
+ + + + + + { + void handleCopyAddress(); + }} + > + + Copy address + + + setIsSendOpen(true)} + > + + Send funds + + setIsReceiveOpen(true)} + > + + Receive funds + + + +
+ +
+
+

+ Balance +

+
+ + {balanceQuery.isLoading ? ( + + ) : balanceDisplay ? ( + + {balanceDisplay} + + ) : ( + N/A + )} + +
+
+ +
+
+
+ + 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(), - }); + 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; + 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} - - - - )} - /> - )} -
- -
- - -
-
- -
-
- ); + 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} + + + + )} + /> + )} +
+ +
+ + +
+
+ +
+
+ ); } From 630f6cffbd691648166ec3824592b8ddc06c12a9 Mon Sep 17 00:00:00 2001 From: Joaquim Verges Date: Wed, 8 Oct 2025 13:33:32 +1300 Subject: [PATCH 11/13] save address in project services, set as default from server wallets table --- .../project/create-project-modal/index.tsx | 15 +-- .../src/@/lib/server/project-wallet.ts | 17 ++- .../_components/create-project-form.tsx | 14 +-- .../components/ProjectFTUX/ProjectFTUX.tsx | 74 ++++++----- .../ProjectWalletControls.client.tsx | 116 ++++++++---------- .../[project_slug]/(sidebar)/page.tsx | 5 + .../transactions/lib/vault.client.ts | 43 +++++++ .../create-server-wallet.client.tsx | 2 + .../wallet-table/wallet-table-ui.client.tsx | 64 ++++++++-- packages/service-utils/src/core/api.ts | 1 + 10 files changed, 215 insertions(+), 136 deletions(-) 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 b0f899904c5..1dec37b0d5d 100644 --- a/apps/dashboard/src/@/components/project/create-project-modal/index.tsx +++ b/apps/dashboard/src/@/components/project/create-project-modal/index.tsx @@ -36,13 +36,9 @@ import { Spinner } from "@/components/ui/Spinner"; import { Textarea } from "@/components/ui/textarea"; import { createProjectClient } from "@/hooks/useApi"; import { useDashboardRouter } from "@/lib/DashboardRouter"; -import { getProjectWalletLabel } from "@/lib/project-wallet"; import { projectDomainsSchema, projectNameSchema } from "@/schema/validations"; import { toArrFromList } from "@/utils/string"; -import { - createProjectServerWallet, - createVaultAccountAndAccessToken, -} from "../../../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client"; +import { createVaultAccountAndAccessToken } from "../../../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client"; const ALL_PROJECT_SERVICES = SERVICES.filter( (srv) => srv.name !== "relayer" && srv.name !== "chainsaw", @@ -85,15 +81,6 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => { throw new Error("Missing management access token for project wallet"); } - await createProjectServerWallet({ - label: getProjectWalletLabel(res.project.name), - managementAccessToken, - project: res.project, - }).catch((error) => { - console.error("Failed to create default project wallet", error); - throw error; - }); - return { project: res.project, secret: res.secret, diff --git a/apps/dashboard/src/@/lib/server/project-wallet.ts b/apps/dashboard/src/@/lib/server/project-wallet.ts index b88b93f12e3..049a7fa6d90 100644 --- a/apps/dashboard/src/@/lib/server/project-wallet.ts +++ b/apps/dashboard/src/@/lib/server/project-wallet.ts @@ -31,8 +31,13 @@ export async function getProjectWallet( const managementAccessToken = engineCloudService?.managementAccessToken || undefined; + const projectWalletAddress = engineCloudService?.projectWalletAddress; - if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) { + if ( + !managementAccessToken || + !NEXT_PUBLIC_THIRDWEB_VAULT_URL || + !projectWalletAddress + ) { return undefined; } @@ -50,7 +55,7 @@ export async function getProjectWallet( options: { page: 0, // @ts-expect-error - SDK expects snake_case for pagination arguments - page_size: 25, + page_size: 100, }, }, }); @@ -71,10 +76,10 @@ export async function getProjectWallet( (item) => item.metadata?.projectId === project.id, ); - const defaultWallet = - serverWallets.find((item) => item.metadata?.label === expectedLabel) ?? - serverWallets.find((item) => item.metadata?.type === "server-wallet") ?? - serverWallets[0]; + const defaultWallet = serverWallets.find( + (item) => + item.address.toLowerCase() === projectWalletAddress.toLowerCase(), + ); if (!defaultWallet) { 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 129a93c098a..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 @@ -31,13 +31,9 @@ import { Spinner } from "@/components/ui/Spinner"; import { Textarea } from "@/components/ui/textarea"; import { createProjectClient } from "@/hooks/useApi"; import { useDashboardRouter } from "@/lib/DashboardRouter"; -import { getProjectWalletLabel } from "@/lib/project-wallet"; import { projectDomainsSchema, projectNameSchema } from "@/schema/validations"; import { toArrFromList } from "@/utils/string"; -import { - createProjectServerWallet, - createVaultAccountAndAccessToken, -} from "../../../../../team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client"; +import { createVaultAccountAndAccessToken } from "../../../../../team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client"; const ALL_PROJECT_SERVICES = SERVICES.filter( (srv) => srv.name !== "relayer" && srv.name !== "chainsaw", @@ -79,14 +75,6 @@ export function CreateProjectFormOnboarding(props: { ); } - await createProjectServerWallet({ - label: getProjectWalletLabel(res.project.name), - managementAccessToken, - project: res.project, - }).catch((error) => { - console.error("Failed to create default project wallet", error); - throw error; - }); return { project: res.project, secret: res.secret, 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 658a8d52a65..309ed09273b 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"; @@ -24,6 +25,7 @@ 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"; @@ -33,6 +35,7 @@ export async function ProjectFTUX(props: { project: Project; teamSlug: string; wallet?: ProjectWalletSummary | undefined; + managementAccessToken: string | undefined; }) { const projectWallet = props.wallet ?? (await getProjectWallet(props.project)); @@ -42,6 +45,7 @@ export async function ProjectFTUX(props: { project={props.project} teamSlug={props.teamSlug} wallet={projectWallet} + managementAccessToken={props.managementAccessToken} /> -

- Project Wallet -

-
@@ -79,49 +80,56 @@ export function ProjectWalletSection(props: {
-

{label}

+

+ Project Wallet +

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

{walletAddress ? ( - + <> + +
+ + View Transactions + + +
+ ) : ( - Project wallet unavailable + No default project wallet set - We could not load the default wallet for this project. Visit the - Transactions page to create or refresh your server wallets. + Set a default project wallet to use for dashboard and API + integrations. + + + )} - -
-

- Manage balances, gas sponsorship, and smart account settings in - Transactions. -

- - Open Transactions - - -
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 index 90d878f9957..d7a1f1d898a 100644 --- 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 @@ -3,7 +3,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation } from "@tanstack/react-query"; import { - CopyIcon, EllipsisVerticalIcon, RefreshCcwIcon, SendIcon, @@ -20,6 +19,7 @@ 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 { Button } from "@/components/ui/button"; import { Dialog, @@ -32,7 +32,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { @@ -94,66 +93,28 @@ export function ProjectWalletControls(props: ProjectWalletControlsProps) { return (
-
+
-
-
-

- Wallet address -

-

{walletAddress}

+
+
+

Address

+ +
+
+

Label

+

{label}

- - - - - - { - void handleCopyAddress(); - }} - > - - Copy address - - - setIsSendOpen(true)} - > - - Send funds - - setIsReceiveOpen(true)} - > - - Receive funds - - -
-
-

- Balance -

-
+
+

Balance

+
{balanceQuery.isLoading ? ( ) : balanceDisplay ? ( - + {balanceDisplay} ) : ( @@ -174,17 +135,48 @@ export function ProjectWalletControls(props: ProjectWalletControlsProps) { )} /> + + + + + + setIsSendOpen(true)} + > + + Send funds + + setIsReceiveOpen(true)} + > + + Receive funds + + +
- +
+

Network

+ +
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 72c3a2a05d0..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 @@ -109,6 +109,9 @@ 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,6 +138,7 @@ export default async function ProjectOverviewPage(props: PageProps) { project={project} teamSlug={params.team_slug} wallet={projectWallet} + managementAccessToken={managementAccessToken} /> )} 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 cc1b3bdc52d..8a4cd4d7ca6 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 @@ -133,6 +133,7 @@ export async function createProjectServerWallet(props: { project: Project; managementAccessToken: string; label?: string; + setAsProjectWallet?: boolean; }) { const vaultClient = await initVaultClient(); @@ -176,9 +177,42 @@ export async function createProjectServerWallet(props: { 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; + const engineCloudService = 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; @@ -210,6 +244,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); @@ -244,6 +285,7 @@ async function createAndEncryptVaultAccessTokens(props: { encryptedAdminKey, encryptedWalletAccessToken, rotationCode: rotationCode, + projectWalletAddress: defaultProjectServerWallet.address, }, ], }, @@ -266,6 +308,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 6791ff355b2..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 @@ -21,6 +21,7 @@ export default function CreateServerWallet(props: { project: Project; teamSlug: string; managementAccessToken: string | undefined; + setAsProjectWallet?: boolean; }) { const router = useDashboardRouter(); const [label, setLabel] = useState(""); @@ -38,6 +39,7 @@ export default function CreateServerWallet(props: { label, managementAccessToken, project: props.project, + setAsProjectWallet: props.setAsProjectWallet, }); router.refresh(); 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; From 5595cd7a1719aedb09c74d709f224bce3f1cf8fd Mon Sep 17 00:00:00 2001 From: Joaquim Verges Date: Wed, 8 Oct 2025 13:42:16 +1300 Subject: [PATCH 12/13] CR --- .../(sidebar)/transactions/lib/vault.client.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) 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 8a4cd4d7ca6..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 @@ -191,8 +191,10 @@ export async function updateDefaultProjectWallet(props: { project: Project; projectWalletAddress: string; }) { - const services = props.project.services; - const engineCloudService = services.find( + const services = props.project.services.filter( + (service) => service.name !== "engineCloud", + ); + const engineCloudService = props.project.services.find( (service) => service.name === "engineCloud", ); if (engineCloudService) { @@ -276,7 +278,9 @@ async function createAndEncryptVaultAccessTokens(props: { }, { services: [ - ...props.project.services, + ...props.project.services.filter( + (service) => service.name !== "engineCloud", + ), { name: "engineCloud", actions: [], @@ -299,7 +303,9 @@ async function createAndEncryptVaultAccessTokens(props: { }, { services: [ - ...props.project.services, + ...props.project.services.filter( + (service) => service.name !== "engineCloud", + ), { name: "engineCloud", actions: [], From 5d856fd7d2e1fe68182c7c6876c40fa4a9d9878b Mon Sep 17 00:00:00 2001 From: 0xFirekeeper <0xFirekeeper@gmail.com> Date: Wed, 8 Oct 2025 20:36:33 +0700 Subject: [PATCH 13/13] Add ability to change default project server wallet Introduces a new action to list project server wallets and updates ProjectWalletControls to allow users to select and change the default server wallet for a project. Updates related types and props, and improves label handling in getProjectWallet. Also updates the ProjectFTUX story to include managementAccessToken. --- .../project-wallet/list-server-wallets.ts | 70 +++++ .../src/@/lib/server/project-wallet.ts | 8 +- .../ProjectFTUX/ProjectFTUX.stories.tsx | 1 + .../components/ProjectFTUX/ProjectFTUX.tsx | 7 +- .../ProjectWalletControls.client.tsx | 247 +++++++++++++++++- 5 files changed, 310 insertions(+), 23 deletions(-) create mode 100644 apps/dashboard/src/@/actions/project-wallet/list-server-wallets.ts 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/@/lib/server/project-wallet.ts b/apps/dashboard/src/@/lib/server/project-wallet.ts index 049a7fa6d90..4597c079add 100644 --- a/apps/dashboard/src/@/lib/server/project-wallet.ts +++ b/apps/dashboard/src/@/lib/server/project-wallet.ts @@ -31,7 +31,9 @@ export async function getProjectWallet( const managementAccessToken = engineCloudService?.managementAccessToken || undefined; - const projectWalletAddress = engineCloudService?.projectWalletAddress; + const projectWalletAddress = ( + engineCloudService as { projectWalletAddress?: string } | undefined + )?.projectWalletAddress; if ( !managementAccessToken || @@ -70,7 +72,7 @@ export async function getProjectWallet( return undefined; } - const expectedLabel = getProjectWalletLabel(project.name); + const defaultLabel = getProjectWalletLabel(project.name); const serverWallets = items.filter( (item) => item.metadata?.projectId === project.id, @@ -88,7 +90,7 @@ export async function getProjectWallet( return { id: defaultWallet.id, address: defaultWallet.address, - label: defaultWallet.metadata?.label, + label: defaultWallet.metadata?.label ?? defaultLabel, }; } catch (error) { console.error("Failed to load project wallet", error); 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 309ed09273b..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 @@ -94,12 +94,7 @@ export function ProjectWalletSection(props: { <>
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 index d7a1f1d898a..97fb9d328f2 100644 --- 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 @@ -1,30 +1,34 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { EllipsisVerticalIcon, RefreshCcwIcon, SendIcon, + ShuffleIcon, WalletIcon, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "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"; @@ -44,15 +48,23 @@ import { 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: Pick< + Project, + "id" | "publishableKey" | "teamId" | "services" | "name" + >; defaultChainId?: number; }; @@ -61,16 +73,112 @@ export function ProjectWalletControls(props: ProjectWalletControlsProps) { 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, @@ -81,16 +189,6 @@ export function ProjectWalletControls(props: ProjectWalletControlsProps) { ? `${balanceQuery.data.displayValue} ${balanceQuery.data.symbol}` : undefined; - const handleCopyAddress = useCallback(async () => { - try { - await navigator.clipboard.writeText(walletAddress); - toast.success("Wallet address copied"); - } catch (error) { - console.error("Failed to copy wallet address", error); - toast.error("Unable to copy the address"); - } - }, [walletAddress]); - return (
@@ -102,7 +200,23 @@ export function ProjectWalletControls(props: ProjectWalletControlsProps) {

Label

-

{label}

+
+

+ {label} +

+ {canChangeWallet && ( + + )} +
@@ -204,6 +318,111 @@ export function ProjectWalletControls(props: ProjectWalletControlsProps) { recipientAddress={walletAddress} title="Fund project wallet" /> + + { + 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 ( + + ); + })} + + )} +
+ + + + + +
+
); }