diff --git a/apps/dashboard/src/@/actions/project-wallet/list-server-wallets.ts b/apps/dashboard/src/@/actions/project-wallet/list-server-wallets.ts index ebd2e41e857..35691d40bcc 100644 --- a/apps/dashboard/src/@/actions/project-wallet/list-server-wallets.ts +++ b/apps/dashboard/src/@/actions/project-wallet/list-server-wallets.ts @@ -1,68 +1,27 @@ "use server"; -import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk"; -import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs"; +import { listVaultServerWallets } from "@/actions/vault"; import type { ProjectWalletSummary } from "@/lib/server/project-wallet"; -interface VaultWalletListItem { - id: string; - address: string; - metadata?: { - label?: string; - projectId?: string; - teamId?: string; - type?: string; - } | null; -} - export async function listProjectServerWallets(params: { - managementAccessToken: string; + teamId: string; projectId: string; pageSize?: number; }): Promise { - const { managementAccessToken, projectId, pageSize = 100 } = params; - - if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) { - return []; - } + const { teamId, projectId, pageSize = 100 } = params; try { - const vaultClient = await createVaultClient({ - baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL, + const { wallets } = await listVaultServerWallets({ + chainType: "evm", + pageSize, + project: { projectId, teamId }, }); - const response = await listEoas({ - client: vaultClient, - request: { - auth: { - accessToken: managementAccessToken, - }, - options: { - page: 0, - // @ts-expect-error - Vault SDK expects snake_case pagination fields - page_size: pageSize, - }, - }, - }); - - if (!response.success || !response.data?.items) { - return []; - } - - const items = response.data.items as VaultWalletListItem[]; - - return items - .filter((item) => { - return ( - item.metadata?.projectId === projectId && - (!item.metadata?.type || item.metadata.type === "server-wallet") - ); - }) - .map((item) => ({ - id: item.id, - address: item.address, - label: item.metadata?.label ?? undefined, - })); + return wallets.map((wallet) => ({ + address: wallet.address, + id: wallet.address, + label: wallet.label ?? undefined, + })); } catch (error) { console.error("Failed to list project server wallets", error); return []; diff --git a/apps/dashboard/src/@/actions/proxies.ts b/apps/dashboard/src/@/actions/proxies.ts index b9c4fab51d1..d4fb77b02d6 100644 --- a/apps/dashboard/src/@/actions/proxies.ts +++ b/apps/dashboard/src/@/actions/proxies.ts @@ -10,7 +10,7 @@ import { ANALYTICS_SERVICE_URL } from "@/constants/server-envs"; type ProxyActionParams = { pathname: string; searchParams?: Record; - method: "GET" | "POST" | "PUT" | "DELETE"; + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; body?: string; headers?: Record; parseAsText?: boolean; diff --git a/apps/dashboard/src/@/actions/vault.ts b/apps/dashboard/src/@/actions/vault.ts new file mode 100644 index 00000000000..aa2bbe84c33 --- /dev/null +++ b/apps/dashboard/src/@/actions/vault.ts @@ -0,0 +1,256 @@ +"use server"; + +import { apiServerProxy } from "@/actions/proxies"; + +type VaultChainType = "evm" | "solana"; + +type VaultServerWallet = { + address: string; + label: string | null; + chainType: VaultChainType; + createdAt: string; + updatedAt: string; +}; + +type VaultServerWalletPage = { + wallets: VaultServerWallet[]; + page: number; + pageSize: number; + totalRecords: number; + projectWalletAddress: string | null; +}; + +type VaultAccessToken = { + id: string; + accessToken: string | null; + maskedAccessToken: string; + purpose: string | null; + createdAt: string; + updatedAt: string; + expiresAt: string; +}; + +type VaultAccessTokenPage = { + accessTokens: VaultAccessToken[]; + page: number; + pageSize: number; + totalRecords: number; + revealed: boolean; +}; + +type RotateVaultServiceAccountResult = { + isManagedVault: boolean; + maskedAdminKey: string; + adminKey?: string; + walletAccessToken?: string; +}; + +/** + * Credentials the caller supplies to unlock a vault. A managed vault takes the + * project secret key, an ejected one takes the vault admin key. + */ +export type VaultCredentials = { + projectSecretKey?: string; + adminKey?: string; +}; + +type ProjectRef = { + teamId: string; + projectId: string; +}; + +function vaultPath(project: ProjectRef, path: string) { + return `/v1/teams/${project.teamId}/projects/${project.projectId}/vault${path}`; +} + +/** API server errors are `{ data: null, error: { message, code, ... } }`. */ +function errorMessage(body: string, fallback: string) { + try { + const parsed = JSON.parse(body) as { error?: { message?: string } }; + if (parsed.error?.message) { + return parsed.error.message; + } + } catch { + // not JSON, fall through + } + return body || fallback; +} + +export async function listVaultServerWallets(params: { + project: ProjectRef; + chainType: VaultChainType; + page?: number; + pageSize?: number; +}): Promise { + const res = await apiServerProxy<{ data: VaultServerWalletPage }>({ + method: "GET", + pathname: vaultPath(params.project, "/wallets"), + searchParams: { + chainType: params.chainType, + page: params.page?.toString(), + pageSize: params.pageSize?.toString(), + }, + }); + + if (!res.ok) { + throw new Error(errorMessage(res.error, "Failed to list server wallets")); + } + + return res.data.data; +} + +export async function createVaultServerWallet(params: { + project: ProjectRef; + chainType: VaultChainType; + label?: string; + setAsProjectWallet?: boolean; +}): Promise<{ + wallet: VaultServerWallet; + smartAccountCached: boolean; + projectWalletAddress: string | null; +}> { + const res = await apiServerProxy<{ + data: { + wallet: VaultServerWallet; + smartAccountCached: boolean; + projectWalletAddress: string | null; + }; + }>({ + body: JSON.stringify({ + chainType: params.chainType, + label: params.label, + setAsProjectWallet: params.setAsProjectWallet, + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + pathname: vaultPath(params.project, "/wallets"), + }); + + if (!res.ok) { + throw new Error(errorMessage(res.error, "Failed to create server wallet")); + } + + return res.data.data; +} + +export async function setVaultProjectWallet(params: { + project: ProjectRef; + address: string; +}): Promise<{ projectWalletAddress: string }> { + const res = await apiServerProxy<{ + data: { projectWalletAddress: string }; + }>({ + body: JSON.stringify({ address: params.address }), + headers: { "Content-Type": "application/json" }, + method: "PATCH", + pathname: vaultPath(params.project, "/project-wallet"), + }); + + if (!res.ok) { + throw new Error(errorMessage(res.error, "Failed to update project wallet")); + } + + return res.data.data; +} + +export async function rotateVaultServiceAccount(params: { + project: ProjectRef; + projectSecretKey?: string; +}): Promise { + const res = await apiServerProxy<{ + data: RotateVaultServiceAccountResult; + }>({ + body: JSON.stringify({ projectSecretKey: params.projectSecretKey }), + headers: { "Content-Type": "application/json" }, + method: "POST", + pathname: vaultPath(params.project, "/service-account/rotate"), + }); + + if (!res.ok) { + throw new Error(errorMessage(res.error, "Failed to rotate admin key")); + } + + return res.data.data; +} + +export async function listVaultAccessTokens(params: { + project: ProjectRef; + credentials?: VaultCredentials; + page?: number; + pageSize?: number; +}): Promise { + const { credentials } = params; + + const res = await apiServerProxy<{ data: VaultAccessTokenPage }>({ + headers: { + ...(credentials?.projectSecretKey + ? { "x-vault-project-secret-key": credentials.projectSecretKey } + : {}), + ...(credentials?.adminKey + ? { "x-vault-admin-key": credentials.adminKey } + : {}), + }, + method: "GET", + pathname: vaultPath(params.project, "/access-tokens"), + searchParams: { + page: params.page?.toString(), + pageSize: params.pageSize?.toString(), + }, + }); + + if (!res.ok) { + throw new Error(errorMessage(res.error, "Failed to list access tokens")); + } + + return res.data.data; +} + +export async function createVaultAccessToken(params: { + project: ProjectRef; + credentials: VaultCredentials; + label?: string; +}): Promise<{ id: string; accessToken: string; purpose: string }> { + const res = await apiServerProxy<{ + data: { id: string; accessToken: string; purpose: string }; + }>({ + body: JSON.stringify({ + adminKey: params.credentials.adminKey, + label: params.label, + projectSecretKey: params.credentials.projectSecretKey, + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + pathname: vaultPath(params.project, "/access-tokens"), + }); + + if (!res.ok) { + throw new Error(errorMessage(res.error, "Failed to create access token")); + } + + return res.data.data; +} + +export async function revokeVaultAccessToken(params: { + project: ProjectRef; + credentials: VaultCredentials; + accessTokenId: string; +}): Promise<{ id: string }> { + const res = await apiServerProxy<{ data: { id: string } }>({ + body: JSON.stringify({ + adminKey: params.credentials.adminKey, + projectSecretKey: params.credentials.projectSecretKey, + }), + headers: { "Content-Type": "application/json" }, + method: "DELETE", + pathname: vaultPath( + params.project, + `/access-tokens/${encodeURIComponent(params.accessTokenId)}`, + ), + }); + + if (!res.ok) { + throw new Error(errorMessage(res.error, "Failed to revoke access token")); + } + + return res.data.data; +} diff --git a/apps/dashboard/src/@/lib/server/project-wallet.ts b/apps/dashboard/src/@/lib/server/project-wallet.ts index 4597c079add..8e18ec2f855 100644 --- a/apps/dashboard/src/@/lib/server/project-wallet.ts +++ b/apps/dashboard/src/@/lib/server/project-wallet.ts @@ -1,8 +1,7 @@ import "server-only"; -import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk"; +import { listVaultServerWallets } from "@/actions/vault"; import type { Project } from "@/api/project/projects"; -import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs"; import { getProjectWalletLabel } from "@/lib/project-wallet"; export type ProjectWalletSummary = { @@ -11,17 +10,6 @@ export type ProjectWalletSummary = { label?: string; }; -type VaultWalletListItem = { - id: string; - address: string; - metadata?: { - label?: string; - projectId?: string; - teamId?: string; - type?: string; - }; -}; - export async function getProjectWallet( project: Project, ): Promise { @@ -35,52 +23,20 @@ export async function getProjectWallet( engineCloudService as { projectWalletAddress?: string } | undefined )?.projectWalletAddress; - if ( - !managementAccessToken || - !NEXT_PUBLIC_THIRDWEB_VAULT_URL || - !projectWalletAddress - ) { + if (!managementAccessToken || !projectWalletAddress) { return undefined; } try { - const vaultClient = await createVaultClient({ - baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL, + const { wallets } = await listVaultServerWallets({ + chainType: "evm", + pageSize: 100, + project: { projectId: project.id, teamId: project.teamId }, }); - const response = await listEoas({ - client: vaultClient, - request: { - auth: { - accessToken: managementAccessToken, - }, - options: { - page: 0, - // @ts-expect-error - SDK expects snake_case for pagination arguments - page_size: 100, - }, - }, - }); - - if (!response.success || !response.data) { - return undefined; - } - - const items = response.data.items as VaultWalletListItem[] | undefined; - - if (!items?.length) { - return undefined; - } - - const defaultLabel = getProjectWalletLabel(project.name); - - const serverWallets = items.filter( - (item) => item.metadata?.projectId === project.id, - ); - - const defaultWallet = serverWallets.find( - (item) => - item.address.toLowerCase() === projectWalletAddress.toLowerCase(), + const defaultWallet = wallets.find( + (wallet) => + wallet.address.toLowerCase() === projectWalletAddress.toLowerCase(), ); if (!defaultWallet) { @@ -88,9 +44,9 @@ export async function getProjectWallet( } return { - id: defaultWallet.id, address: defaultWallet.address, - label: defaultWallet.metadata?.label ?? defaultLabel, + id: defaultWallet.address, + label: defaultWallet.label ?? getProjectWalletLabel(project.name), }; } catch (error) { console.error("Failed to load project wallet", error); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/project-wallet/project-wallet-details.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/project-wallet/project-wallet-details.tsx index 9167cbd7581..3374ffd0c19 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/project-wallet/project-wallet-details.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/project-wallet/project-wallet-details.tsx @@ -82,7 +82,7 @@ import { getSDKTheme } from "@/utils/sdk-component-theme"; import { updateDefaultProjectWallet } from "../../transactions/lib/vault.client"; type GetProjectServerWallets = (params: { - managementAccessToken: string; + teamId: string; projectId: string; }) => Promise; @@ -208,8 +208,8 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) { } return props.getProjectServerWallets({ - managementAccessToken, projectId: project.id, + teamId: project.teamId, }); }, queryKey: ["project", project.id, "server-wallets", managementAccessToken], diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/project-wallet/project-wallet.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/project-wallet/project-wallet.tsx index 4d3ab97ba0f..a6dc8d790ac 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/project-wallet/project-wallet.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/project-wallet/project-wallet.tsx @@ -36,7 +36,7 @@ import { CreateServerWallet } from "../../transactions/server-wallets/components import { ProjectWalletDetailsSection } from "./project-wallet-details"; type GetProjectServerWallets = (params: { - managementAccessToken: string; + teamId: string; projectId: string; }) => Promise; @@ -66,8 +66,8 @@ function CreateProjectWalletSection(props: { } return props.getProjectServerWallets({ - managementAccessToken, projectId: project.id, + teamId: project.teamId, }); }, queryKey: ["project", project.id, "server-wallets", managementAccessToken], diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/server-wallets.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/server-wallets.ts new file mode 100644 index 00000000000..95b9eb23bf0 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/server-wallets.ts @@ -0,0 +1,53 @@ +import "server-only"; + +import { listVaultServerWallets } from "@/actions/vault"; +import type { Wallet } from "../server-wallets/wallet-table/types"; + +export type ServerWalletList = { + data: { items: Wallet[]; totalRecords: number }; + error: Error | null; + success: boolean; +}; + +export async function listEvmServerWallets(params: { + teamId: string; + projectId: string; + page?: number; + limit?: number; +}): Promise { + const { teamId, projectId, page = 1, limit = 100 } = params; + + try { + const result = await listVaultServerWallets({ + chainType: "evm", + page, + pageSize: limit, + project: { projectId, teamId }, + }); + + return { + data: { + items: result.wallets.map((wallet) => ({ + address: wallet.address, + createdAt: wallet.createdAt, + id: wallet.address, + metadata: { + label: wallet.label ?? undefined, + projectId, + type: "server-wallet", + }, + updatedAt: wallet.updatedAt, + })), + totalRecords: result.totalRecords, + }, + error: null, + success: true, + }; + } catch (error) { + return { + data: { items: [], totalRecords: 0 }, + error: error instanceof Error ? error : new Error("Unknown error"), + success: false, + }; + } +} 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 a0b0445e30b..5315381c7b1 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 @@ -6,10 +6,13 @@ import { createEoa, createServiceAccount, createVaultClient, - rotateServiceAccount, type VaultClient, } from "@thirdweb-dev/vault-sdk"; import { engineCloudProxy } from "@/actions/proxies"; +import { + createVaultServerWallet, + setVaultProjectWallet, +} from "@/actions/vault"; import type { Project } from "@/api/project/projects"; import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs"; import { updateProjectClient } from "@/hooks/useApi"; @@ -18,7 +21,7 @@ import { getProjectWalletLabel } from "@/lib/project-wallet"; const SERVER_WALLET_ACCESS_TOKEN_PURPOSE = "Access Token for All Server Wallets"; -export const SERVER_WALLET_MANAGEMENT_ACCESS_TOKEN_PURPOSE = +const SERVER_WALLET_MANAGEMENT_ACCESS_TOKEN_PURPOSE = "Management Token for Dashboard"; /** @@ -68,68 +71,6 @@ export async function initVaultClient() { return vc; } -export async function rotateVaultAccountAndAccessToken(props: { - project: Project; - projectSecretKey?: string; - projectSecretHash?: string; -}) { - const vaultClient = await initVaultClient(); - const service = props.project.services.find( - (service) => service.name === "engineCloud", - ); - const storedRotationCode = service?.rotationCode; - if (!storedRotationCode) { - 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: { - auth: { - rotationCode: storedRotationCode, - }, - }, - }); - if (rotateServiceAccountRes.error) { - throw new Error(rotateServiceAccountRes.error.message); - } - const adminKey = rotateServiceAccountRes.data.newAdminKey; - const rotationCode = rotateServiceAccountRes.data.newRotationCode; - - const { managementToken, walletToken } = - await createAndEncryptVaultAccessTokens({ - project: props.project, - projectSecretKey: props.projectSecretKey, - projectSecretHash: props.projectSecretHash, - vaultClient, - adminKey, - rotationCode, - // Skip wallet creation on rotation - preserve the existing project wallet - skipWalletCreation: true, - existingProjectWalletAddress: service?.projectWalletAddress ?? undefined, - }); - - return { - adminKey, - managementToken, - walletToken, - }; -} - export async function createVaultAccountAndAccessToken(props: { project: Project; projectSecretKey?: string; @@ -183,25 +124,47 @@ export async function createVaultAccountAndAccessToken(props: { export async function createProjectServerWallet(props: { project: Project; - managementAccessToken: string; label?: string; setAsProjectWallet?: boolean; }) { - const vaultClient = await initVaultClient(); + const { wallet } = await createVaultServerWallet({ + chainType: "evm", + label: props.label?.trim() || undefined, + project: { projectId: props.project.id, teamId: props.project.teamId }, + setAsProjectWallet: props.setAsProjectWallet, + }); - const walletLabel = props.label?.trim() - ? props.label.trim() - : getProjectWalletLabel(props.project.name); + return wallet; +} +export async function updateDefaultProjectWallet(props: { + project: Project; + projectWalletAddress: string; +}) { + await setVaultProjectWallet({ + address: props.projectWalletAddress, + project: { projectId: props.project.id, teamId: props.project.teamId }, + }); +} + +/** + * 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: vaultClient, + client: props.vaultClient, request: { auth: { accessToken: props.managementAccessToken, }, options: { metadata: { - label: walletLabel, + label: getProjectWalletLabel(props.project.name), projectId: props.project.id, teamId: props.project.teamId, type: "server-wallet", @@ -229,44 +192,9 @@ export async function createProjectServerWallet(props: { console.warn("failed to cache server wallet", err); }); - if (props.setAsProjectWallet) { - await updateDefaultProjectWallet({ - project: props.project, - projectWalletAddress: eoa.data.address, - }); - } - return eoa.data; } -export async function updateDefaultProjectWallet(props: { - project: Project; - projectWalletAddress: string; -}) { - const services = props.project.services.filter( - (service) => service.name !== "engineCloud", - ); - const engineCloudService = props.project.services.find( - (service) => service.name === "engineCloud", - ); - if (engineCloudService) { - const engineCloudServiceWithProjectWallet = { - ...engineCloudService, - projectWalletAddress: props.projectWalletAddress, - }; - - await updateProjectClient( - { - projectId: props.project.id, - teamId: props.project.teamId, - }, - { - services: [...services, engineCloudServiceWithProjectWallet], - }, - ); - } -} - async function createAndEncryptVaultAccessTokens(props: { project: Project; vaultClient: VaultClient; @@ -274,8 +202,6 @@ async function createAndEncryptVaultAccessTokens(props: { projectSecretHash?: string; adminKey: string; rotationCode: string; - skipWalletCreation?: boolean; - existingProjectWalletAddress?: string; }) { const { project, @@ -284,8 +210,6 @@ async function createAndEncryptVaultAccessTokens(props: { vaultClient, adminKey, rotationCode, - skipWalletCreation, - existingProjectWalletAddress, } = props; const [managementTokenResult, walletTokenResult] = await Promise.all([ @@ -334,18 +258,15 @@ async function createAndEncryptVaultAccessTokens(props: { ]); } - // 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; + let projectWalletAddress: string | null | undefined = 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({ + if (!projectWalletAddress) { + const defaultProjectServerWallet = await createDefaultServerWallet({ project, managementAccessToken: managementToken.accessToken, - label: getProjectWalletLabel(project.name), + vaultClient, }); projectWalletAddress = defaultProjectServerWallet.address; } @@ -386,7 +307,7 @@ async function createAndEncryptVaultAccessTokens(props: { }; } -export async function createWalletAccessToken(props: { +async function createWalletAccessToken(props: { project: Project; adminKey: string; vaultClient: VaultClient; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/server-wallets/components/create-server-wallet.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/server-wallets/components/create-server-wallet.client.tsx index 9e72ca15c2e..1d9cd05b8f2 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/server-wallets/components/create-server-wallet.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/server-wallets/components/create-server-wallet.client.tsx @@ -36,16 +36,9 @@ export function CreateServerWallet(props: { ?.managementAccessToken ?? undefined; const createEoaMutation = useMutation({ - mutationFn: async ({ - managementAccessToken, - label, - }: { - managementAccessToken: string; - label: string; - }) => { + mutationFn: async ({ label }: { label: string }) => { const wallet = await createProjectServerWallet({ label, - managementAccessToken, project: props.project, setAsProjectWallet: props.setAsProjectWallet, }); @@ -64,10 +57,7 @@ export function CreateServerWallet(props: { if (!managementAccessToken) { router.push(`/team/${props.teamSlug}/${props.project.slug}/vault`); } else { - await createEoaMutation.mutateAsync({ - label, - managementAccessToken: managementAccessToken, - }); + await createEoaMutation.mutateAsync({ label }); } }; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/solana-wallets/components/create-solana-wallet.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/solana-wallets/components/create-solana-wallet.client.tsx index 01ef9b6118c..9ccd064f7ed 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/solana-wallets/components/create-solana-wallet.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/solana-wallets/components/create-solana-wallet.client.tsx @@ -39,7 +39,6 @@ export function CreateSolanaWallet(props: { } const result = await createSolanaAccount({ - managementAccessToken: managementAccessToken, label: label.trim(), projectId: props.project.id, teamId: props.project.teamId, diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/solana-wallets/lib/vault.client.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/solana-wallets/lib/vault.client.ts index c493bd58219..37876061656 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/solana-wallets/lib/vault.client.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/solana-wallets/lib/vault.client.ts @@ -1,116 +1,53 @@ "use server"; import { - createVaultClient, - createSolanaAccount as vaultCreateSolanaAccount, - listSolanaAccounts as vaultListSolanaAccounts, -} from "@thirdweb-dev/vault-sdk"; -import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs"; + createVaultServerWallet, + listVaultServerWallets, +} from "@/actions/vault"; import type { SolanaWallet } from "../wallet-table/types"; -interface VaultSolanaAccountListItem { - id: string; - pubkey: string; - createdAt: string; - updatedAt: string; - metadata: { - projectId?: string; - type?: string; - label?: string; - } | null; -} - interface SolanaAccountResponse { pubkey: string; createdAt: string; updatedAt: string; } -export async function listSolanaAccounts(params: { - managementAccessToken: string; - page?: number; - limit?: number; - projectId?: string; -}): Promise<{ - data: { - items: SolanaWallet[]; - totalRecords: number; - }; +export type SolanaWalletList = { + data: { items: SolanaWallet[]; totalRecords: number }; error: Error | null; success: boolean; -}> { - const { managementAccessToken, page = 1, limit = 100, projectId } = params; +}; - if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) { - return { - data: { - items: [], - totalRecords: 0, - }, - error: new Error("Missing managementAccessToken or vault URL"), - success: false, - }; - } +export async function listSolanaAccounts(params: { + teamId: string; + projectId: string; + page?: number; + limit?: number; +}): Promise { + const { teamId, projectId, page = 1, limit = 100 } = params; try { - const vaultClient = await createVaultClient({ - baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL, - }); - - const response = await vaultListSolanaAccounts({ - client: vaultClient, - request: { - auth: { - accessToken: managementAccessToken, - }, - options: { - page: page - 1, // Vault SDK uses 0-based pagination - pageSize: limit, - }, - }, + const result = await listVaultServerWallets({ + chainType: "solana", + page, + pageSize: limit, + project: { projectId, teamId }, }); - if (!response.success || !response.data) { - return { - data: { - items: [], - totalRecords: 0, - }, - error: response.error - ? new Error(JSON.stringify(response.error)) - : new Error("Failed to fetch Solana accounts"), - success: false, - }; - } - - const items = (response.data.items || []) as VaultSolanaAccountListItem[]; - - // Filter by projectId and type, transform to SolanaWallet type - const wallets: SolanaWallet[] = items - .filter((item) => { - // Filter by projectId - if (projectId && item.metadata?.projectId !== projectId) { - return false; - } - // Only include server-wallet type - return !item.metadata?.type || item.metadata.type === "server-wallet"; - }) - .map((item) => ({ - id: item.id, - publicKey: item.pubkey, - metadata: { - type: "server-wallet", - projectId: item.metadata?.projectId || projectId || "", - label: item.metadata?.label, - }, - createdAt: item.createdAt, - updatedAt: item.updatedAt, - })); - return { data: { - items: wallets, - totalRecords: wallets.length, // Use filtered count since we're filtering by projectId + items: result.wallets.map((wallet) => ({ + createdAt: wallet.createdAt, + id: wallet.address, + metadata: { + label: wallet.label ?? undefined, + projectId, + type: "server-wallet", + }, + publicKey: wallet.address, + updatedAt: wallet.updatedAt, + })), + totalRecords: result.totalRecords, }, error: null, success: true, @@ -129,62 +66,28 @@ export async function listSolanaAccounts(params: { } export async function createSolanaAccount(params: { - managementAccessToken: string; - label: string; - projectId: string; teamId: string; + projectId: string; + label: string; }): Promise<{ data: SolanaAccountResponse | null; error: Error | null; success: boolean; }> { - const { managementAccessToken, label, projectId, teamId } = params; - - if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) { - return { - data: null, - error: new Error("Missing managementAccessToken or vault URL"), - success: false, - }; - } + const { teamId, projectId, label } = params; try { - const vaultClient = await createVaultClient({ - baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL, - }); - - const response = await vaultCreateSolanaAccount({ - client: vaultClient, - request: { - auth: { - accessToken: managementAccessToken, - }, - options: { - metadata: { - label, - projectId, - teamId, - type: "server-wallet", - }, - }, - }, + const { wallet } = await createVaultServerWallet({ + chainType: "solana", + label, + project: { projectId, teamId }, }); - if (!response.success || !response.data) { - return { - data: null, - error: response.error - ? new Error(JSON.stringify(response.error)) - : new Error("Failed to create Solana account"), - success: false, - }; - } - return { data: { - pubkey: response.data.pubkey, - createdAt: response.data.createdAt, - updatedAt: response.data.updatedAt, + createdAt: wallet.createdAt, + pubkey: wallet.address, + updatedAt: wallet.updatedAt, }, error: null, success: true, diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/list-access-tokens.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/list-access-tokens.client.tsx index 9248bf04452..e474c726f71 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/list-access-tokens.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/list-access-tokens.client.tsx @@ -1,9 +1,14 @@ "use client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { listAccessTokens, revokeAccessToken } from "@thirdweb-dev/vault-sdk"; import { Loader2Icon, LockIcon, Trash2Icon } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; +import { + createVaultAccessToken, + listVaultAccessTokens, + revokeVaultAccessToken, + type VaultCredentials, +} from "@/actions/vault"; import type { Project } from "@/api/project/projects"; import { Button } from "@/components/ui/button"; import { CopyTextButton } from "@/components/ui/CopyTextButton"; @@ -16,39 +21,36 @@ import { import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; import { toDateTimeLocal } from "@/utils/date-utils"; -import { - createWalletAccessToken, - initVaultClient, - SERVER_WALLET_MANAGEMENT_ACCESS_TOKEN_PURPOSE, -} from "../../transactions/lib/vault.client"; export default function ListAccessTokens(props: { project: Project }) { const [modalOpen, setModalOpen] = useState(false); - const [typedAdminKey, setTypedAdminKey] = useState(""); - const [adminKey, setAdminKey] = useState(""); + const [typedUnlockKey, setTypedUnlockKey] = useState(""); + const [unlockKey, setUnlockKey] = useState(""); const [deletingTokenId, setDeletingTokenId] = useState(null); const queryClient = useQueryClient(); - // TODO allow passing permissions to the access token - const createAccessTokenMutation = useMutation({ - mutationFn: async (args: { adminKey: string }) => { - const vaultClient = await initVaultClient(); + const engineCloudService = props.project.services.find( + (s) => s.name === "engineCloud", + ); + const managementAccessToken = engineCloudService?.managementAccessToken; + const isManagedVault = !!engineCloudService?.encryptedAdminKey; - const userAccessTokenRes = await createWalletAccessToken({ - adminKey: args.adminKey, - project: props.project, - vaultClient, - }); + const projectRef = { + projectId: props.project.id, + teamId: props.project.teamId, + }; - if (!userAccessTokenRes.success) { - throw new Error( - `Failed to create access token: ${userAccessTokenRes.error.message}`, - ); - } + const credentials: VaultCredentials = isManagedVault + ? { projectSecretKey: unlockKey || undefined } + : { adminKey: unlockKey || undefined }; - return { - userAccessToken: userAccessTokenRes.data, - }; + // TODO allow passing permissions to the access token + const createAccessTokenMutation = useMutation({ + mutationFn: async () => { + return createVaultAccessToken({ + credentials, + project: projectRef, + }); }, onError: (error) => { toast.error(error.message); @@ -61,31 +63,13 @@ export default function ListAccessTokens(props: { project: Project }) { }); const revokeAccessTokenMutation = useMutation({ - mutationFn: async (args: { adminKey: string; accessTokenId: string }) => { + mutationFn: async (args: { accessTokenId: string }) => { setDeletingTokenId(args.accessTokenId); - const vaultClient = await initVaultClient(); - - const revokeAccessTokenRes = await revokeAccessToken({ - client: vaultClient, - request: { - auth: { - adminKey: args.adminKey, - }, - options: { - id: args.accessTokenId, - }, - }, + return revokeVaultAccessToken({ + accessTokenId: args.accessTokenId, + credentials, + project: projectRef, }); - - if (!revokeAccessTokenRes.success) { - throw new Error( - `Failed to revoke access token: ${revokeAccessTokenRes.error.message}`, - ); - } - - return { - success: true, - }; }, onError: (error) => { toast.error(error.message); @@ -99,52 +83,15 @@ export default function ListAccessTokens(props: { project: Project }) { }, }); - const managementAccessToken = props.project.services.find( - (s) => s.name === "engineCloud", - )?.managementAccessToken; - const listAccessTokensQuery = useQuery({ enabled: !!managementAccessToken, queryFn: async () => { - if (!managementAccessToken) { - throw new Error("Management access token not found"); - } - const vaultClient = await initVaultClient(); - const listResult = await listAccessTokens({ - client: vaultClient, - request: { - auth: adminKey - ? { - adminKey, - } - : { - accessToken: managementAccessToken, - }, - options: {}, - }, + return listVaultAccessTokens({ + credentials, + project: projectRef, }); - - if (!listResult.success) { - throw new Error( - `Failed to list access tokens: ${listResult.error.message}`, - ); - } - return { - accessTokens: listResult.data.items - .filter( - (t) => - t.metadata?.purpose?.toString() !== - SERVER_WALLET_MANAGEMENT_ACCESS_TOKEN_PURPOSE, - ) - .filter((t) => !t.revokedAt && !t.isRotated), - }; - // Return stub data for now }, - queryKey: [ - "list-access-tokens", - maskSecret(managementAccessToken || ""), - maskSecret(adminKey), - ], + queryKey: ["list-access-tokens", props.project.id, maskSecret(unlockKey)], }); const handleCloseModal = () => { @@ -167,11 +114,11 @@ export default function ListAccessTokens(props: { project: Project }) {
@@ -193,10 +140,12 @@ export default function ListAccessTokens(props: { project: Project }) { ) : listAccessTokensQuery.error ? (

- Failed to list access tokens. Check your admin key and try again. + {isManagedVault + ? "Failed to list access tokens. Check your project secret key and try again." + : "Failed to list access tokens. Check your admin key and try again."}

- ) : listAccessTokensQuery.data ? ( + ) : listAccessTokensQuery.data?.accessTokens.length ? (
@@ -206,19 +155,10 @@ export default function ListAccessTokens(props: { project: Project }) {

- {token.metadata?.purpose || "Unnamed Access Token"} + {token.purpose || "Unnamed Access Token"}

- {token.accessToken.includes("**") ? ( -
-

- {token.accessToken}{" "} - - (unlock vault to reveal the full token) - -

-
- ) : ( + {token.accessToken ? ( + ) : ( +
+

+ {token.maskedAccessToken}{" "} + + (unlock vault to reveal the full token) + +

+
)}
- ) : rotateAdminKeyMutation.data ? ( + ) : rotatedKeys ? (
New Vault Keys @@ -152,10 +157,8 @@ export default function RotateAdminKeyButton(props: {

@@ -172,14 +175,8 @@ export default function RotateAdminKeyButton(props: {

@@ -233,6 +230,36 @@ export default function RotateAdminKeyButton(props: {

+ ) : rotateAdminKeyMutation.data ? ( +
+ + Admin Key Rotated + + +
+
+

+ New Vault Admin Key +

+
+
+ {rotateAdminKeyMutation.data.maskedAdminKey} +
+

+ Your admin key and wallet access token were re-encrypted + with your project secret key. Your backend keeps working + without changes. +

+
+
+
+ +
+ +
+
) : ( <> diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/page.tsx index 94b5b5c5e36..f9f8abc59c8 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/page.tsx @@ -1,16 +1,19 @@ -import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk"; import { redirect } from "next/navigation"; import { getAuthToken } from "@/api/auth-token"; import { getProject } from "@/api/project/projects"; -import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs"; import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; import { getProjectWallet } from "@/lib/server/project-wallet"; import { loginRedirect } from "@/utils/redirects"; import { ProjectWalletSection } from "../../components/project-wallet/project-wallet"; import { TransactionsAnalyticsPageContent } from "../../transactions/analytics/analytics-page"; -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 { + listEvmServerWallets, + type ServerWalletList, +} from "../../transactions/lib/server-wallets"; +import { + listSolanaAccounts, + type SolanaWalletList, +} from "../../transactions/solana-wallets/lib/vault.client"; export const dynamic = "force-dynamic"; @@ -35,21 +38,12 @@ export default async function Page(props: { ); } - const [vaultClient, project] = await Promise.all([ - createVaultClient({ - baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL, - }).catch(() => undefined), - getProject(params.team_slug, params.project_slug), - ]); + const project = await getProject(params.team_slug, params.project_slug); if (!project) { redirect(`/team/${params.team_slug}`); } - if (!vaultClient) { - return
Error: Failed to connect to Vault
; - } - const projectEngineCloudService = project.services.find( (service) => service.name === "engineCloud", ); @@ -62,44 +56,35 @@ export default async function Page(props: { const currentPage = Number.parseInt(searchParams.page ?? "1"); const solanaCurrentPage = Number.parseInt(searchParams.solana_page ?? "1"); - const eoas = managementAccessToken - ? await listEoas({ - client: vaultClient, - request: { - auth: { - accessToken: managementAccessToken, - }, - options: { - page: currentPage - 1, - // @ts-expect-error - TODO: fix this - page_size: pageSize, - }, - }, - }) - : { data: { items: [], totalRecords: 0 }, error: null, success: true }; - - const wallets = (eoas.data?.items as Wallet[] | undefined) ?? []; - - let solanaAccounts: { - data: { items: SolanaWallet[]; totalRecords: number }; - error: Error | null; - success: boolean; + const emptyEvmList: ServerWalletList = { + data: { items: [], totalRecords: 0 }, + error: null, + success: true, + }; + const emptySolanaList: SolanaWalletList = { + data: { items: [], totalRecords: 0 }, + error: null, + success: true, }; - if (managementAccessToken) { - solanaAccounts = await listSolanaAccounts({ - managementAccessToken, - page: solanaCurrentPage, - limit: pageSize, - projectId: project.id, - }); - } else { - solanaAccounts = { - data: { items: [], totalRecords: 0 }, - error: null, - success: true, - }; - } + const [eoas, solanaAccounts] = managementAccessToken + ? await Promise.all([ + listEvmServerWallets({ + limit: pageSize, + page: currentPage, + projectId: project.id, + teamId: project.teamId, + }), + listSolanaAccounts({ + limit: pageSize, + page: solanaCurrentPage, + projectId: project.id, + teamId: project.teamId, + }), + ]) + : [emptyEvmList, emptySolanaList]; + + const wallets = eoas.data.items; const testTxWithWallet = typeof searchParams.testTxWithWallet === "string" 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 6943181fdff..ba522e9d942 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 @@ -1,14 +1,17 @@ -import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk"; import { redirect } from "next/navigation"; import { getAuthToken } from "@/api/auth-token"; import { getProject } from "@/api/project/projects"; -import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs"; import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; import { loginRedirect } from "@/utils/redirects"; import { ServerWalletsTable } from "../../../transactions/components/server-wallets-table.client"; -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 { + listEvmServerWallets, + type ServerWalletList, +} from "../../../transactions/lib/server-wallets"; +import { + listSolanaAccounts, + type SolanaWalletList, +} from "../../../transactions/solana-wallets/lib/vault.client"; import { VaultRecoveryCard } from "./vault-recovery-card.client"; export const dynamic = "force-dynamic"; @@ -32,21 +35,12 @@ export default async function Page(props: { ); } - const [vaultClient, project] = await Promise.all([ - createVaultClient({ - baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL, - }).catch(() => undefined), - getProject(params.team_slug, params.project_slug), - ]); + const project = await getProject(params.team_slug, params.project_slug); if (!project) { redirect(`/team/${params.team_slug}`); } - if (!vaultClient) { - return
Error: Failed to connect to Vault
; - } - const projectEngineCloudService = project.services.find( (service) => service.name === "engineCloud", ); @@ -58,44 +52,35 @@ export default async function Page(props: { const currentPage = Number.parseInt(searchParams.page ?? "1"); const solanaCurrentPage = Number.parseInt(searchParams.solana_page ?? "1"); - const eoas = managementAccessToken - ? await listEoas({ - client: vaultClient, - request: { - auth: { - accessToken: managementAccessToken, - }, - options: { - page: currentPage - 1, - // @ts-expect-error - TODO: fix this - page_size: pageSize, - }, - }, - }) - : { data: { items: [], totalRecords: 0 }, error: null, success: true }; - - const wallets = (eoas.data?.items as Wallet[] | undefined) ?? []; - - let solanaAccounts: { - data: { items: SolanaWallet[]; totalRecords: number }; - error: Error | null; - success: boolean; + const emptyEvmList: ServerWalletList = { + data: { items: [], totalRecords: 0 }, + error: null, + success: true, + }; + const emptySolanaList: SolanaWalletList = { + data: { items: [], totalRecords: 0 }, + error: null, + success: true, }; - if (managementAccessToken) { - solanaAccounts = await listSolanaAccounts({ - managementAccessToken, - page: solanaCurrentPage, - limit: pageSize, - projectId: project.id, - }); - } else { - solanaAccounts = { - data: { items: [], totalRecords: 0 }, - error: null, - success: true, - }; - } + const [eoas, solanaAccounts] = managementAccessToken + ? await Promise.all([ + listEvmServerWallets({ + limit: pageSize, + page: currentPage, + projectId: project.id, + teamId: project.teamId, + }), + listSolanaAccounts({ + limit: pageSize, + page: solanaCurrentPage, + projectId: project.id, + teamId: project.teamId, + }), + ]) + : [emptyEvmList, emptySolanaList]; + + const wallets = eoas.data.items; const isSolanaPermissionError = solanaAccounts.error?.message.includes("AUTH_INSUFFICIENT_SCOPE") ?? false;