diff --git a/apps/dashboard/src/@/hooks/useApi.ts b/apps/dashboard/src/@/hooks/useApi.ts index 18c6e4f1940..faf7d93c048 100644 --- a/apps/dashboard/src/@/hooks/useApi.ts +++ b/apps/dashboard/src/@/hooks/useApi.ts @@ -330,20 +330,16 @@ export async function rotateSecretKeyClient(params: { project: Project }) { throw new Error(res.error); } - try { - // if the project has an encrypted vault admin key, rotate it as well - const service = params.project.services.find( - (service) => service.name === "engineCloud", - ); - if (service?.encryptedAdminKey) { - await rotateVaultAccountAndAccessToken({ - project: params.project, - projectSecretKey: res.data.data.secret, - projectSecretHash: res.data.data.secretHash, - }); - } - } catch (error) { - console.error("Failed to rotate vault admin key", error); + // if the project has an encrypted vault admin key, rotate it as well + const service = params.project.services.find( + (service) => service.name === "engineCloud", + ); + if (service?.encryptedAdminKey) { + await rotateVaultAccountAndAccessToken({ + project: params.project, + projectSecretKey: res.data.data.secret, + projectSecretHash: res.data.data.secretHash, + }); } return res.data; 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 ac5ac44220e..a0b0445e30b 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 @@ -21,6 +21,41 @@ const SERVER_WALLET_ACCESS_TOKEN_PURPOSE = export 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() { @@ -47,6 +82,20 @@ export async function rotateVaultAccountAndAccessToken(props: { throw new Error("No rotation code found"); } + // IMPORTANT: Validate secret key BEFORE rotating to prevent bricking the project. + // If we rotate first and then secret key validation fails, the old rotation code + // is consumed but we can't save the new one, leaving the project unrecoverable. + if (props.projectSecretKey) { + const projectSecretKeyHash = await hashSecretKey(props.projectSecretKey); + const secretKeysHashed = [ + ...props.project.secretKeys, + ...(props.projectSecretHash ? [{ hash: props.projectSecretHash }] : []), + ]; + if (!secretKeysHashed.some((key) => key?.hash === projectSecretKeyHash)) { + throw new Error("Invalid project secret key"); + } + } + const rotateServiceAccountRes = await rotateServiceAccount({ client: vaultClient, request: { @@ -69,6 +118,9 @@ export async function rotateVaultAccountAndAccessToken(props: { vaultClient, adminKey, rotationCode, + // Skip wallet creation on rotation - preserve the existing project wallet + skipWalletCreation: true, + existingProjectWalletAddress: service?.projectWalletAddress ?? undefined, }); return { @@ -222,9 +274,19 @@ async function createAndEncryptVaultAccessTokens(props: { projectSecretHash?: string; adminKey: string; rotationCode: string; + skipWalletCreation?: boolean; + existingProjectWalletAddress?: string; }) { - const { project, projectSecretKey, vaultClient, adminKey, rotationCode } = - props; + const { + project, + projectSecretKey, + projectSecretHash, + vaultClient, + adminKey, + rotationCode, + skipWalletCreation, + existingProjectWalletAddress, + } = props; const [managementTokenResult, walletTokenResult] = await Promise.all([ createManagementAccessToken({ project, adminKey, vaultClient }), @@ -246,12 +308,12 @@ 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), - }); + // 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 @@ -259,68 +321,65 @@ async function createAndEncryptVaultAccessTokens(props: { const secretKeysHashed = [ ...project.secretKeys, // for newly rotated secret keys, we don't have the secret key in the project secret keys yet - ...(props.projectSecretHash ? [{ hash: props.projectSecretHash }] : []), + ...(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 - const [encryptedAdminKey, encryptedWalletAccessToken] = await Promise.all([ + [encryptedAdminKey, encryptedWalletAccessToken] = await Promise.all([ encrypt(adminKey, projectSecretKey), encrypt(walletToken.accessToken, projectSecretKey), ]); + } - await updateProjectClient( - { - projectId: props.project.id, - teamId: props.project.teamId, - }, - { - services: [ - ...props.project.services.filter( - (service) => service.name !== "engineCloud", - ), - { - name: "engineCloud", - actions: [], - managementAccessToken: managementToken.accessToken, - maskedAdminKey: maskSecret(adminKey), - encryptedAdminKey, - encryptedWalletAccessToken, - rotationCode: rotationCode, - projectWalletAddress: defaultProjectServerWallet.address, - }, - ], - }, - ); - } else { - // no secret key, only store the management token, remove any encrypted keys - await updateProjectClient( - { - projectId: props.project.id, - teamId: props.project.teamId, - }, - { - services: [ - ...props.project.services.filter( - (service) => service.name !== "engineCloud", - ), - { - name: "engineCloud", - actions: [], - managementAccessToken: managementToken.accessToken, - maskedAdminKey: maskSecret(adminKey), - encryptedAdminKey: null, - encryptedWalletAccessToken: null, - rotationCode: rotationCode, - projectWalletAddress: defaultProjectServerWallet.address, - }, - ], - }, - ); + // For rotation, preserve existing wallet address. For new creation, create a default wallet. + let projectWalletAddress: string | null | undefined = + existingProjectWalletAddress ?? + project.services.find((s) => s.name === "engineCloud") + ?.projectWalletAddress; + + // Only create a new wallet if we don't have one (initial setup, not rotation) + if (!skipWalletCreation && !projectWalletAddress) { + const defaultProjectServerWallet = await createProjectServerWallet({ + project, + managementAccessToken: managementToken.accessToken, + label: getProjectWalletLabel(project.name), + }); + 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, diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/wallets/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/wallets/page.tsx index e309ae2d755..6943181fdff 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/wallets/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/wallets/page.tsx @@ -9,6 +9,7 @@ import { ServerWalletsTable } from "../../../transactions/components/server-wall import type { Wallet } from "../../../transactions/server-wallets/wallet-table/types"; import { listSolanaAccounts } from "../../../transactions/solana-wallets/lib/vault.client"; import type { SolanaWallet } from "../../../transactions/solana-wallets/wallet-table/types"; +import { VaultRecoveryCard } from "./vault-recovery-card.client"; export const dynamic = "force-dynamic"; @@ -107,12 +108,10 @@ export default async function Page(props: { return (
{eoas.error ? ( -
-

- EVM Wallet Error -

-

{eoas.error.message}

-
+ ) : ( s.name === "engineCloud", + ); + const wasManagedVault = !!engineCloudService?.encryptedAdminKey; + + const isInsufficientScopeError = + errorMessage.includes("AUTH_INSUFFICIENT_SCOPE") || + errorMessage.toLowerCase().includes("insufficient scope"); + + const regenerateMutation = useMutation({ + mutationFn: async () => { + await initVaultClient(); + + const result = await createVaultAccountAndAccessToken({ + project, + // Only pass secret key if it was a managed vault and user provided one + projectSecretKey: wasManagedVault ? secretKeyInput : undefined, + }); + + return result; + }, + onSuccess: () => { + // For managed vaults, reload immediately (keys are encrypted with secret key) + // For ejected vaults, show the key download dialog first + if (wasManagedVault) { + window.location.reload(); + } + // For ejected vaults, we stay in the dialog to show the admin key + }, + }); + + const handleDownloadKeys = () => { + if (!regenerateMutation.data) { + 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 blob = new Blob([fileContent], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + const filename = `${project.name}-vault-keys.txt`; + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + + document.body.removeChild(link); + URL.revokeObjectURL(url); + + toast.success(`Keys downloaded as ${filename}`); + setKeysDownloaded(true); + }; + + const handleCloseAfterKeysSaved = () => { + if (!keysConfirmed) { + return; + } + window.location.reload(); + }; + + // For managed vaults, require secret key input + const canProceed = wasManagedVault + ? confirmed && secretKeyInput.trim().length > 0 + : confirmed; + + if (!isInsufficientScopeError) { + // Show standard error for non-scope errors + return ( +
+

EVM Wallet Error

+

{errorMessage}

+
+ ); + } + + return ( + + + Server Wallet Access Lost + +

+ Your server wallet credentials have become invalid. This can happen if + the secret key was rotated but the new credentials weren't saved + properly. +

+ +
+

+ ⚠️ Important: Existing wallets cannot be recovered +

+

+ If you have existing wallets that are important to your project, + please{" "} + + contact support + {" "} + before proceeding. +

+
+ + + + + + + {/* Show key download UI for ejected vaults after success */} + {!wasManagedVault && regenerateMutation.data ? ( + <> + + Save your Vault Admin Key + +
+

+ You'll need this key to create server wallets and + access tokens. +

+ +
+ +

+ Download this key to your local machine or a password + manager. +

+
+ + + Secure your admin key + + This key will not be displayed again. Store it + securely as it provides access to your server wallets. + +
+ + {keysDownloaded && ( + + + + )} +
+
+ + setKeysConfirmed(!!v)} + /> + I confirm that I've securely stored my admin + key + +
+
+
+
+
+ + + Close + + + + ) : ( + <> + + + Create New Server Wallet Configuration? + + +
+

+ This will create a completely new server wallet + configuration for your project. +

+
+

+ Warning: This action cannot be undone +

+
    +
  • + Any existing server wallets will be permanently + inaccessible +
  • +
  • + You will need to create new wallets after this + process +
  • +
+
+ + {wasManagedVault && ( +
+ + setSecretKeyInput(e.target.value)} + /> +

+ Your secret key is required to create a managed + vault. +

+
+ )} + +
+ + setConfirmed(checked === true) + } + /> + +
+
+
+
+ + { + setConfirmed(false); + setSecretKeyInput(""); + }} + > + Cancel + + { + e.preventDefault(); + regenerateMutation.mutate(); + }} + variant="destructive" + className="gap-2" + > + {regenerateMutation.isPending && ( + + )} + Create New Wallet Configuration + + + + )} +
+
+ + {regenerateMutation.error && ( +

+ Failed to create new configuration:{" "} + {regenerateMutation.error.message} +

+ )} + +

+ Error details: {errorMessage} +

+
+
+ ); +}