diff --git a/apps/dashboard/src/@/actions/vault.ts b/apps/dashboard/src/@/actions/vault.ts index aa2bbe84c33..76421f45c9b 100644 --- a/apps/dashboard/src/@/actions/vault.ts +++ b/apps/dashboard/src/@/actions/vault.ts @@ -45,6 +45,11 @@ type RotateVaultServiceAccountResult = { walletAccessToken?: string; }; +type CreateVaultServiceAccountResult = RotateVaultServiceAccountResult & { + /** The project's default server wallet, null when none was created. */ + projectWalletAddress: string | null; +}; + /** * Credentials the caller supplies to unlock a vault. A managed vault takes the * project secret key, an ejected one takes the vault admin key. @@ -153,6 +158,38 @@ export async function setVaultProjectWallet(params: { return res.data.data; } +/** + * Creates the project's vault service account and its access tokens. + * + * A managed vault seals its admin key and wallet token with the project secret + * key and returns neither. An ejected vault returns both, once. + */ +export async function createVaultServiceAccount(params: { + project: ProjectRef; + mode: "managed" | "ejected"; + projectSecretKey?: string; + skipWalletCreation?: boolean; +}): Promise { + const res = await apiServerProxy<{ + data: CreateVaultServiceAccountResult; + }>({ + body: JSON.stringify({ + mode: params.mode, + projectSecretKey: params.projectSecretKey, + skipWalletCreation: params.skipWalletCreation, + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + pathname: vaultPath(params.project, "/service-account"), + }); + + if (!res.ok) { + throw new Error(errorMessage(res.error, "Failed to create vault")); + } + + return res.data.data; +} + export async function rotateVaultServiceAccount(params: { project: ProjectRef; projectSecretKey?: string; diff --git a/apps/dashboard/src/@/components/project/create-project-modal/index.tsx b/apps/dashboard/src/@/components/project/create-project-modal/index.tsx index 1dec37b0d5d..6fc0b594e97 100644 --- a/apps/dashboard/src/@/components/project/create-project-modal/index.tsx +++ b/apps/dashboard/src/@/components/project/create-project-modal/index.tsx @@ -7,6 +7,7 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; +import { createVaultServiceAccount } from "@/actions/vault"; import type { Project } from "@/api/project/projects"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; @@ -38,7 +39,6 @@ import { createProjectClient } from "@/hooks/useApi"; import { useDashboardRouter } from "@/lib/DashboardRouter"; 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"; const ALL_PROJECT_SERVICES = SERVICES.filter( (srv) => srv.name !== "relayer" && srv.name !== "chainsaw", @@ -64,8 +64,12 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => { { const res = await createProjectClient(props.teamId, params); - const vaultTokens = await createVaultAccountAndAccessToken({ - project: res.project, + await createVaultServiceAccount({ + mode: "managed", + project: { + projectId: res.project.id, + teamId: res.project.teamId, + }, projectSecretKey: res.secret, }).catch((error) => { console.error( @@ -75,12 +79,6 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => { throw error; }); - const managementAccessToken = vaultTokens.managementToken?.accessToken; - - if (!managementAccessToken) { - throw new Error("Missing management access token for project wallet"); - } - return { project: res.project, secret: res.secret, diff --git a/apps/dashboard/src/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 c5406c9ac57..8924557d0c2 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 @@ -8,6 +8,7 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; +import { createVaultServiceAccount } from "@/actions/vault"; import { reportOnboardingCompleted } from "@/analytics/report"; import type { Project } from "@/api/project/projects"; import type { CreateProjectPrefillOptions } from "@/components/project/create-project-modal"; @@ -33,7 +34,6 @@ import { createProjectClient } from "@/hooks/useApi"; import { useDashboardRouter } from "@/lib/DashboardRouter"; import { projectDomainsSchema, projectNameSchema } from "@/schema/validations"; import { toArrFromList } from "@/utils/string"; -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", @@ -55,8 +55,12 @@ export function CreateProjectFormOnboarding(props: { { const res = await createProjectClient(props.teamId, params); - const vaultTokens = await createVaultAccountAndAccessToken({ - project: res.project, + await createVaultServiceAccount({ + mode: "managed", + project: { + projectId: res.project.id, + teamId: res.project.teamId, + }, projectSecretKey: res.secret, }).catch((error) => { console.error( @@ -66,15 +70,6 @@ export function CreateProjectFormOnboarding(props: { throw error; }); - const managementAccessToken = - vaultTokens.managementToken?.accessToken; - - if (!managementAccessToken) { - throw new Error( - "Missing management access token for project wallet", - ); - } - return { project: res.project, secret: res.secret, diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts index 5315381c7b1..41c7728c41c 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,12 +3,9 @@ import { decrypt, encrypt } from "@thirdweb-dev/service-utils"; import { createAccessToken, - createEoa, - createServiceAccount, createVaultClient, type VaultClient, } from "@thirdweb-dev/vault-sdk"; -import { engineCloudProxy } from "@/actions/proxies"; import { createVaultServerWallet, setVaultProjectWallet, @@ -16,7 +13,6 @@ import { 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"; @@ -24,44 +20,11 @@ const SERVER_WALLET_ACCESS_TOKEN_PURPOSE = const SERVER_WALLET_MANAGEMENT_ACCESS_TOKEN_PURPOSE = "Management Token for Dashboard"; -/** - * Retry a function with exponential backoff. - */ -async function withRetry( - fn: () => Promise, - options: { maxAttempts?: number; baseDelayMs?: number } = {}, -): Promise { - const { maxAttempts = 3, baseDelayMs = 1000 } = options; - if (!Number.isInteger(maxAttempts) || maxAttempts < 1) { - throw new Error("maxAttempts must be at least 1"); - } - if (!Number.isFinite(baseDelayMs) || baseDelayMs < 0) { - throw new Error("baseDelayMs must be a non-negative number"); - } - let lastError: Error | undefined; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - return await fn(); - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - if (attempt < maxAttempts) { - // Exponential backoff with cap at 30s and jitter to prevent thundering herd - const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), 30_000); - const jitter = Math.floor(Math.random() * Math.min(250, delay)); - await new Promise((resolve) => - setTimeout(resolve, delay + jitter), - ); - } - } - } - - throw lastError ?? new Error("withRetry failed without capturing an error"); -} - let vc: VaultClient | null = null; -export async function initVaultClient() { +// Only `upgradeAccessTokensForSolana` still needs this; every other vault +// operation goes through api-server. +async function initVaultClient() { if (vc) { return vc; } @@ -71,57 +34,6 @@ export async function initVaultClient() { return vc; } -export async function createVaultAccountAndAccessToken(props: { - project: Project; - projectSecretKey?: string; - projectSecretHash?: string; -}) { - try { - const vaultClient = await initVaultClient(); - - const serviceAccountResult = await createServiceAccount({ - client: vaultClient, - request: { - options: { - metadata: { - projectId: props.project.id, - purpose: "Thirdweb Project Server Wallet Service Account", - teamId: props.project.teamId, - }, - }, - }, - }); - if (serviceAccountResult.success === false) { - throw new Error( - `Failed to create service account: ${serviceAccountResult.error}`, - ); - } - const serviceAccount = serviceAccountResult.data; - const adminKey = serviceAccount.adminKey; - const rotationCode = serviceAccount.rotationCode; - - const { managementToken, walletToken } = - await createAndEncryptVaultAccessTokens({ - project: props.project, - projectSecretKey: props.projectSecretKey, - projectSecretHash: props.projectSecretHash, - vaultClient, - adminKey, - rotationCode, - }); - - return { - adminKey, - managementToken, - walletToken, - }; - } catch (error) { - throw new Error( - `Failed to create vault account and access token: ${error}`, - ); - } -} - export async function createProjectServerWallet(props: { project: Project; label?: string; @@ -147,166 +59,6 @@ export async function updateDefaultProjectWallet(props: { }); } -/** - * Creates the project's first server wallet during vault setup. Runs before the - * management token is stored on the project, so it uses the token it was handed. - */ -async function createDefaultServerWallet(props: { - project: Project; - managementAccessToken: string; - vaultClient: VaultClient; -}) { - const eoa = await createEoa({ - client: props.vaultClient, - request: { - auth: { - accessToken: props.managementAccessToken, - }, - options: { - metadata: { - label: getProjectWalletLabel(props.project.name), - 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; - projectSecretKey?: string; - projectSecretHash?: string; - adminKey: string; - rotationCode: string; -}) { - const { - project, - projectSecretKey, - projectSecretHash, - vaultClient, - adminKey, - rotationCode, - } = props; - - const [managementTokenResult, walletTokenResult] = await Promise.all([ - createManagementAccessToken({ project, adminKey, vaultClient }), - createWalletAccessToken({ project, adminKey, vaultClient }), - ]); - - if (!managementTokenResult.success) { - throw new Error( - `Failed to create management token: ${managementTokenResult.error}`, - ); - } - - if (!walletTokenResult.success) { - throw new Error( - `Failed to create wallet token: ${walletTokenResult.error}`, - ); - } - - const managementToken = managementTokenResult.data; - const walletToken = walletTokenResult.data; - - // CRITICAL: Save credentials IMMEDIATELY after creating tokens. - // This prevents a broken state if wallet creation or other operations fail. - // The rotationCode is consumed when rotating, so if we don't save the new one, - // the project becomes unrecoverable. - let encryptedAdminKey: string | null = null; - let encryptedWalletAccessToken: string | null = null; - - if (projectSecretKey) { - // verify that the project secret key is valid - const projectSecretKeyHash = await hashSecretKey(projectSecretKey); - const secretKeysHashed = [ - ...project.secretKeys, - // for newly rotated secret keys, we don't have the secret key in the project secret keys yet - ...(projectSecretHash ? [{ hash: projectSecretHash }] : []), - ]; - if (!secretKeysHashed.some((key) => key?.hash === projectSecretKeyHash)) { - throw new Error("Invalid project secret key"); - } - - // encrypt admin key and wallet token with project secret key - [encryptedAdminKey, encryptedWalletAccessToken] = await Promise.all([ - encrypt(adminKey, projectSecretKey), - encrypt(walletToken.accessToken, projectSecretKey), - ]); - } - - let projectWalletAddress: string | null | undefined = project.services.find( - (s) => s.name === "engineCloud", - )?.projectWalletAddress; - - if (!projectWalletAddress) { - const defaultProjectServerWallet = await createDefaultServerWallet({ - project, - managementAccessToken: managementToken.accessToken, - vaultClient, - }); - projectWalletAddress = defaultProjectServerWallet.address; - } - - // Save credentials with retry - this is critical because if rotation succeeded - // but this save fails, the new rotation code is lost and project becomes unrecoverable - await withRetry( - () => - updateProjectClient( - { - projectId: project.id, - teamId: project.teamId, - }, - { - services: [ - ...project.services.filter( - (service) => service.name !== "engineCloud", - ), - { - name: "engineCloud", - actions: [], - managementAccessToken: managementToken.accessToken, - maskedAdminKey: maskSecret(adminKey), - encryptedAdminKey, - encryptedWalletAccessToken, - rotationCode: rotationCode, - projectWalletAddress: projectWalletAddress ?? null, - }, - ], - }, - ), - { maxAttempts: 3, baseDelayMs: 1000 }, - ); - - return { - managementToken, - walletToken, - }; -} - async function createWalletAccessToken(props: { project: Project; adminKey: string; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/create-vault-account.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/create-vault-account.client.tsx index 4bfd99bcf84..56bea93f967 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/create-vault-account.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/create-vault-account.client.tsx @@ -9,6 +9,7 @@ import { } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; +import { createVaultServiceAccount } from "@/actions/vault"; import type { Project } from "@/api/project/projects"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; @@ -25,10 +26,7 @@ import { Label } from "@/components/ui/label"; import { Spinner } from "@/components/ui/Spinner"; import { useDashboardRouter } from "@/lib/DashboardRouter"; import { cn } from "@/lib/utils"; -import { - createVaultAccountAndAccessToken, - maskSecret, -} from "../../transactions/lib/vault.client"; +import { maskSecret } from "../../transactions/lib/vault.client"; export function CreateVaultAccountButton(props: { project: Project }) { const [secretKeyModalOpen, setSecretKeyModalOpen] = useState(false); @@ -45,12 +43,14 @@ export function CreateVaultAccountButton(props: { project: Project }) { setModalOpen(true); setSecretKeyModalOpen(false); - const result = await createVaultAccountAndAccessToken({ - project: props.project, + return createVaultServiceAccount({ + mode: projectSecretKey ? "managed" : "ejected", + project: { + projectId: props.project.id, + teamId: props.project.teamId, + }, projectSecretKey, }); - - return result; }, onError: (error) => { setErrorMessage(error.message); @@ -90,12 +90,23 @@ export function CreateVaultAccountButton(props: { project: Project }) { setManageSelfChecked(false); }; + // Only an ejected vault returns its keys; a managed vault keeps them. + const createdKeys = + initialiseProjectWithVaultMutation.data?.adminKey && + initialiseProjectWithVaultMutation.data.walletAccessToken + ? { + adminKey: initialiseProjectWithVaultMutation.data.adminKey, + walletAccessToken: + initialiseProjectWithVaultMutation.data.walletAccessToken, + } + : undefined; + const handleDownloadKeys = () => { - if (!initialiseProjectWithVaultMutation.data) { + if (!createdKeys) { return; } - const fileContent = `Project:\n${props.project.name} (${props.project.publishableKey})\n\nVault Admin Key:\n${initialiseProjectWithVaultMutation.data.adminKey}\n\nVault Access Token:\n${initialiseProjectWithVaultMutation.data.walletToken.accessToken}\n`; + const fileContent = `Project:\n${props.project.name} (${props.project.publishableKey})\n\nVault Admin Key:\n${createdKeys.adminKey}\n\nVault Access Token:\n${createdKeys.walletAccessToken}\n`; const blob = new Blob([fileContent], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); @@ -114,7 +125,7 @@ export function CreateVaultAccountButton(props: { project: Project }) { }; const handleCloseModal = () => { - if (!keysConfirmed) { + if (createdKeys && !keysConfirmed) { return; } @@ -234,7 +245,7 @@ export function CreateVaultAccountButton(props: { project: Project }) { {initialiseProjectWithVaultMutation.isPending ? ( <> @@ -248,7 +259,7 @@ export function CreateVaultAccountButton(props: { project: Project }) {

- ) : initialiseProjectWithVaultMutation.data ? ( + ) : createdKeys ? (
Save your Vault Admin Key @@ -265,12 +276,8 @@ export function CreateVaultAccountButton(props: { project: Project }) {

@@ -323,6 +330,34 @@ export function CreateVaultAccountButton(props: { project: Project }) {

+ ) : initialiseProjectWithVaultMutation.data ? ( +
+ + Vault Created + + +
+
+

Vault Admin Key

+
+
+ {initialiseProjectWithVaultMutation.data.maskedAdminKey} +
+

+ Your admin key and wallet access token were encrypted with + your project secret key. Use your project secret key to + access your vault. +

+
+
+
+ +
+ +
+
) : null}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/wallets/vault-recovery-card.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/wallets/vault-recovery-card.client.tsx index 619992f67a3..d6400c8c295 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/wallets/vault-recovery-card.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/wallets/vault-recovery-card.client.tsx @@ -9,6 +9,7 @@ import { } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; +import { createVaultServiceAccount } from "@/actions/vault"; import type { Project } from "@/api/project/projects"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { @@ -27,11 +28,7 @@ import { CopyTextButton } from "@/components/ui/CopyTextButton"; import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/Spinner"; -import { - createVaultAccountAndAccessToken, - initVaultClient, - maskSecret, -} from "../../../transactions/lib/vault.client"; +import { maskSecret } from "../../../transactions/lib/vault.client"; interface VaultRecoveryCardProps { errorMessage: string; @@ -66,15 +63,12 @@ export function VaultRecoveryCard({ const regenerateMutation = useMutation({ mutationFn: async () => { - await initVaultClient(); - - const result = await createVaultAccountAndAccessToken({ - project, + return createVaultServiceAccount({ + mode: willCreateEjectedVault ? "ejected" : "managed", + project: { projectId: project.id, teamId: project.teamId }, // Only pass secret key if creating managed vault (not ejected) projectSecretKey: willCreateEjectedVault ? undefined : secretKeyInput, }); - - return result; }, onSuccess: () => { // For managed vaults (with secret key), reload immediately @@ -86,12 +80,22 @@ export function VaultRecoveryCard({ }, }); + // Only an ejected vault returns its keys; a managed vault keeps them. + const createdKeys = + regenerateMutation.data?.adminKey && + regenerateMutation.data.walletAccessToken + ? { + adminKey: regenerateMutation.data.adminKey, + walletAccessToken: regenerateMutation.data.walletAccessToken, + } + : undefined; + const handleDownloadKeys = () => { - if (!regenerateMutation.data) { + if (!createdKeys) { return; } - const fileContent = `Project:\n${project.name} (${project.publishableKey})\n\nVault Admin Key:\n${regenerateMutation.data.adminKey}\n\nVault Access Token:\n${regenerateMutation.data.walletToken.accessToken}\n`; + const fileContent = `Project:\n${project.name} (${project.publishableKey})\n\nVault Admin Key:\n${createdKeys.adminKey}\n\nVault Access Token:\n${createdKeys.walletAccessToken}\n`; const blob = new Blob([fileContent], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); @@ -170,7 +174,7 @@ export function VaultRecoveryCard({ {/* Show key download UI for ejected vaults after success */} - {willCreateEjectedVault && regenerateMutation.data ? ( + {createdKeys ? ( <> Save your Vault Admin Key @@ -185,10 +189,8 @@ export function VaultRecoveryCard({