Skip to content

Commit 5ca4d74

Browse files
authored
Preserve wallet address on vault credential rotation (#8618)
1 parent 0382d42 commit 5ca4d74

4 files changed

Lines changed: 484 additions & 78 deletions

File tree

apps/dashboard/src/@/hooks/useApi.ts

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -330,20 +330,16 @@ export async function rotateSecretKeyClient(params: { project: Project }) {
330330
throw new Error(res.error);
331331
}
332332

333-
try {
334-
// if the project has an encrypted vault admin key, rotate it as well
335-
const service = params.project.services.find(
336-
(service) => service.name === "engineCloud",
337-
);
338-
if (service?.encryptedAdminKey) {
339-
await rotateVaultAccountAndAccessToken({
340-
project: params.project,
341-
projectSecretKey: res.data.data.secret,
342-
projectSecretHash: res.data.data.secretHash,
343-
});
344-
}
345-
} catch (error) {
346-
console.error("Failed to rotate vault admin key", error);
333+
// if the project has an encrypted vault admin key, rotate it as well
334+
const service = params.project.services.find(
335+
(service) => service.name === "engineCloud",
336+
);
337+
if (service?.encryptedAdminKey) {
338+
await rotateVaultAccountAndAccessToken({
339+
project: params.project,
340+
projectSecretKey: res.data.data.secret,
341+
projectSecretHash: res.data.data.secretHash,
342+
});
347343
}
348344

349345
return res.data;

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts

Lines changed: 117 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,41 @@ const SERVER_WALLET_ACCESS_TOKEN_PURPOSE =
2121
export const SERVER_WALLET_MANAGEMENT_ACCESS_TOKEN_PURPOSE =
2222
"Management Token for Dashboard";
2323

24+
/**
25+
* Retry a function with exponential backoff.
26+
*/
27+
async function withRetry<T>(
28+
fn: () => Promise<T>,
29+
options: { maxAttempts?: number; baseDelayMs?: number } = {},
30+
): Promise<T> {
31+
const { maxAttempts = 3, baseDelayMs = 1000 } = options;
32+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
33+
throw new Error("maxAttempts must be at least 1");
34+
}
35+
if (!Number.isFinite(baseDelayMs) || baseDelayMs < 0) {
36+
throw new Error("baseDelayMs must be a non-negative number");
37+
}
38+
let lastError: Error | undefined;
39+
40+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
41+
try {
42+
return await fn();
43+
} catch (error) {
44+
lastError = error instanceof Error ? error : new Error(String(error));
45+
if (attempt < maxAttempts) {
46+
// Exponential backoff with cap at 30s and jitter to prevent thundering herd
47+
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), 30_000);
48+
const jitter = Math.floor(Math.random() * Math.min(250, delay));
49+
await new Promise<void>((resolve) =>
50+
setTimeout(resolve, delay + jitter),
51+
);
52+
}
53+
}
54+
}
55+
56+
throw lastError ?? new Error("withRetry failed without capturing an error");
57+
}
58+
2459
let vc: VaultClient | null = null;
2560

2661
export async function initVaultClient() {
@@ -47,6 +82,20 @@ export async function rotateVaultAccountAndAccessToken(props: {
4782
throw new Error("No rotation code found");
4883
}
4984

85+
// IMPORTANT: Validate secret key BEFORE rotating to prevent bricking the project.
86+
// If we rotate first and then secret key validation fails, the old rotation code
87+
// is consumed but we can't save the new one, leaving the project unrecoverable.
88+
if (props.projectSecretKey) {
89+
const projectSecretKeyHash = await hashSecretKey(props.projectSecretKey);
90+
const secretKeysHashed = [
91+
...props.project.secretKeys,
92+
...(props.projectSecretHash ? [{ hash: props.projectSecretHash }] : []),
93+
];
94+
if (!secretKeysHashed.some((key) => key?.hash === projectSecretKeyHash)) {
95+
throw new Error("Invalid project secret key");
96+
}
97+
}
98+
5099
const rotateServiceAccountRes = await rotateServiceAccount({
51100
client: vaultClient,
52101
request: {
@@ -69,6 +118,9 @@ export async function rotateVaultAccountAndAccessToken(props: {
69118
vaultClient,
70119
adminKey,
71120
rotationCode,
121+
// Skip wallet creation on rotation - preserve the existing project wallet
122+
skipWalletCreation: true,
123+
existingProjectWalletAddress: service?.projectWalletAddress ?? undefined,
72124
});
73125

74126
return {
@@ -222,9 +274,19 @@ async function createAndEncryptVaultAccessTokens(props: {
222274
projectSecretHash?: string;
223275
adminKey: string;
224276
rotationCode: string;
277+
skipWalletCreation?: boolean;
278+
existingProjectWalletAddress?: string;
225279
}) {
226-
const { project, projectSecretKey, vaultClient, adminKey, rotationCode } =
227-
props;
280+
const {
281+
project,
282+
projectSecretKey,
283+
projectSecretHash,
284+
vaultClient,
285+
adminKey,
286+
rotationCode,
287+
skipWalletCreation,
288+
existingProjectWalletAddress,
289+
} = props;
228290

229291
const [managementTokenResult, walletTokenResult] = await Promise.all([
230292
createManagementAccessToken({ project, adminKey, vaultClient }),
@@ -246,81 +308,78 @@ async function createAndEncryptVaultAccessTokens(props: {
246308
const managementToken = managementTokenResult.data;
247309
const walletToken = walletTokenResult.data;
248310

249-
// create a default project server wallet
250-
const defaultProjectServerWallet = await createProjectServerWallet({
251-
project,
252-
managementAccessToken: managementToken.accessToken,
253-
label: getProjectWalletLabel(project.name),
254-
});
311+
// CRITICAL: Save credentials IMMEDIATELY after creating tokens.
312+
// This prevents a broken state if wallet creation or other operations fail.
313+
// The rotationCode is consumed when rotating, so if we don't save the new one,
314+
// the project becomes unrecoverable.
315+
let encryptedAdminKey: string | null = null;
316+
let encryptedWalletAccessToken: string | null = null;
255317

256318
if (projectSecretKey) {
257319
// verify that the project secret key is valid
258320
const projectSecretKeyHash = await hashSecretKey(projectSecretKey);
259321
const secretKeysHashed = [
260322
...project.secretKeys,
261323
// for newly rotated secret keys, we don't have the secret key in the project secret keys yet
262-
...(props.projectSecretHash ? [{ hash: props.projectSecretHash }] : []),
324+
...(projectSecretHash ? [{ hash: projectSecretHash }] : []),
263325
];
264326
if (!secretKeysHashed.some((key) => key?.hash === projectSecretKeyHash)) {
265327
throw new Error("Invalid project secret key");
266328
}
267329

268330
// encrypt admin key and wallet token with project secret key
269-
const [encryptedAdminKey, encryptedWalletAccessToken] = await Promise.all([
331+
[encryptedAdminKey, encryptedWalletAccessToken] = await Promise.all([
270332
encrypt(adminKey, projectSecretKey),
271333
encrypt(walletToken.accessToken, projectSecretKey),
272334
]);
335+
}
273336

274-
await updateProjectClient(
275-
{
276-
projectId: props.project.id,
277-
teamId: props.project.teamId,
278-
},
279-
{
280-
services: [
281-
...props.project.services.filter(
282-
(service) => service.name !== "engineCloud",
283-
),
284-
{
285-
name: "engineCloud",
286-
actions: [],
287-
managementAccessToken: managementToken.accessToken,
288-
maskedAdminKey: maskSecret(adminKey),
289-
encryptedAdminKey,
290-
encryptedWalletAccessToken,
291-
rotationCode: rotationCode,
292-
projectWalletAddress: defaultProjectServerWallet.address,
293-
},
294-
],
295-
},
296-
);
297-
} else {
298-
// no secret key, only store the management token, remove any encrypted keys
299-
await updateProjectClient(
300-
{
301-
projectId: props.project.id,
302-
teamId: props.project.teamId,
303-
},
304-
{
305-
services: [
306-
...props.project.services.filter(
307-
(service) => service.name !== "engineCloud",
308-
),
309-
{
310-
name: "engineCloud",
311-
actions: [],
312-
managementAccessToken: managementToken.accessToken,
313-
maskedAdminKey: maskSecret(adminKey),
314-
encryptedAdminKey: null,
315-
encryptedWalletAccessToken: null,
316-
rotationCode: rotationCode,
317-
projectWalletAddress: defaultProjectServerWallet.address,
318-
},
319-
],
320-
},
321-
);
337+
// For rotation, preserve existing wallet address. For new creation, create a default wallet.
338+
let projectWalletAddress: string | null | undefined =
339+
existingProjectWalletAddress ??
340+
project.services.find((s) => s.name === "engineCloud")
341+
?.projectWalletAddress;
342+
343+
// Only create a new wallet if we don't have one (initial setup, not rotation)
344+
if (!skipWalletCreation && !projectWalletAddress) {
345+
const defaultProjectServerWallet = await createProjectServerWallet({
346+
project,
347+
managementAccessToken: managementToken.accessToken,
348+
label: getProjectWalletLabel(project.name),
349+
});
350+
projectWalletAddress = defaultProjectServerWallet.address;
322351
}
323352

353+
// Save credentials with retry - this is critical because if rotation succeeded
354+
// but this save fails, the new rotation code is lost and project becomes unrecoverable
355+
await withRetry(
356+
() =>
357+
updateProjectClient(
358+
{
359+
projectId: project.id,
360+
teamId: project.teamId,
361+
},
362+
{
363+
services: [
364+
...project.services.filter(
365+
(service) => service.name !== "engineCloud",
366+
),
367+
{
368+
name: "engineCloud",
369+
actions: [],
370+
managementAccessToken: managementToken.accessToken,
371+
maskedAdminKey: maskSecret(adminKey),
372+
encryptedAdminKey,
373+
encryptedWalletAccessToken,
374+
rotationCode: rotationCode,
375+
projectWalletAddress: projectWalletAddress ?? null,
376+
},
377+
],
378+
},
379+
),
380+
{ maxAttempts: 3, baseDelayMs: 1000 },
381+
);
382+
324383
return {
325384
managementToken,
326385
walletToken,

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/wallets/page.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ServerWalletsTable } from "../../../transactions/components/server-wall
99
import type { Wallet } from "../../../transactions/server-wallets/wallet-table/types";
1010
import { listSolanaAccounts } from "../../../transactions/solana-wallets/lib/vault.client";
1111
import type { SolanaWallet } from "../../../transactions/solana-wallets/wallet-table/types";
12+
import { VaultRecoveryCard } from "./vault-recovery-card.client";
1213

1314
export const dynamic = "force-dynamic";
1415

@@ -107,12 +108,10 @@ export default async function Page(props: {
107108
return (
108109
<div className="flex flex-col gap-10">
109110
{eoas.error ? (
110-
<div className="rounded-xl border border-destructive/50 bg-destructive/10 p-4">
111-
<p className="text-destructive font-semibold mb-2">
112-
EVM Wallet Error
113-
</p>
114-
<p className="text-sm text-muted-foreground">{eoas.error.message}</p>
115-
</div>
111+
<VaultRecoveryCard
112+
errorMessage={eoas.error.message}
113+
project={project}
114+
/>
116115
) : (
117116
<ServerWalletsTable
118117
client={client}

0 commit comments

Comments
 (0)