Skip to content

Commit b48949c

Browse files
committed
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
1 parent f9283f3 commit b48949c

File tree

6 files changed

+308
-47
lines changed

6 files changed

+308
-47
lines changed

apps/dashboard/src/@/components/project/create-project-modal/index.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,13 @@ import { Spinner } from "@/components/ui/Spinner";
3636
import { Textarea } from "@/components/ui/textarea";
3737
import { createProjectClient } from "@/hooks/useApi";
3838
import { useDashboardRouter } from "@/lib/DashboardRouter";
39+
import { getProjectWalletLabel } from "@/lib/project-wallet";
3940
import { projectDomainsSchema, projectNameSchema } from "@/schema/validations";
4041
import { toArrFromList } from "@/utils/string";
41-
import { createVaultAccountAndAccessToken } from "../../../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
42+
import {
43+
createProjectServerWallet,
44+
createVaultAccountAndAccessToken,
45+
} from "../../../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
4246

4347
const ALL_PROJECT_SERVICES = SERVICES.filter(
4448
(srv) => srv.name !== "relayer" && srv.name !== "chainsaw",
@@ -64,7 +68,7 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => {
6468
<CreateProjectDialogUI
6569
createProject={async (params) => {
6670
const res = await createProjectClient(props.teamId, params);
67-
await createVaultAccountAndAccessToken({
71+
const vaultTokens = await createVaultAccountAndAccessToken({
6872
project: res.project,
6973
projectSecretKey: res.secret,
7074
}).catch((error) => {
@@ -74,6 +78,22 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => {
7478
);
7579
throw error;
7680
});
81+
82+
const managementAccessToken = vaultTokens.managementToken?.accessToken;
83+
84+
if (!managementAccessToken) {
85+
throw new Error("Missing management access token for project wallet");
86+
}
87+
88+
await createProjectServerWallet({
89+
label: getProjectWalletLabel(res.project.name),
90+
managementAccessToken,
91+
project: res.project,
92+
}).catch((error) => {
93+
console.error("Failed to create default project wallet", error);
94+
throw error;
95+
});
96+
7797
return {
7898
project: res.project,
7999
secret: res.secret,
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
const PROJECT_WALLET_LABEL_SUFFIX = " Wallet";
2+
const PROJECT_WALLET_LABEL_MAX_LENGTH = 32;
3+
4+
/**
5+
* Builds the default label for a project's primary server wallet.
6+
* Ensures a stable naming convention so the wallet can be identified across flows.
7+
*/
8+
export function getProjectWalletLabel(projectName: string | undefined) {
9+
const baseName = projectName?.trim() || "Project";
10+
const maxBaseLength = Math.max(
11+
1,
12+
PROJECT_WALLET_LABEL_MAX_LENGTH - PROJECT_WALLET_LABEL_SUFFIX.length,
13+
);
14+
15+
const normalizedBase =
16+
baseName.length > maxBaseLength
17+
? baseName.slice(0, maxBaseLength).trimEnd()
18+
: baseName;
19+
20+
return `${normalizedBase}${PROJECT_WALLET_LABEL_SUFFIX}`;
21+
}

apps/dashboard/src/app/(app)/get-started/team/[team_slug]/create-project/_components/create-project-form.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,13 @@ import { Spinner } from "@/components/ui/Spinner";
3131
import { Textarea } from "@/components/ui/textarea";
3232
import { createProjectClient } from "@/hooks/useApi";
3333
import { useDashboardRouter } from "@/lib/DashboardRouter";
34+
import { getProjectWalletLabel } from "@/lib/project-wallet";
3435
import { projectDomainsSchema, projectNameSchema } from "@/schema/validations";
3536
import { toArrFromList } from "@/utils/string";
36-
import { createVaultAccountAndAccessToken } from "../../../../../team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
37+
import {
38+
createProjectServerWallet,
39+
createVaultAccountAndAccessToken,
40+
} from "../../../../../team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
3741

3842
const ALL_PROJECT_SERVICES = SERVICES.filter(
3943
(srv) => srv.name !== "relayer" && srv.name !== "chainsaw",
@@ -55,7 +59,7 @@ export function CreateProjectFormOnboarding(props: {
5559
<CreateProjectForm
5660
createProject={async (params) => {
5761
const res = await createProjectClient(props.teamId, params);
58-
await createVaultAccountAndAccessToken({
62+
const vaultTokens = await createVaultAccountAndAccessToken({
5963
project: res.project,
6064
projectSecretKey: res.secret,
6165
}).catch((error) => {
@@ -65,6 +69,24 @@ export function CreateProjectFormOnboarding(props: {
6569
);
6670
throw error;
6771
});
72+
73+
const managementAccessToken =
74+
vaultTokens.managementToken?.accessToken;
75+
76+
if (!managementAccessToken) {
77+
throw new Error(
78+
"Missing management access token for project wallet",
79+
);
80+
}
81+
82+
await createProjectServerWallet({
83+
label: getProjectWalletLabel(res.project.name),
84+
managementAccessToken,
85+
project: res.project,
86+
}).catch((error) => {
87+
console.error("Failed to create default project wallet", error);
88+
throw error;
89+
});
6890
return {
6991
project: res.project,
7092
secret: res.secret,

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/ProjectFTUX.tsx

Lines changed: 181 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
12
import {
23
ArrowLeftRightIcon,
34
ChevronRightIcon,
@@ -7,8 +8,10 @@ import {
78
import Link from "next/link";
89
import type { Project } from "@/api/project/projects";
910
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
11+
import { CopyTextButton } from "@/components/ui/CopyTextButton";
1012
import { CodeServer } from "@/components/ui/code/code.server";
1113
import { UnderlineLink } from "@/components/ui/UnderlineLink";
14+
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
1215
import { DotNetIcon } from "@/icons/brand-icons/DotNetIcon";
1316
import { GithubIcon } from "@/icons/brand-icons/GithubIcon";
1417
import { ReactIcon } from "@/icons/brand-icons/ReactIcon";
@@ -18,13 +21,31 @@ import { UnrealIcon } from "@/icons/brand-icons/UnrealIcon";
1821
import { ContractIcon } from "@/icons/ContractIcon";
1922
import { InsightIcon } from "@/icons/InsightIcon";
2023
import { PayIcon } from "@/icons/PayIcon";
24+
import { WalletProductIcon } from "@/icons/WalletProductIcon";
25+
import { getProjectWalletLabel } from "@/lib/project-wallet";
2126
import { ClientIDSection } from "./ClientIDSection";
2227
import { IntegrateAPIKeyCodeTabs } from "./IntegrateAPIKeyCodeTabs";
2328
import { SecretKeySection } from "./SecretKeySection";
2429

25-
export function ProjectFTUX(props: { project: Project; teamSlug: string }) {
30+
type ProjectWalletSummary = {
31+
id: string;
32+
address: string;
33+
label?: string;
34+
};
35+
36+
export async function ProjectFTUX(props: {
37+
project: Project;
38+
teamSlug: string;
39+
}) {
40+
const projectWallet = await fetchProjectWallet(props.project);
41+
2642
return (
2743
<div className="flex flex-col gap-10">
44+
<ProjectWalletSection
45+
project={props.project}
46+
teamSlug={props.teamSlug}
47+
wallet={projectWallet}
48+
/>
2849
<IntegrateAPIKeySection
2950
project={props.project}
3051
teamSlug={props.teamSlug}
@@ -39,6 +60,165 @@ export function ProjectFTUX(props: { project: Project; teamSlug: string }) {
3960
);
4061
}
4162

63+
function ProjectWalletSection(props: {
64+
project: Project;
65+
teamSlug: string;
66+
wallet: ProjectWalletSummary | undefined;
67+
}) {
68+
const defaultLabel = getProjectWalletLabel(props.project.name);
69+
const walletAddress = props.wallet?.address;
70+
const label = props.wallet?.label ?? defaultLabel;
71+
const shortenedAddress = walletAddress
72+
? `${walletAddress.slice(0, 6)}...${walletAddress.slice(-4)}`
73+
: undefined;
74+
75+
return (
76+
<section>
77+
<h2 className="mb-3 font-semibold text-xl tracking-tight">
78+
Project Wallet
79+
</h2>
80+
81+
<div className="rounded-lg border border-border bg-card p-4">
82+
<div className="flex flex-col gap-4">
83+
<div className="flex items-center gap-3">
84+
<div className="rounded-full border border-border bg-background p-2">
85+
<WalletProductIcon className="size-5 text-muted-foreground" />
86+
</div>
87+
<div>
88+
<p className="font-semibold text-lg tracking-tight">{label}</p>
89+
<p className="text-muted-foreground text-sm">
90+
Default managed server wallet for this project. Use it for
91+
deployments, payments, and other automated flows.
92+
</p>
93+
</div>
94+
</div>
95+
96+
{walletAddress ? (
97+
<div className="flex flex-col gap-3 rounded-lg border border-dashed border-border/60 bg-background p-3 md:flex-row md:items-center md:justify-between">
98+
<div>
99+
<p className="text-muted-foreground text-xs uppercase">
100+
Wallet address
101+
</p>
102+
<p className="font-mono text-sm break-all">{walletAddress}</p>
103+
</div>
104+
<CopyTextButton
105+
className="w-full md:w-auto"
106+
copyIconPosition="right"
107+
textToCopy={walletAddress}
108+
textToShow={shortenedAddress ?? walletAddress}
109+
tooltip="Copy wallet address"
110+
/>
111+
</div>
112+
) : (
113+
<Alert variant="info">
114+
<CircleAlertIcon className="size-5" />
115+
<AlertTitle>Project wallet unavailable</AlertTitle>
116+
<AlertDescription>
117+
We could not load the default wallet for this project. Visit the
118+
Transactions page to create or refresh your server wallets.
119+
</AlertDescription>
120+
</Alert>
121+
)}
122+
123+
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
124+
<p className="text-muted-foreground text-sm">
125+
Manage balances, gas sponsorship, and smart account settings in
126+
Transactions.
127+
</p>
128+
<Link
129+
className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline"
130+
href={`/team/${props.teamSlug}/${props.project.slug}/transactions`}
131+
>
132+
Open Transactions
133+
<ChevronRightIcon className="size-4" />
134+
</Link>
135+
</div>
136+
</div>
137+
</div>
138+
</section>
139+
);
140+
}
141+
142+
type VaultWalletListItem = {
143+
id: string;
144+
address: string;
145+
metadata?: {
146+
label?: string;
147+
projectId?: string;
148+
teamId?: string;
149+
type?: string;
150+
};
151+
};
152+
153+
async function fetchProjectWallet(
154+
project: Project,
155+
): Promise<ProjectWalletSummary | undefined> {
156+
const engineCloudService = project.services.find(
157+
(service) => service.name === "engineCloud",
158+
);
159+
160+
const managementAccessToken =
161+
engineCloudService?.managementAccessToken || undefined;
162+
163+
if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) {
164+
return undefined;
165+
}
166+
167+
try {
168+
const vaultClient = await createVaultClient({
169+
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
170+
});
171+
172+
const response = await listEoas({
173+
client: vaultClient,
174+
request: {
175+
auth: {
176+
accessToken: managementAccessToken,
177+
},
178+
options: {
179+
page: 0,
180+
// @ts-expect-error - SDK expects snake_case for pagination arguments
181+
page_size: 25,
182+
},
183+
},
184+
});
185+
186+
if (!response.success || !response.data) {
187+
return undefined;
188+
}
189+
190+
const items = response.data.items as VaultWalletListItem[] | undefined;
191+
192+
if (!items?.length) {
193+
return undefined;
194+
}
195+
196+
const expectedLabel = getProjectWalletLabel(project.name);
197+
198+
const serverWallets = items.filter(
199+
(item) => item.metadata?.projectId === project.id,
200+
);
201+
202+
const defaultWallet =
203+
serverWallets.find((item) => item.metadata?.label === expectedLabel) ||
204+
serverWallets.find((item) => item.metadata?.type === "server-wallet") ||
205+
serverWallets[0];
206+
207+
if (!defaultWallet) {
208+
return undefined;
209+
}
210+
211+
return {
212+
id: defaultWallet.id,
213+
address: defaultWallet.address,
214+
label: defaultWallet.metadata?.label,
215+
};
216+
} catch (error) {
217+
console.error("Failed to load project wallet", error);
218+
return undefined;
219+
}
220+
}
221+
42222
// Integrate API key section ------------------------------------------------------------
43223

44224
function IntegrateAPIKeySection({

0 commit comments

Comments
 (0)