Skip to content

Commit 163ab8a

Browse files
Project Wallet (Default Server Wallet) (#8212)
Co-authored-by: Joaquim Verges <[email protected]>
1 parent 62cfbb7 commit 163ab8a

File tree

21 files changed

+1398
-70
lines changed

21 files changed

+1398
-70
lines changed
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"use server";
2+
3+
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
4+
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
5+
import type { ProjectWalletSummary } from "@/lib/server/project-wallet";
6+
7+
interface VaultWalletListItem {
8+
id: string;
9+
address: string;
10+
metadata?: {
11+
label?: string;
12+
projectId?: string;
13+
teamId?: string;
14+
type?: string;
15+
} | null;
16+
}
17+
18+
export async function listProjectServerWallets(params: {
19+
managementAccessToken: string;
20+
projectId: string;
21+
pageSize?: number;
22+
}): Promise<ProjectWalletSummary[]> {
23+
const { managementAccessToken, projectId, pageSize = 100 } = params;
24+
25+
if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) {
26+
return [];
27+
}
28+
29+
try {
30+
const vaultClient = await createVaultClient({
31+
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
32+
});
33+
34+
const response = await listEoas({
35+
client: vaultClient,
36+
request: {
37+
auth: {
38+
accessToken: managementAccessToken,
39+
},
40+
options: {
41+
page: 0,
42+
// @ts-expect-error - Vault SDK expects snake_case pagination fields
43+
page_size: pageSize,
44+
},
45+
},
46+
});
47+
48+
if (!response.success || !response.data?.items) {
49+
return [];
50+
}
51+
52+
const items = response.data.items as VaultWalletListItem[];
53+
54+
return items
55+
.filter((item) => {
56+
return (
57+
item.metadata?.projectId === projectId &&
58+
(!item.metadata?.type || item.metadata.type === "server-wallet")
59+
);
60+
})
61+
.map<ProjectWalletSummary>((item) => ({
62+
id: item.id,
63+
address: item.address,
64+
label: item.metadata?.label ?? undefined,
65+
}));
66+
} catch (error) {
67+
console.error("Failed to list project server wallets", error);
68+
return [];
69+
}
70+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"use server";
2+
3+
import { configure, sendTokens } from "@thirdweb-dev/api";
4+
import { THIRDWEB_API_HOST } from "@/constants/urls";
5+
6+
configure({
7+
override: {
8+
baseUrl: THIRDWEB_API_HOST,
9+
},
10+
});
11+
12+
export async function sendProjectWalletTokens(options: {
13+
walletAddress: string;
14+
recipientAddress: string;
15+
chainId: number;
16+
quantityWei: string;
17+
publishableKey: string;
18+
teamId: string;
19+
tokenAddress?: string;
20+
secretKey: string;
21+
vaultAccessToken?: string;
22+
}) {
23+
const {
24+
walletAddress,
25+
recipientAddress,
26+
chainId,
27+
quantityWei,
28+
publishableKey,
29+
teamId,
30+
tokenAddress,
31+
secretKey,
32+
vaultAccessToken,
33+
} = options;
34+
35+
if (!secretKey) {
36+
return {
37+
error: "A project secret key is required to send funds.",
38+
ok: false,
39+
} as const;
40+
}
41+
42+
const response = await sendTokens({
43+
body: {
44+
chainId,
45+
from: walletAddress,
46+
recipients: [
47+
{
48+
address: recipientAddress,
49+
quantity: quantityWei,
50+
},
51+
],
52+
...(tokenAddress ? { tokenAddress } : {}),
53+
},
54+
headers: {
55+
"Content-Type": "application/json",
56+
"x-client-id": publishableKey,
57+
"x-secret-key": secretKey,
58+
"x-team-id": teamId,
59+
...(vaultAccessToken ? { "x-vault-access-token": vaultAccessToken } : {}),
60+
},
61+
});
62+
63+
if (response.error || !response.data) {
64+
return {
65+
error: response.error || "Failed to submit transfer request.",
66+
ok: false,
67+
} as const;
68+
}
69+
70+
return {
71+
ok: true,
72+
transactionIds: response.data.result?.transactionIds ?? [],
73+
} as const;
74+
}

apps/dashboard/src/@/components/misc/AnnouncementBanner.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Link from "next/link";
55
import { Button } from "@/components/ui/button";
66
import { useLocalStorage } from "@/hooks/useLocalStorage";
77

8+
// biome-ignore lint/correctness/noUnusedVariables: banner is toggled on-demand via API content changes
89
function AnnouncementBannerUI(props: {
910
href: string;
1011
label: string;

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => {
6464
<CreateProjectDialogUI
6565
createProject={async (params) => {
6666
const res = await createProjectClient(props.teamId, params);
67-
await createVaultAccountAndAccessToken({
67+
const vaultTokens = await createVaultAccountAndAccessToken({
6868
project: res.project,
6969
projectSecretKey: res.secret,
7070
}).catch((error) => {
@@ -74,6 +74,13 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => {
7474
);
7575
throw error;
7676
});
77+
78+
const managementAccessToken = vaultTokens.managementToken?.accessToken;
79+
80+
if (!managementAccessToken) {
81+
throw new Error("Missing management access token for project wallet");
82+
}
83+
7784
return {
7885
project: res.project,
7986
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+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import "server-only";
2+
3+
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
4+
import type { Project } from "@/api/project/projects";
5+
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
6+
import { getProjectWalletLabel } from "@/lib/project-wallet";
7+
8+
export type ProjectWalletSummary = {
9+
id: string;
10+
address: string;
11+
label?: string;
12+
};
13+
14+
type VaultWalletListItem = {
15+
id: string;
16+
address: string;
17+
metadata?: {
18+
label?: string;
19+
projectId?: string;
20+
teamId?: string;
21+
type?: string;
22+
};
23+
};
24+
25+
export async function getProjectWallet(
26+
project: Project,
27+
): Promise<ProjectWalletSummary | undefined> {
28+
const engineCloudService = project.services.find(
29+
(service) => service.name === "engineCloud",
30+
);
31+
32+
const managementAccessToken =
33+
engineCloudService?.managementAccessToken || undefined;
34+
const projectWalletAddress = (
35+
engineCloudService as { projectWalletAddress?: string } | undefined
36+
)?.projectWalletAddress;
37+
38+
if (
39+
!managementAccessToken ||
40+
!NEXT_PUBLIC_THIRDWEB_VAULT_URL ||
41+
!projectWalletAddress
42+
) {
43+
return undefined;
44+
}
45+
46+
try {
47+
const vaultClient = await createVaultClient({
48+
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
49+
});
50+
51+
const response = await listEoas({
52+
client: vaultClient,
53+
request: {
54+
auth: {
55+
accessToken: managementAccessToken,
56+
},
57+
options: {
58+
page: 0,
59+
// @ts-expect-error - SDK expects snake_case for pagination arguments
60+
page_size: 100,
61+
},
62+
},
63+
});
64+
65+
if (!response.success || !response.data) {
66+
return undefined;
67+
}
68+
69+
const items = response.data.items as VaultWalletListItem[] | undefined;
70+
71+
if (!items?.length) {
72+
return undefined;
73+
}
74+
75+
const defaultLabel = getProjectWalletLabel(project.name);
76+
77+
const serverWallets = items.filter(
78+
(item) => item.metadata?.projectId === project.id,
79+
);
80+
81+
const defaultWallet = serverWallets.find(
82+
(item) =>
83+
item.address.toLowerCase() === projectWalletAddress.toLowerCase(),
84+
);
85+
86+
if (!defaultWallet) {
87+
return undefined;
88+
}
89+
90+
return {
91+
id: defaultWallet.id,
92+
address: defaultWallet.address,
93+
label: defaultWallet.metadata?.label ?? defaultLabel,
94+
};
95+
} catch (error) {
96+
console.error("Failed to load project wallet", error);
97+
return undefined;
98+
}
99+
}

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export function CreateProjectFormOnboarding(props: {
5555
<CreateProjectForm
5656
createProject={async (params) => {
5757
const res = await createProjectClient(props.teamId, params);
58-
await createVaultAccountAndAccessToken({
58+
const vaultTokens = await createVaultAccountAndAccessToken({
5959
project: res.project,
6060
projectSecretKey: res.secret,
6161
}).catch((error) => {
@@ -65,6 +65,16 @@ export function CreateProjectFormOnboarding(props: {
6565
);
6666
throw error;
6767
});
68+
69+
const managementAccessToken =
70+
vaultTokens.managementToken?.accessToken;
71+
72+
if (!managementAccessToken) {
73+
throw new Error(
74+
"Missing management access token for project wallet",
75+
);
76+
}
77+
6878
return {
6979
project: res.project,
7080
secret: res.secret,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,6 @@ export const Default: Story = {
3131
],
3232
},
3333
teamSlug: "bar",
34+
managementAccessToken: undefined,
3435
},
3536
};

0 commit comments

Comments
 (0)