diff --git a/.changeset/big-cases-wish.md b/.changeset/big-cases-wish.md new file mode 100644 index 00000000000..36fc7f749a5 --- /dev/null +++ b/.changeset/big-cases-wish.md @@ -0,0 +1,5 @@ +--- +"@thirdweb-dev/vault-sdk": patch +--- + +Introducing vault sdk diff --git a/.gitignore b/.gitignore index fc96d534409..b9bd7c27d98 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,5 @@ packages/*/typedoc/* storybook-static .aider* -tsconfig.tsbuildinfo \ No newline at end of file +tsconfig.tsbuildinfo +.cursor \ No newline at end of file diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example index 04e0f385e60..3f719125270 100644 --- a/apps/dashboard/.env.example +++ b/apps/dashboard/.env.example @@ -104,4 +104,8 @@ ANALYTICS_SERVICE_URL="" NEXT_PUBLIC_NEBULA_URL="" # required for billing parts of the dashboard (team -> settings -> billing / invoices) -STRIPE_SECRET_KEY="" \ No newline at end of file +STRIPE_SECRET_KEY="" + +# required for server wallet management +NEXT_PUBLIC_THIRDWEB_VAULT_URL="" +NEXT_PUBLIC_ENGINE_CLOUD_URL="" \ No newline at end of file diff --git a/apps/dashboard/knip.json b/apps/dashboard/knip.json index 0a12e455fdb..77e776608e1 100644 --- a/apps/dashboard/knip.json +++ b/apps/dashboard/knip.json @@ -12,6 +12,7 @@ "ignoreDependencies": [ "@storybook/blocks", "@thirdweb-dev/service-utils", + "@thirdweb-dev/vault-sdk", "@types/color", "fast-xml-parser" ] diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 00641d23f9c..9aeff918a0a 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -45,11 +45,13 @@ "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-switch": "^1.2.2", "@radix-ui/react-tooltip": "1.2.3", + "@scalar/api-reference-react": "^0.6.19", "@sentry/nextjs": "9.13.0", "@shazow/whatsabi": "0.21.0", "@tanstack/react-query": "5.74.4", "@tanstack/react-table": "^8.21.3", "@thirdweb-dev/service-utils": "workspace:*", + "@thirdweb-dev/vault-sdk": "workspace:*", "@vercel/functions": "2.0.0", "@vercel/og": "^0.6.8", "abitype": "1.0.8", diff --git a/apps/dashboard/src/@/actions/proxies.ts b/apps/dashboard/src/@/actions/proxies.ts index 9e705cbce8b..130e3c1d804 100644 --- a/apps/dashboard/src/@/actions/proxies.ts +++ b/apps/dashboard/src/@/actions/proxies.ts @@ -1,7 +1,7 @@ "use server"; import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken"; -import { API_SERVER_URL } from "../constants/env"; +import { API_SERVER_URL, THIRDWEB_ENGINE_CLOUD_URL } from "../constants/env"; type ProxyActionParams = { pathname: string; @@ -79,6 +79,10 @@ export async function apiServerProxy(params: ProxyActionParams) { return proxy(API_SERVER_URL, params); } +export async function engineCloudProxy(params: ProxyActionParams) { + return proxy(THIRDWEB_ENGINE_CLOUD_URL, params); +} + export async function payServerProxy(params: ProxyActionParams) { return proxy( process.env.NEXT_PUBLIC_PAY_URL diff --git a/apps/dashboard/src/@/components/blocks/SidebarLayout.tsx b/apps/dashboard/src/@/components/blocks/SidebarLayout.tsx index cc5bba9f020..d0b5b0fb23b 100644 --- a/apps/dashboard/src/@/components/blocks/SidebarLayout.tsx +++ b/apps/dashboard/src/@/components/blocks/SidebarLayout.tsx @@ -95,7 +95,7 @@ export function FullWidthSidebarLayout(props: { links={[...contentSidebarLinks, ...(footerSidebarLinks || [])]} /> -
+
{children}
diff --git a/apps/dashboard/src/@/components/blocks/select-with-search.tsx b/apps/dashboard/src/@/components/blocks/select-with-search.tsx index 12b75153c3c..50dcda770e5 100644 --- a/apps/dashboard/src/@/components/blocks/select-with-search.tsx +++ b/apps/dashboard/src/@/components/blocks/select-with-search.tsx @@ -129,7 +129,9 @@ export const SelectWithSearch = React.forwardRef< selectedOption && "text-foreground", )} > - {selectedOption?.label || placeholder} + {renderOption && selectedOption + ? renderOption(selectedOption) + : selectedOption?.label || placeholder} diff --git a/apps/dashboard/src/@/components/ui/button.tsx b/apps/dashboard/src/@/components/ui/button.tsx index d89c7a433b8..ea25f0e8b27 100644 --- a/apps/dashboard/src/@/components/ui/button.tsx +++ b/apps/dashboard/src/@/components/ui/button.tsx @@ -22,7 +22,7 @@ const buttonVariants = cva( link: "text-primary underline-offset-4 hover:underline text-semibold", pink: "border border-nebula-pink-foreground !text-nebula-pink-foreground bg-[hsl(var(--nebula-pink-foreground)/5%)] hover:bg-nebula-pink-foreground/10 dark:!text-foreground dark:bg-nebula-pink-foreground/10 dark:hover:bg-nebula-pink-foreground/20", upsell: - "bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:from-purple-600 hover:to-pink-600 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200", + "bg-green-600 text-white hover:bg-green-700 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200", }, size: { default: "h-10 px-4 py-2", diff --git a/apps/dashboard/src/@/components/ui/tabs.tsx b/apps/dashboard/src/@/components/ui/tabs.tsx index c672d2589e9..c2ff5d947a3 100644 --- a/apps/dashboard/src/@/components/ui/tabs.tsx +++ b/apps/dashboard/src/@/components/ui/tabs.tsx @@ -10,7 +10,7 @@ import { Button } from "./button"; import { ToolTipLabel } from "./tooltip"; export type TabLink = { - name: string; + name: React.ReactNode; href: string; isActive: boolean; isDisabled?: boolean; @@ -43,7 +43,7 @@ export function TabLinks(props: { return ( + + + ); +} + +function CreateServerWalletStep(props: { + project: Project; + teamSlug: string; + managementAccessToken: string | undefined; +}) { + return ( +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/analytics/send-test-tx.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/analytics/send-test-tx.client.tsx new file mode 100644 index 00000000000..f9453587794 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/analytics/send-test-tx.client.tsx @@ -0,0 +1,299 @@ +"use client"; +import { engineCloudProxy } from "@/actions/proxies"; +import type { Project } from "@/api/projects"; +import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useThirdwebClient } from "@/constants/thirdweb.client"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Loader2Icon, LockIcon } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { + arbitrumSepolia, + baseSepolia, + optimismSepolia, + sepolia, +} from "thirdweb/chains"; +import * as z from "zod"; +import { CopyTextButton } from "../../../../../../../../@/components/ui/CopyTextButton"; +import { useTrack } from "../../../../../../../../hooks/analytics/useTrack"; +import type { Wallet } from "../server-wallets/wallet-table/types"; +import { SmartAccountCell } from "../server-wallets/wallet-table/wallet-table-ui.client"; + +const formSchema = z.object({ + accessToken: z.string().min(1, "Access token is required"), + walletIndex: z.string(), + chainId: z.number(), +}); + +type FormValues = z.infer; + +export function SendTestTransaction(props: { + wallets?: Wallet[]; + project: Project; + teamSlug: string; + userAccessToken?: string; + expanded?: boolean; + walletId?: string; +}) { + const thirdwebClient = useThirdwebClient(); + const queryClient = useQueryClient(); + const [hasSentTx, setHasSentTx] = useState(false); + const router = useDashboardRouter(); + const trackEvent = useTrack(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + accessToken: props.userAccessToken ?? "", + walletIndex: + props.wallets && props.walletId + ? props.wallets + .findIndex((w) => w.id === props.walletId) + ?.toString() + .replace("-1", "0") + : "0", + chainId: 84532, + }, + }); + + const selectedWalletIndex = Number(form.watch("walletIndex")); + const selectedWallet = props.wallets?.[selectedWalletIndex]; + + const sendDummyTxMutation = useMutation({ + mutationFn: async (args: { + walletAddress: string; + accessToken: string; + chainId: number; + }) => { + trackEvent({ + category: "engine-cloud", + action: "send_test_tx", + }); + + const response = await engineCloudProxy({ + pathname: "/v1/write/transaction", + method: "POST", + headers: { + "Content-Type": "application/json", + "x-team-id": props.project.teamId, + "x-client-id": props.project.publishableKey, + "x-vault-access-token": args.accessToken, + }, + body: JSON.stringify({ + executionOptions: { + from: args.walletAddress, + chainId: args.chainId.toString(), + }, + params: [ + { + to: args.walletAddress, + value: "0", + }, + ], + }), + }); + + if (!response.ok) { + const errorMsg = response.error ?? "Failed to send transaction"; + throw new Error(errorMsg); + } + + return response.data; + }, + onSuccess: () => { + toast.success("Test transaction sent successfully!"); + }, + onError: (error) => { + toast.error(error.message); + }, + }); + + const isLoading = sendDummyTxMutation.isPending; + + // Early return in render phase + if (!props.wallets || props.wallets.length === 0 || !selectedWallet) { + return null; + } + + const onSubmit = async (data: FormValues) => { + await sendDummyTxMutation.mutateAsync({ + walletAddress: selectedWallet.address, + accessToken: data.accessToken, + chainId: data.chainId, + }); + queryClient.invalidateQueries({ + queryKey: ["transactions", props.project.id], + }); + setHasSentTx(true); + }; + + return ( +
+
+ {props.walletId && ( +

Send a test transaction

+ )} +

+ + {props.userAccessToken + ? "Copy your Vault access token, you'll need it for every HTTP call to Engine." + : "Every wallet action requires your Vault access token."} +

+
+ {/* Responsive container */} +
+
+
+

Vault Access Token

+ {props.userAccessToken ? ( +
+ +

+ This is a project-wide access token to access your server + wallets. You can create more access tokens using your admin + key, with granular scopes and permissions. +

+
+ ) : ( + + )} +
+
+
+
+ {/* Wallet Selector */} +
+
+
+

Server Wallet

+ +
+
+

Network

+ { + form.setValue("chainId", chainId); + }} + /> +
+
+
+
+
+ + {hasSentTx && ( + + )} +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/analytics/summary.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/analytics/summary.tsx new file mode 100644 index 00000000000..145f432f94b --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/analytics/summary.tsx @@ -0,0 +1,96 @@ +import { StatCard } from "components/analytics/stat"; // Assuming correct path +import { ActivityIcon, CoinsIcon } from "lucide-react"; +import { toEther } from "thirdweb/utils"; +import type { TransactionSummaryData } from "../lib/analytics"; + +// Renders the UI based on fetched data or pending state +function TransactionAnalyticsSummaryUI(props: { + data: TransactionSummaryData | undefined; + isPending: boolean; +}) { + // Formatter function specifically for the StatCard prop + // Typed to accept number for StatCard's prop type, but receives the string via `as any` + const parseTotalGasCost = (valueFromCard: string): number => { + // At runtime, valueFromCard is the string passed via `as any` if data exists, + // or potentially the fallback number (like 0) if data doesn't exist. + // We prioritize the actual string from props.data if available. + const weiString = props.data?.totalGasCostWei ?? "0"; + + // Check if the effective value is zero + if (weiString === "0") { + return 0; + } + + try { + // Convert the definitive wei string to BigInt + const weiBigInt = BigInt(weiString); + // Use the imported toEther function + return Number.parseFloat(toEther(weiBigInt)); + } catch (e) { + // Catch potential errors during BigInt conversion or formatting + console.error("Error formatting wei value:", weiString, e); + // Check if the value passed from card was actually the fallback number + if (typeof valueFromCard === "number" && valueFromCard === 0) { + return 0; // If fallback 0 was passed, display 0 + } + } + return 0; + }; + + // NOTE: props.data?.totalGasUnitsUsed is fetched but not currently displayed. + + return ( +
+ + `${v.toFixed(10)} ETH`} + icon={CoinsIcon} + // Pass the formatter that handles the type juggling + isPending={props.isPending} + /> + {/* + // Example of how totalGasUnitsUsed could be added later: + { // Formatter receives string via `as any` + const unitString = props.data?.totalGasUnitsUsed ?? '0'; + if (unitString === '0') return '0'; + try { + return new Intl.NumberFormat("en-US").format(BigInt(unitString)); + } catch { + return "Error"; + } + }} + /> + */} +
+ ); +} + +export function TransactionAnalyticsSummary(props: { + teamId: string; + clientId: string; + initialData: TransactionSummaryData | undefined; +}) { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/analytics/tx-chart/tx-chart-ui.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/analytics/tx-chart/tx-chart-ui.tsx new file mode 100644 index 00000000000..1a18269b723 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/analytics/tx-chart/tx-chart-ui.tsx @@ -0,0 +1,206 @@ +"use client"; + +import type { Project } from "@/api/projects"; +import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton"; +import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart"; +import { Button } from "@/components/ui/button"; +import type { ChartConfig } from "@/components/ui/chart"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { formatDate } from "date-fns"; +import { useAllChainsData } from "hooks/chains/allChains"; +import { formatTickerNumber } from "lib/format-utils"; +import Link from "next/link"; +import { useMemo } from "react"; +import type { TransactionStats } from "types/analytics"; +import type { Wallet } from "../../server-wallets/wallet-table/types"; + +type ChartData = Record & { + time: string; +}; + +// TODO: write a story for this component when its finalized +export function TransactionsChartCardUI(props: { + userOpStats: TransactionStats[]; + isPending: boolean; + project: Project; + wallets: Wallet[]; + teamSlug: string; +}) { + const { userOpStats } = props; + const topChainsToShow = 10; + const chainsStore = useAllChainsData(); + + // TODO - update this if we need to change it + const { chartConfig, chartData } = useMemo(() => { + const _chartConfig: ChartConfig = {}; + const _chartDataMap: Map = new Map(); + const chainIdToVolumeMap: Map = new Map(); + // for each stat, add it in _chartDataMap + for (const stat of userOpStats) { + const chartData = _chartDataMap.get(stat.date); + const { chainId } = stat; + const chain = chainsStore.idToChain.get(Number(chainId)); + + const chainName = chain?.name || chainId.toString() || "Unknown"; + // if no data for current day - create new entry + if (!chartData) { + _chartDataMap.set(stat.date, { + time: stat.date, + [chainName]: stat.count, + } as ChartData); + } else { + chartData[chainName] = (chartData[chainName] || 0) + stat.count; + } + + chainIdToVolumeMap.set( + chainName, + stat.count + (chainIdToVolumeMap.get(chainName) || 0), + ); + } + + const chainsSorted = Array.from(chainIdToVolumeMap.entries()) + .sort((a, b) => b[1] - a[1]) + .map((w) => w[0]); + + const chainsToShow = chainsSorted.slice(0, topChainsToShow); + const chainsToTagAsOthers = chainsSorted.slice(topChainsToShow); + + // replace chainIdsToTagAsOther chainId with "other" + for (const data of _chartDataMap.values()) { + for (const chainId in data) { + if (chainsToTagAsOthers.includes(chainId)) { + data.others = (data.others || 0) + (data[chainId] || 0); + delete data[chainId]; + } + } + } + + chainsToShow.forEach((chainName, i) => { + _chartConfig[chainName] = { + label: chainName, + color: `hsl(var(--chart-${(i % 10) + 1}))`, + }; + }); + + // Add Other + chainsToShow.push("others"); + _chartConfig.others = { + label: "Others", + color: "hsl(var(--muted-foreground))", + }; + + return { + chartData: Array.from(_chartDataMap.values()), + chartConfig: _chartConfig, + }; + }, [userOpStats, chainsStore]); + + const uniqueChainIds = Object.keys(chartConfig); + const disableActions = + props.isPending || + chartData.length === 0 || + chartData.every((data) => data.transactions === 0); + + return ( + +

+ Daily Transactions +

+

+ Amount of daily transactions by chain. +

+ +
+ { + const header = ["Date", ...uniqueChainIds]; + const rows = chartData.map((data) => { + const { time, ...rest } = data; + return [ + time, + ...uniqueChainIds.map((w) => (rest[w] || 0).toString()), + ]; + }); + return { header, rows }; + }} + /> +
+
+ } + config={chartConfig} + data={chartData} + isPending={props.isPending} + chartClassName="aspect-[1.5] lg:aspect-[3.5]" + showLegend + hideLabel={false} + toolTipLabelFormatter={(_v, item) => { + if (Array.isArray(item)) { + const time = item[0].payload.time as number; + return formatDate(new Date(time), "MMM d, yyyy"); + } + return undefined; + }} + toolTipValueFormatter={(value) => formatTickerNumber(Number(value))} + emptyChartState={ + + } + /> + ); +} + +// TODO - update the title and doc links +function EmptyChartContent(props: { + project: Project; + teamSlug: string; + wallets: Wallet[]; +}) { + const router = useDashboardRouter(); + return ( +
+ {props.wallets.length === 0 ? ( + <> + + Engine requires a{" "} + + Vault admin account + + . Create one to get started. + +
+ +
+ + ) : ( +

+ + + + + Waiting for transactions... +

+ )} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/analytics/tx-chart/tx-chart.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/analytics/tx-chart/tx-chart.tsx new file mode 100644 index 00000000000..fe78f41c005 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/analytics/tx-chart/tx-chart.tsx @@ -0,0 +1,73 @@ +import type { Project } from "@/api/projects"; +import { ResponsiveSuspense } from "responsive-rsc"; +import { getTransactionsChart } from "../../lib/analytics"; +import { getTxAnalyticsFiltersFromSearchParams } from "../../lib/utils"; +import type { Wallet } from "../../server-wallets/wallet-table/types"; +import { TransactionsChartCardUI } from "./tx-chart-ui"; + +async function AsyncTransactionsChartCard(props: { + from: string; + to: string; + interval: "day" | "week"; + project: Project; + wallets: Wallet[]; + teamSlug: string; +}) { + const data = await getTransactionsChart({ + clientId: props.project.publishableKey, + teamId: props.project.teamId, + from: props.from, + to: props.to, + interval: props.interval, + }); + + return ( + + ); +} + +export function TransactionsChartCard(props: { + searchParams: { + from?: string | undefined | string[]; + to?: string | undefined | string[]; + interval?: string | undefined | string[]; + }; + project: Project; + wallets: Wallet[]; + teamSlug: string; +}) { + const { range, interval } = getTxAnalyticsFiltersFromSearchParams( + props.searchParams, + ); + + return ( + + } + > + + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/analytics/tx-table/tx-table-ui.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/analytics/tx-table/tx-table-ui.tsx new file mode 100644 index 00000000000..7ca6fc2a8e0 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/analytics/tx-table/tx-table-ui.tsx @@ -0,0 +1,414 @@ +"use client"; + +import type { Project } from "@/api/projects"; +import { WalletAddress } from "@/components/blocks/wallet-address"; +import { PaginationButtons } from "@/components/pagination-buttons"; +import { CopyAddressButton } from "@/components/ui/CopyAddressButton"; +import { CopyTextButton } from "@/components/ui/CopyTextButton"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { ToolTipLabel } from "@/components/ui/tooltip"; +import { useThirdwebClient } from "@/constants/thirdweb.client"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { formatDistanceToNowStrict } from "date-fns"; +import { format } from "date-fns/format"; +import { useAllChainsData } from "hooks/chains/allChains"; +import { ExternalLinkIcon, InfoIcon } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import { ChainIconClient } from "../../../../../../../../../components/icons/ChainIcon"; +import type { Wallet } from "../../server-wallets/wallet-table/types"; +import type { + Transaction, + TransactionStatus, + TransactionsResponse, +} from "./types"; + +// TODO - add Status selector dropdown here +export function TransactionsTableUI(props: { + getData: (params: { page: number }) => Promise; + project: Project; + teamSlug: string; + wallets?: Wallet[]; +}) { + const router = useDashboardRouter(); + const [autoUpdate, setAutoUpdate] = useState(true); + const [status, setStatus] = useState( + undefined, + ); + const [page, setPage] = useState(1); + + const pageSize = 10; + const transactionsQuery = useQuery({ + queryKey: ["transactions", props.project.id, page], + queryFn: () => props.getData({ page }), + refetchInterval: autoUpdate ? 4_000 : false, + placeholderData: keepPreviousData, + enabled: !!props.wallets && props.wallets.length > 0, + }); + + const transactions = transactionsQuery.data?.transactions ?? []; + + const totalCount = transactionsQuery.data?.pagination.totalCount ?? 0; + const totalPages = Math.ceil(totalCount / pageSize); + const showPagination = totalCount > pageSize; + + const showSkeleton = + (transactionsQuery.isPlaceholderData && transactionsQuery.isFetching) || + (transactionsQuery.isLoading && !transactionsQuery.isPlaceholderData); + + return ( +
+
+
+

+ Transaction History +

+

+ Transactions sent from server wallets +

+
+ +
+
+ + setAutoUpdate(!!v)} + id="auto-update" + /> +
+ { + setStatus(v); + // reset page + setPage(1); + }} + /> +
+
+ + + + + + Queue ID + Chain + Status + From + Tx Hash + Queued + + + + {showSkeleton ? ( + <> + {new Array(pageSize).fill(0).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: + + ))} + + ) : ( + <> + {transactions.map((tx) => ( + { + router.push( + `/team/${props.teamSlug}/${props.project.slug}/engine/cloud/tx/${tx.id}`, + ); + }} + > + {/* Queue ID */} + + + + + {/* Chain Id */} + + + + + {/* Status */} + + + + + {/* From Address */} + + {tx.from ? : "N/A"} + + + {/* Tx Hash */} + + + + + {/* Queued At */} + + + + + ))} + + )} + +
+ + {!showSkeleton && transactions.length === 0 && ( +
+ No transactions found +
+ )} +
+ + {showPagination && ( +
+ +
+ )} +
+ ); +} + +export const statusDetails = { + QUEUED: { + name: "Queued", + type: "warning", + }, + SUBMITTED: { + name: "Submitted", + type: "warning", + }, + CONFIRMED: { + name: "Confirmed", + type: "success", + }, + REVERTED: { + name: "Reverted", + type: "destructive", + }, + FAILED: { + name: "Failed", + type: "destructive", + }, +} as const; + +function StatusSelector(props: { + status: TransactionStatus | undefined; + setStatus: (value: TransactionStatus | undefined) => void; +}) { + const statuses = Object.keys(statusDetails) as TransactionStatus[]; + + return ( + + ); +} + +function SkeletonRow() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +function TxChainCell(props: { chainId: string | undefined }) { + const { chainId } = props; + const { idToChain } = useAllChainsData(); + const thirdwebClient = useThirdwebClient(); + if (!chainId) { + return "N/A"; + } + + const chain = idToChain.get(Number.parseInt(chainId)); + + if (!chain) { + return `Chain ID: ${chainId}`; + } + + return ( +
+ +
+ {chain.name ?? `Chain ID: ${chainId}`} +
+
+ ); +} + +function TxStatusCell(props: { transaction: Transaction }) { + const { transaction } = props; + const { errorMessage } = transaction; + const minedAt = transaction.confirmedAt; + const status = + (transaction.executionResult?.status as TransactionStatus) ?? null; + + const onchainStatus = + transaction.executionResult && + "onchainStatus" in transaction.executionResult + ? transaction.executionResult.onchainStatus + : null; + + if (!status) { + return null; + } + + const tooltip = + onchainStatus !== "REVERTED" + ? errorMessage + : status === "CONFIRMED" && minedAt + ? `Completed ${format(new Date(minedAt), "PP pp")}` + : undefined; + + return ( + + + {statusDetails[status].name} + {errorMessage && } + + + ); +} + +function TxHashCell(props: { transaction: Transaction }) { + const { idToChain } = useAllChainsData(); + const { chainId, transactionHash } = props.transaction; + if (!transactionHash) { + return "N/A"; + } + + const chain = chainId ? idToChain.get(Number.parseInt(chainId)) : undefined; + const explorer = chain?.explorers?.[0]; + + const shortHash = `${transactionHash.slice(0, 6)}...${transactionHash.slice(-4)}`; + if (!explorer) { + return ( + + ); + } + + return ( + + ); +} + +function TxQueuedAtCell(props: { transaction: Transaction }) { + const value = props.transaction.createdAt; + if (!value) { + return; + } + + const date = new Date(value); + return ( + +

{formatDistanceToNowStrict(date, { addSuffix: true })}

+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/analytics/tx-table/tx-table.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/analytics/tx-table/tx-table.tsx new file mode 100644 index 00000000000..c19c4addab5 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/analytics/tx-table/tx-table.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { engineCloudProxy } from "@/actions/proxies"; +import type { Project } from "@/api/projects"; +import type { Wallet } from "../../server-wallets/wallet-table/types"; +import { TransactionsTableUI } from "./tx-table-ui"; +import type { TransactionsResponse } from "./types"; + +export function TransactionsTable(props: { + project: Project; + wallets?: Wallet[]; + teamSlug: string; +}) { + return ( + { + return await getTransactions({ + project: props.project, + page, + }); + }} + project={props.project} + wallets={props.wallets} + teamSlug={props.teamSlug} + /> + ); +} + +async function getTransactions({ + project, + page, +}: { + project: Project; + page: number; +}) { + const transactions = await engineCloudProxy<{ result: TransactionsResponse }>( + { + pathname: "/v1/transactions/search", + method: "POST", + headers: { + "Content-Type": "application/json", + "x-team-id": project.teamId, + "x-client-id": project.publishableKey, + }, + body: JSON.stringify({ + page, + limit: 20, + }), + }, + ); + + if (!transactions.ok) { + throw new Error(transactions.error); + } + + return transactions.data.result; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/analytics/tx-table/types.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/analytics/tx-table/types.ts new file mode 100644 index 00000000000..66552766fc2 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/analytics/tx-table/types.ts @@ -0,0 +1,93 @@ +import type { Address, Hex } from "thirdweb"; + +// Revert data type (referenced but not defined in the provided code) +type RevertDataSerialized = { + revertReason?: string; + decodedError?: { + name: string; + signature: string; + args: string[]; + }; +}; + +// Transaction parameters +type TransactionParamsSerialized = { + to: Address; + data: Hex; + value: string; +}; + +// Execution parameters +type ExecutionParams4337Serialized = { + type: "AA"; + entrypointAddress: string; + smartAccountAddress: string; + signerAddress: string; +}; + +type ExecutionParamsSerialized = ExecutionParams4337Serialized; + +// Execution result +type ExecutionResult4337Serialized = + | { + status: "QUEUED"; + } + | { + status: "SUBMITTED"; + monitoringStatus: "WILL_MONITOR" | "CANNOT_MONITOR"; + userOpHash: string; + } + | ({ + status: "CONFIRMED"; + userOpHash: Hex; + transactionHash: Hex; + actualGasCost: string; + actualGasUsed: string; + nonce: string; + } & ( + | { + onchainStatus: "SUCCESS"; + } + | { + onchainStatus: "REVERTED"; + revertData?: RevertDataSerialized; + } + )); + +type ExecutionResultSerialized = ExecutionResult4337Serialized; + +// Main Transaction type from database +export type Transaction = { + id: string; + batchIndex: number; + chainId: string; + from: Address | null; + transactionParams: TransactionParamsSerialized[]; + transactionHash: Hex | null; + confirmedAt: Date | null; + confirmedAtBlockNumber: string | null; + enrichedData: unknown[]; + executionParams: ExecutionParamsSerialized; + executionResult: ExecutionResultSerialized | null; + createdAt: Date; + errorMessage: string | null; + cancelledAt: Date | null; +}; + +export type TransactionStatus = + | "QUEUED" + | "SUBMITTED" + | "CONFIRMED" + | "REVERTED" + | "FAILED"; + +type Pagination = { + totalCount: number; + page: number; + limit: number; +}; + +export type TransactionsResponse = { + transactions: Transaction[]; + pagination: Pagination; +}; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/explorer/components/scalar.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/explorer/components/scalar.tsx new file mode 100644 index 00000000000..07df6be94e9 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/explorer/components/scalar.tsx @@ -0,0 +1,24 @@ +"use client"; +import { ApiReferenceReact } from "@scalar/api-reference-react"; +import "@scalar/api-reference-react/style.css"; +import { THIRDWEB_ENGINE_CLOUD_URL } from "@/constants/env"; + +export function Scalar() { + return ( +
+

Full API Reference

+ +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/explorer/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/explorer/page.tsx new file mode 100644 index 00000000000..c9fcb32347b --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/explorer/page.tsx @@ -0,0 +1,11 @@ +import { TryItOut } from "../server-wallets/components/try-it-out"; +import { Scalar } from "./components/scalar"; + +export default async function TransactionsExplorerPage() { + return ( +
+ + +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/layout.tsx new file mode 100644 index 00000000000..8d83cccdaa7 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/layout.tsx @@ -0,0 +1,101 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { TabPathLinks } from "@/components/ui/tabs"; +import { THIRDWEB_ENGINE_CLOUD_URL } from "@/constants/env"; +import Link from "next/link"; +import { EngineIcon } from "../../../../../(dashboard)/(chain)/components/server/icons/EngineIcon"; + +export default async function Page(props: { + params: Promise<{ team_slug: string; project_slug: string }>; + children: React.ReactNode; +}) { + const { team_slug, project_slug } = await props.params; + + return ( + + {props.children} + + ); +} + +function TransactionsLayout(props: { + projectSlug: string; + teamSlug: string; + children: React.ReactNode; +}) { + const engineBaseSlug = `/team/${props.teamSlug}/${props.projectSlug}/engine`; + const engineLayoutSlug = `${engineBaseSlug}/cloud`; + + return ( +
+ {/* top */} +
+ {/* header */} +
+
+
+

+ Engine{" "} + + Cloud + + + Beta + +

+
+ + {THIRDWEB_ENGINE_CLOUD_URL} + +
+
+ + + +
+
+ +
+ + {/* Nav */} + +
+
+
+
{props.children}
+
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/lib/analytics.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/lib/analytics.ts new file mode 100644 index 00000000000..0362be6fadc --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/lib/analytics.ts @@ -0,0 +1,202 @@ +import { THIRDWEB_ENGINE_CLOUD_URL } from "@/constants/env"; +import type { TransactionStats } from "../../../../../../../../types/analytics"; +import { getAuthToken } from "../../../../../../api/lib/getAuthToken"; +import type { + Transaction, + TransactionsResponse, +} from "../analytics/tx-table/types"; + +// Define the structure of the data we expect back from our fetch function +export type TransactionSummaryData = { + totalCount: number; + totalGasCostWei: string; + totalGasUnitsUsed: string; // Keep fetched data structure +}; + +// Define the structure of the API response +type AnalyticsSummaryApiResponse = { + result: { + summary: { + totalCount: number; + totalGasCostWei: string; + totalGasUnitsUsed: string; + }; + metadata: { + startDate?: string; + endDate?: string; + }; + }; +}; + +// Fetches data from the /analytics-summary endpoint +export async function getTransactionAnalyticsSummary(props: { + teamId: string; + clientId: string; +}): Promise { + const authToken = await getAuthToken(); + const body = {}; + const defaultData: TransactionSummaryData = { + totalCount: 0, + totalGasCostWei: "0", + totalGasUnitsUsed: "0", + }; + + try { + const response = await fetch( + `${THIRDWEB_ENGINE_CLOUD_URL}/v1/transactions/analytics-summary`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-team-id": props.teamId, + "x-client-id": props.clientId, + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify(body), + }, + ); + + if (!response.ok) { + if (response.status === 401 || response.status === 400) { + console.error("Unauthorized fetching transaction summary"); + return defaultData; + } + const errorText = await response.text(); + throw new Error( + `Error fetching transaction summary: ${response.status} ${response.statusText} - ${errorText}`, + ); + } + + const data = (await response.json()) as AnalyticsSummaryApiResponse; + + return { + totalCount: data.result.summary.totalCount ?? 0, + totalGasCostWei: data.result.summary.totalGasCostWei ?? "0", + totalGasUnitsUsed: data.result.summary.totalGasUnitsUsed ?? "0", + }; + } catch (error) { + console.error("Failed to fetch transaction summary:", error); + return defaultData; + } +} + +export async function getTransactionsChart({ + teamId, + clientId, + from, + to, + interval, +}: { + teamId: string; + clientId: string; + from: string; + to: string; + interval: "day" | "week"; +}): Promise { + const authToken = await getAuthToken(); + + const filters = { + startDate: from, + endDate: to, + resolution: interval, + }; + + const response = await fetch( + `${THIRDWEB_ENGINE_CLOUD_URL}/v1/transactions/analytics`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-team-id": teamId, + "x-client-id": clientId, + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify(filters), + }, + ); + + if (!response.ok) { + if (response.status === 401 || response.status === 400) { + return []; + } + + // TODO - need to handle this error state, like we do with the connect charts + throw new Error( + `Error fetching transactions chart data: ${response.status} ${response.statusText} - ${await response.text().catch(() => "Unknown error")}`, + ); + } + + type TransactionsChartResponse = { + result: { + analytics: Array<{ + timeBucket: string; + chainId: string; + count: number; + }>; + metadata: { + resolution: string; + startDate: string; + endDate: string; + }; + }; + }; + + const data = (await response.json()) as TransactionsChartResponse; + + return data.result.analytics.map((stat) => ({ + date: stat.timeBucket, + chainId: Number(stat.chainId), + count: stat.count, + })); +} + +export async function getSingleTransaction({ + teamId, + clientId, + transactionId, +}: { + teamId: string; + clientId: string; + transactionId: string; +}): Promise { + const authToken = await getAuthToken(); + + const filters = { + filters: [ + { + field: "id", + values: [transactionId], + operation: "OR", + }, + ], + }; + + const response = await fetch( + `${THIRDWEB_ENGINE_CLOUD_URL}/v1/transactions/search`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-team-id": teamId, + "x-client-id": clientId, + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify(filters), + }, + ); + + if (!response.ok) { + if (response.status === 401) { + return undefined; + } + + // TODO - need to handle this error state, like we do with the connect charts + throw new Error( + `Error fetching single transaction data: ${response.status} ${response.statusText} - ${await response.text().catch(() => "Unknown error")}`, + ); + } + + const data = (await response.json()).result as TransactionsResponse; + + return data.transactions[0]; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/lib/utils.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/lib/utils.ts new file mode 100644 index 00000000000..6b6d9c0adb2 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/lib/utils.ts @@ -0,0 +1,14 @@ +import { getFiltersFromSearchParams } from "@/lib/time"; + +export function getTxAnalyticsFiltersFromSearchParams(params: { + from?: string | undefined | string[]; + to?: string | undefined | string[]; + interval?: string | undefined | string[]; +}) { + return getFiltersFromSearchParams({ + from: params.from, + to: params.to, + interval: params.interval, + defaultRange: "last-30", + }); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/lib/vault.client.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/lib/vault.client.ts new file mode 100644 index 00000000000..4c40deb0ca5 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/lib/vault.client.ts @@ -0,0 +1,317 @@ +"use client"; + +import type { Project } from "@/api/projects"; +import { THIRDWEB_VAULT_URL } from "@/constants/env"; +import { + type VaultClient, + createAccessToken, + createVaultClient, +} from "@thirdweb-dev/vault-sdk"; +import { updateProjectClient } from "../../../../../../../../@3rdweb-sdk/react/hooks/useApi"; + +const SERVER_WALLET_ACCESS_TOKEN_PURPOSE = + "Access Token for All Server Wallets"; + +export const SERVER_WALLET_MANAGEMENT_ACCESS_TOKEN_PURPOSE = + "Management Token for Dashboard"; + +let vc: VaultClient | null = null; + +export async function initVaultClient() { + if (vc) { + return vc; + } + vc = await createVaultClient({ + baseUrl: THIRDWEB_VAULT_URL, + }); + return vc; +} + +export async function createWalletAccessToken(props: { + project: Project; + adminKey: string; + vaultClient: VaultClient; +}) { + return createAccessToken({ + client: props.vaultClient, + request: { + options: { + expiresAt: new Date( + Date.now() + 60 * 60 * 1000 * 24 * 365 * 1000, + ).toISOString(), // 100 years from now + policies: [ + { + type: "eoa:read", + metadataPatterns: [ + { + key: "projectId", + rule: { + pattern: props.project.id, + }, + }, + { + key: "teamId", + rule: { + pattern: props.project.teamId, + }, + }, + { + key: "type", + rule: { + pattern: "server-wallet", + }, + }, + ], + }, + { + type: "eoa:create", + requiredMetadataPatterns: [ + { + key: "projectId", + rule: { + pattern: props.project.id, + }, + }, + { + key: "teamId", + rule: { + pattern: props.project.teamId, + }, + }, + { + key: "type", + rule: { + pattern: "server-wallet", + }, + }, + ], + }, + { + type: "eoa:signMessage", + metadataPatterns: [ + { + key: "projectId", + rule: { + pattern: props.project.id, + }, + }, + { + key: "teamId", + rule: { + pattern: props.project.teamId, + }, + }, + { + key: "type", + rule: { + pattern: "server-wallet", + }, + }, + ], + }, + { + type: "eoa:signTransaction", + payloadPatterns: {}, + metadataPatterns: [ + { + key: "projectId", + rule: { + pattern: props.project.id, + }, + }, + { + key: "teamId", + rule: { + pattern: props.project.teamId, + }, + }, + { + key: "type", + rule: { + pattern: "server-wallet", + }, + }, + ], + }, + { + type: "eoa:signTypedData", + metadataPatterns: [ + { + key: "projectId", + rule: { + pattern: props.project.id, + }, + }, + { + key: "teamId", + rule: { + pattern: props.project.teamId, + }, + }, + { + key: "type", + rule: { + pattern: "server-wallet", + }, + }, + ], + }, + { + type: "eoa:signStructuredMessage", + structuredPatterns: { + useropV06: {}, + useropV07: {}, + }, + metadataPatterns: [ + { + key: "projectId", + rule: { + pattern: props.project.id, + }, + }, + { + key: "teamId", + rule: { + pattern: props.project.teamId, + }, + }, + { + key: "type", + rule: { + pattern: "server-wallet", + }, + }, + ], + }, + ], + metadata: { + projectId: props.project.id, + teamId: props.project.teamId, + purpose: SERVER_WALLET_ACCESS_TOKEN_PURPOSE, + }, + }, + auth: { + adminKey: props.adminKey, + }, + }, + }); +} + +export async function createManagementAccessToken(props: { + project: Project; + adminKey: string; + rotationCode: string; + vaultClient: VaultClient; +}) { + const res = await createAccessToken({ + client: props.vaultClient, + request: { + options: { + expiresAt: new Date( + Date.now() + 60 * 60 * 1000 * 24 * 365 * 1000, + ).toISOString(), // 100 years from now + policies: [ + { + type: "eoa:read", + metadataPatterns: [ + { + key: "projectId", + rule: { + pattern: props.project.id, + }, + }, + { + key: "teamId", + rule: { + pattern: props.project.teamId, + }, + }, + { + key: "type", + rule: { + pattern: "server-wallet", + }, + }, + ], + }, + { + type: "eoa:create", + requiredMetadataPatterns: [ + { + key: "projectId", + rule: { + pattern: props.project.id, + }, + }, + { + key: "teamId", + rule: { + pattern: props.project.teamId, + }, + }, + { + key: "type", + rule: { + pattern: "server-wallet", + }, + }, + ], + }, + { + type: "accessToken:read", + metadataPatterns: [ + { + key: "projectId", + rule: { + pattern: props.project.id, + }, + }, + { + key: "teamId", + rule: { + pattern: props.project.teamId, + }, + }, + ], + revealSensitive: false, + }, + ], + metadata: { + projectId: props.project.id, + teamId: props.project.teamId, + purpose: SERVER_WALLET_MANAGEMENT_ACCESS_TOKEN_PURPOSE, + }, + }, + auth: { + adminKey: props.adminKey, + }, + }, + }); + if (res.success) { + const data = res.data; + // store the management access token in the project + await updateProjectClient( + { + projectId: props.project.id, + teamId: props.project.teamId, + }, + { + services: [ + ...props.project.services, + { + name: "engineCloud", + managementAccessToken: data.accessToken, + maskedAdminKey: maskSecret(props.adminKey), + rotationCode: props.rotationCode, + actions: [], + }, + ], + }, + ); + } + return res; +} + +export function maskSecret(secret: string) { + return `${secret.substring(0, 11)}...${secret.substring(secret.length - 5)}`; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/page.tsx new file mode 100644 index 00000000000..fb9877f228e --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/page.tsx @@ -0,0 +1,107 @@ +import { getProject } from "@/api/projects"; +import { THIRDWEB_VAULT_URL } from "@/constants/env"; +import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk"; +import { notFound, redirect } from "next/navigation"; +import { getAuthToken } from "../../../../../api/lib/getAuthToken"; +import { TransactionsAnalyticsPageContent } from "./analytics/analytics-page"; +import { EngineChecklist } from "./analytics/ftux.client"; +import { TransactionAnalyticsSummary } from "./analytics/summary"; +import { + type TransactionSummaryData, + getTransactionAnalyticsSummary, +} from "./lib/analytics"; +import type { Wallet } from "./server-wallets/wallet-table/types"; + +export default async function TransactionsAnalyticsPage(props: { + params: Promise<{ team_slug: string; project_slug: string }>; + searchParams: Promise<{ + from?: string | string[] | undefined; + to?: string | string[] | undefined; + interval?: string | string[] | undefined; + testTxWithWallet?: string | string[] | undefined; + }>; +}) { + const [params, searchParams, authToken] = await Promise.all([ + props.params, + props.searchParams, + getAuthToken(), + ]); + + if (!authToken) { + notFound(); + } + + const [vaultClient, project] = await Promise.all([ + createVaultClient({ + baseUrl: THIRDWEB_VAULT_URL, + }).catch(() => undefined), + 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", + ); + + const managementAccessToken = + projectEngineCloudService?.managementAccessToken; + + const eoas = managementAccessToken + ? await listEoas({ + client: vaultClient, + request: { + auth: { + accessToken: managementAccessToken, + }, + options: {}, + }, + }) + : { data: { items: [] }, error: null, success: true }; + + const wallets = eoas.data?.items as Wallet[] | undefined; + + let initialData: TransactionSummaryData | undefined; + if (wallets && wallets.length > 0) { + const summary = await getTransactionAnalyticsSummary({ + teamId: project.teamId, + clientId: project.publishableKey, + }).catch(() => undefined); + initialData = summary; + } + const hasTransactions = initialData ? initialData.totalCount > 0 : false; + + return ( +
+ + {hasTransactions && !searchParams.testTxWithWallet && ( + + )} +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/server-wallets/components/create-server-wallet.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/server-wallets/components/create-server-wallet.client.tsx new file mode 100644 index 00000000000..6001935c15b --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/server-wallets/components/create-server-wallet.client.tsx @@ -0,0 +1,179 @@ +"use client"; +import type { Project } from "@/api/projects"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { useMutation } from "@tanstack/react-query"; +import { createEoa } from "@thirdweb-dev/vault-sdk"; +import { Loader2Icon, WalletIcon } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { engineCloudProxy } from "../../../../../../../../../@/actions/proxies"; +import { useTrack } from "../../../../../../../../../hooks/analytics/useTrack"; +import { initVaultClient } from "../../lib/vault.client"; + +export default function CreateServerWallet(props: { + project: Project; + teamSlug: string; + managementAccessToken: string | undefined; +}) { + const router = useDashboardRouter(); + const [label, setLabel] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const trackEvent = useTrack(); + + const createEoaMutation = useMutation({ + mutationFn: async ({ + managementAccessToken, + label, + }: { + managementAccessToken: string; + label: string; + }) => { + trackEvent({ + category: "engine-cloud", + action: "create_server_wallet", + }); + + const vaultClient = await initVaultClient(); + + const eoa = await createEoa({ + request: { + options: { + metadata: { + projectId: props.project.id, + teamId: props.project.teamId, + type: "server-wallet", + label, + }, + }, + auth: { + accessToken: managementAccessToken, + }, + }, + client: vaultClient, + }); + + if (!eoa.success) { + throw new Error("Failed to create eoa"); + } + + // no need to await this, it's not blocking + engineCloudProxy({ + pathname: "/cache/smart-account", + method: "POST", + headers: { + "Content-Type": "application/json", + "x-team-id": props.project.teamId, + "x-client-id": props.project.publishableKey, + }, + body: JSON.stringify({ + signerAddress: eoa.data.address, + }), + }).catch((err) => { + console.warn("failed to cache server wallet", err); + }); + + router.refresh(); + setModalOpen(false); + + return eoa; + }, + onError: (error) => { + toast.error(error.message); + }, + }); + + const handleCreateServerWallet = async () => { + if (!props.managementAccessToken) { + router.push( + `/team/${props.teamSlug}/${props.project.slug}/engine/cloud/vault`, + ); + } else { + await createEoaMutation.mutateAsync({ + managementAccessToken: props.managementAccessToken, + label, + }); + } + }; + + const isLoading = createEoaMutation.isPending; + + return ( + <> + + + + + + Create server wallet + + Enter a label for your server wallet. + + +
+
+ setLabel(e.target.value)} + className="w-full" + /> +
+
+
+ + +
+
+
+ + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/server-wallets/components/try-it-out.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/server-wallets/components/try-it-out.tsx new file mode 100644 index 00000000000..c64f0f6789d --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/server-wallets/components/try-it-out.tsx @@ -0,0 +1,412 @@ +"use client"; +import { CodeClient } from "@/components/ui/code/code.client"; +import { TabButtons } from "@/components/ui/tabs"; +import { THIRDWEB_ENGINE_CLOUD_URL } from "@/constants/env"; +import { CircleAlertIcon } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import { + Alert, + AlertDescription, + AlertTitle, +} from "../../../../../../../../../@/components/ui/alert"; + +export function TryItOut() { + const [activeTab, setActiveTab] = useState("sdk"); + + return ( +
+
+
+
+

+ Usage from your backend +

+

+ Send transactions from your server wallets using the thirdweb SDK + or the HTTP API directly. +

+
+
+
+
+ setActiveTab("sdk"), + isActive: activeTab === "sdk", + }, + { + name: "Curl", + onClick: () => setActiveTab("curl"), + isActive: activeTab === "curl", + }, + { + name: "JavaScript", + onClick: () => setActiveTab("js"), + isActive: activeTab === "js", + }, + { + name: "Python", + onClick: () => setActiveTab("python"), + isActive: activeTab === "python", + }, + { + name: "Go", + onClick: () => setActiveTab("go"), + isActive: activeTab === "go", + }, + { + name: "C#", + onClick: () => setActiveTab("csharp"), + isActive: activeTab === "csharp", + }, + ]} + /> + +
+ + {activeTab === "sdk" && ( +
+ + + Using the thirdweb SDK on the backend + +

+ You can use the full TypeScript thirdweb SDK in your backend, + allowing you to use:{" "} +

    +
  • + The full catalog of{" "} + + extension functions + +
  • +
  • + The{" "} + + prepareContractCall + {" "} + function to encode your transactions +
  • +
  • + The full{" "} + + account + {" "} + interface, predefined chains, and more +
  • +
+ The SDK handles encoding your transactions, signing them to + Engine and polling for status. +

+
+
+

+ Installation +

+ +

+ Usage example: Minting a ERC1155 NFT to a user +

+ +
+ )} + {activeTab === "curl" && ( + + )} + {activeTab === "js" && ( +
+

+ A lightweight, type safe wrapper package of the Engine HTTP API is + available on{" "} + + NPM + + . +

+ +
+ )} + {activeTab === "python" && ( + + )} + {activeTab === "go" && ( + + )} + {activeTab === "csharp" && ( + + )} +
+
+ ); +} + +const sdkExample = () => `\ +import { createThirdwebClient, sendTransaction, getContract, Engine } from "thirdweb"; +import { baseSepolia } from "thirdweb/chains"; +import { claimTo } from "thirdweb/extensions/1155"; + +// Create a thirdweb client +const client = createThirdwebClient({ + secretKey: "", +}); + +// Create a server wallet +const serverWallet = Engine.serverWallet({ + client, + address: "", + vaultAccessToken: "", +}); + +// Prepare the transaction +const transaction = claimTo({ + contract: getContract({ + client, + address: "0x...", // Address of the ERC1155 token contract + chain: baseSepolia, // Chain of the ERC1155 token contract + }), + to: "0x...", // The address of the user to mint to + tokenId: 0n, // The token ID of the NFT to mint + quantity: 1n, // The quantity of NFTs to mint +}); + +// Enqueue the transaction via Engine +const { transactionId } = await serverWallet.enqueueTransaction({ + transaction, +}); + +// Get the execution status of the transaction at any point in time +const executionResult = await Engine.getTransactionStatus({ + client, + transactionId, +}); + +// Utility function to poll for the transaction to be submitted onchain +const txHash = await Engine.waitForTransactionHash({ + client, + transactionId, +}); +console.log("Transaction hash:", result.transactionHash); +`; + +const curlExample = () => `\ +curl -X POST "${THIRDWEB_ENGINE_CLOUD_URL}/v1/write/contract" \\ + -H "Content-Type: application/json" \\ + -H "x-secret-key: " \\ + -H "x-vault-access-token: " \\ + -d '{ + "executionOptions": { + "from": "", + "chainId": "84532" + }, + "params": [ + { + "contractAddress": "0x...", + "method": "function mintTo(address to, uint256 amount)", + "params": ["0x...", "100"] + } + ] + }'`; + +const jsExample = () => `\ +const response = await fetch( + "${THIRDWEB_ENGINE_CLOUD_URL}/v1/write/contract", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-secret-key": "", + "x-vault-access-token": "" + }, + body: JSON.stringify({ + "executionOptions": { + "from": "", + "chainId": "84532" + }, + "params": [ + { + "contractAddress": "0x...", + "method": "function mintTo(address to, uint256 amount)", + "params": ["0x...", "100"] + } + ] + }) + } +);`; + +const pythonExample = () => `\ +import requests +import json + +url = "${THIRDWEB_ENGINE_CLOUD_URL}/v1/write/contract" +headers = { + "Content-Type": "application/json", + "x-secret-key": "", + "x-vault-access-token": "" +} +payload = { + "executionOptions": { + "from": "", + "chainId": "84532" + }, + "params": [ + { + "contractAddress": "0x...", + "method": "function mintTo(address to, uint256 amount)", + "params": ["0x...", "100"] + } + ] +} + +response = requests.post(url, headers=headers, json=payload) +result = response.json()`; + +const goExample = () => `\ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +func main() { + url := "${THIRDWEB_ENGINE_CLOUD_URL}/v1/write/contract" + + // Create the request payload + type Param struct { + ContractAddress string \`json:"contractAddress"\` + Method string \`json:"method"\` + Params []string \`json:"params"\` + } + + type RequestBody struct { + ExecutionOptions struct { + From string \`json:"from"\` + ChainId string \`json:"chainId"\` + } \`json:"executionOptions"\` + Params []Param \`json:"params"\` + } + + requestBody := RequestBody{ + Params: []Param{ + { + ContractAddress: "0x...", + Method: "function mintTo(address to, uint256 amount)", + Params: []string{"0x...", "100"}, + }, + }, + } + requestBody.ExecutionOptions.From = "" + requestBody.ExecutionOptions.ChainId = "84532" + + jsonData, _ := json.Marshal(requestBody) + + // Create the HTTP request + req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-secret-key", "") + req.Header.Set("x-vault-access-token", "") + + // Send the request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + fmt.Println("Error:", err) + return + } + defer resp.Body.Close() + + // Process the response + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + fmt.Println("Response:", result) +}`; + +const csharpExample = () => `\ +using System; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +class Program +{ + static async Task Main() + { + var url = "${THIRDWEB_ENGINE_CLOUD_URL}/v1/write/contract"; + + var requestData = new + { + executionOptions = new + { + from = "", + chainId = "84532" + }, + @params = new[] + { + new + { + contractAddress = "0x...", + method = "function mintTo(address to, uint256 amount)", + @params = new[] { "0x...", "100" } + } + } + }; + + var json = JsonSerializer.Serialize(requestData); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + using var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Add("x-secret-key", ""); + httpClient.DefaultRequestHeaders.Add("x-vault-access-token", ""); + + var response = await httpClient.PostAsync(url, content); + var responseContent = await response.Content.ReadAsStringAsync(); + + Console.WriteLine(responseContent); + } +}`; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/server-wallets/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/server-wallets/page.tsx new file mode 100644 index 00000000000..d1ac07601a8 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/server-wallets/page.tsx @@ -0,0 +1,61 @@ +import { getProject } from "@/api/projects"; +import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk"; +import { notFound } from "next/navigation"; +import { THIRDWEB_VAULT_URL } from "../../../../../../../../@/constants/env"; +import { getAuthToken } from "../../../../../../api/lib/getAuthToken"; +import type { Wallet } from "./wallet-table/types"; +import { ServerWalletsTable } from "./wallet-table/wallet-table"; + +export default async function TransactionsServerWalletsPage(props: { + params: Promise<{ team_slug: string; project_slug: string }>; +}) { + const vaultClient = await createVaultClient({ + baseUrl: THIRDWEB_VAULT_URL, + }); + + const { team_slug, project_slug } = await props.params; + const [authToken, project] = await Promise.all([ + getAuthToken(), + getProject(team_slug, project_slug), + ]); + + if (!project || !authToken) { + notFound(); + } + + const projectEngineCloudService = project.services.find( + (service) => service.name === "engineCloud", + ); + + const managementAccessToken = + projectEngineCloudService?.managementAccessToken; + + const eoas = managementAccessToken + ? await listEoas({ + client: vaultClient, + request: { + auth: { + accessToken: managementAccessToken, + }, + options: {}, + }, + }) + : { data: { items: [] }, error: null, success: true }; + + return ( + <> + {eoas.error ? ( +
Error: {eoas.error.message}
+ ) : ( +
+ +
+ )} + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/server-wallets/wallet-table/types.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/server-wallets/wallet-table/types.ts new file mode 100644 index 00000000000..2b957b5220c --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/server-wallets/wallet-table/types.ts @@ -0,0 +1,11 @@ +export type Wallet = { + id: string; + address: string; + metadata: { + type: string; + projectId: string; + label?: string; + }; + createdAt: string; + updatedAt: string; +}; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/server-wallets/wallet-table/wallet-table-ui.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/server-wallets/wallet-table/wallet-table-ui.client.tsx new file mode 100644 index 00000000000..034df03d40d --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/server-wallets/wallet-table/wallet-table-ui.client.tsx @@ -0,0 +1,191 @@ +"use client"; + +import type { Project } from "@/api/projects"; +import { WalletAddress } from "@/components/blocks/wallet-address"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { ToolTipLabel } from "@/components/ui/tooltip"; +import { getThirdwebClient } from "@/constants/thirdweb.server"; +import { useQuery } from "@tanstack/react-query"; +import { formatDistanceToNowStrict } from "date-fns"; +import { format } from "date-fns/format"; +import { SendIcon } from "lucide-react"; +import { useState } from "react"; +import { + DEFAULT_ACCOUNT_FACTORY_V0_7, + predictSmartAccountAddress, +} from "thirdweb/wallets/smart"; +import { Button } from "../../../../../../../../../@/components/ui/button"; +import { useDashboardRouter } from "../../../../../../../../../@/lib/DashboardRouter"; +import { useV5DashboardChain } from "../../../../../../../../../lib/v5-adapter"; +import CreateServerWallet from "../components/create-server-wallet.client"; +import type { Wallet } from "./types"; + +export function ServerWalletsTableUI({ + wallets, + project, + teamSlug, + managementAccessToken, +}: { + wallets: Wallet[]; + project: Project; + teamSlug: string; + managementAccessToken: string | undefined; +}) { + const [showSigners, setShowSigners] = useState(false); + return ( +
+
+
+
+

+ Server Wallets +

+

+ Create and manage server wallets for you project +

+
+
+
+ + setShowSigners(!showSigners)} + /> +
+ +
+ + + + + + Address + Label + Created At + Updated At + Send test tx + + + + {wallets.length === 0 ? ( + + + No server wallets found + + + ) : ( + wallets.map((wallet) => ( + + + {showSigners ? ( + + ) : ( + + )} + + {wallet.metadata.label || "none"} + + + + + + + + + + + )) + )} + +
+
+
+ ); +} + +export function SmartAccountCell({ wallet }: { wallet: Wallet }) { + const chainId = 1; // TODO: add chain switcher for balance + smart account address + const chain = useV5DashboardChain(chainId); + + const smartAccountAddressQuery = useQuery({ + queryKey: ["smart-account-address", wallet.address], + queryFn: async () => { + const smartAccountAddress = await predictSmartAccountAddress({ + client: getThirdwebClient(undefined), + adminAddress: wallet.address, + chain, + factoryAddress: DEFAULT_ACCOUNT_FACTORY_V0_7, + }); + return smartAccountAddress; + }, + }); + + return ( +
+ {smartAccountAddressQuery.data ? ( +
+ + Smart Account +
+ ) : ( + + )} +
+ ); +} + +function WalletDateCell({ date }: { date: string }) { + if (!date) { + return "N/A"; + } + + const dateObj = new Date(date); + return ( + +

{formatDistanceToNowStrict(dateObj, { addSuffix: true })}

+
+ ); +} + +function SendTestTransaction(props: { + wallet: Wallet; + teamSlug: string; + project: Project; +}) { + const router = useDashboardRouter(); + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/server-wallets/wallet-table/wallet-table.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/server-wallets/wallet-table/wallet-table.tsx new file mode 100644 index 00000000000..0b604f6102c --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/server-wallets/wallet-table/wallet-table.tsx @@ -0,0 +1,24 @@ +import type { Project } from "@/api/projects"; +import type { Wallet } from "./types"; +import { ServerWalletsTableUI } from "./wallet-table-ui.client"; + +export function ServerWalletsTable({ + wallets, + project, + teamSlug, + managementAccessToken, +}: { + wallets: Wallet[]; + project: Project; + teamSlug: string; + managementAccessToken: string | undefined; +}) { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/tx/[id]/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/tx/[id]/layout.tsx new file mode 100644 index 00000000000..849b1af7bb7 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/tx/[id]/layout.tsx @@ -0,0 +1,25 @@ +import { ChevronLeftIcon } from "lucide-react"; +import Link from "next/link"; + +export default function TransactionLayout({ + children, + params, +}: { + children: React.ReactNode; + params: { team_slug: string; project_slug: string }; +}) { + return ( +
+
+ + + Back to Transactions + +
+
{children}
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/tx/[id]/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/tx/[id]/page.tsx new file mode 100644 index 00000000000..88c9fdb5b41 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/tx/[id]/page.tsx @@ -0,0 +1,38 @@ +import { getProject } from "@/api/projects"; +import { notFound, redirect } from "next/navigation"; +import { getSingleTransaction } from "../../lib/analytics"; +import { TransactionDetailsUI } from "./transaction-details-ui"; + +export default async function TransactionPage({ + params, +}: { + params: Promise<{ team_slug: string; project_slug: string; id: string }>; +}) { + const { team_slug, project_slug, id } = await params; + + const project = await getProject(team_slug, project_slug); + + if (!project) { + redirect(`/team/${team_slug}`); + } + + const transactionData = await getSingleTransaction({ + teamId: project.teamId, + clientId: project.publishableKey, + transactionId: id, + }); + + if (!transactionData) { + notFound(); + } + + return ( +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/tx/[id]/transaction-details-ui.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/tx/[id]/transaction-details-ui.tsx new file mode 100644 index 00000000000..35dcd10329e --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/tx/[id]/transaction-details-ui.tsx @@ -0,0 +1,334 @@ +"use client"; + +import type { Project } from "@/api/projects"; +import { WalletAddress } from "@/components/blocks/wallet-address"; +import { CopyTextButton } from "@/components/ui/CopyTextButton"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { CodeClient } from "@/components/ui/code/code.client"; +import { ToolTipLabel } from "@/components/ui/tooltip"; +import { useThirdwebClient } from "@/constants/thirdweb.client"; +import { format, formatDistanceToNowStrict } from "date-fns"; +import { useAllChainsData } from "hooks/chains/allChains"; +import { ExternalLinkIcon, InfoIcon } from "lucide-react"; +import Link from "next/link"; +import { toEther } from "thirdweb"; +import { ChainIconClient } from "../../../../../../../../../components/icons/ChainIcon"; +import { statusDetails } from "../../analytics/tx-table/tx-table-ui"; +import type { Transaction } from "../../analytics/tx-table/types"; + +export function TransactionDetailsUI({ + transaction, +}: { + transaction: Transaction; + teamSlug: string; + project: Project; +}) { + const thirdwebClient = useThirdwebClient(); + const { idToChain } = useAllChainsData(); + + // Extract relevant data from transaction + const { + id, + chainId, + from, + transactionHash, + confirmedAt, + createdAt, + errorMessage, + executionParams, + executionResult, + } = transaction; + + const status = executionResult?.status as keyof typeof statusDetails; + + const chain = chainId ? idToChain.get(Number.parseInt(chainId)) : undefined; + const explorer = chain?.explorers?.[0]; + + // Calculate time difference between creation and confirmation + const confirmationTime = + confirmedAt && createdAt + ? new Date(confirmedAt).getTime() - new Date(createdAt).getTime() + : null; + + // Determine sender and signer addresses + const senderAddress = executionParams?.smartAccountAddress || from || ""; + const signerAddress = executionParams?.signerAddress || from || ""; + + // Gas information + const gasUsed = + executionResult && "actualGasUsed" in executionResult + ? `${executionResult.actualGasUsed}` + : "N/A"; + + const gasCost = + executionResult && "actualGasCost" in executionResult + ? `${toEther(BigInt(executionResult.actualGasCost || "0"))} ${chain?.nativeCurrency.symbol || ""}` + : "N/A"; + + return ( + <> +
+

+ Transaction Details +

+
+ +
+ {/* Transaction Info Card */} + + + Transaction Information + + +
+
+ Status +
+
+ {status && ( + + + {statusDetails[status].name} + {errorMessage && } + + + )} +
+
+ +
+
+ Transaction ID +
+
+ +
+
+ +
+
+ Transaction Hash +
+
+ {transactionHash ? ( +
+ {explorer ? ( + + ) : ( + + )} +
+ ) : ( +
Not available yet
+ )} +
+
+ +
+
+ Network +
+
+ {chain ? ( +
+ + {chain.name} +
+ ) : ( +
Chain ID: {chainId || "Unknown"}
+ )} +
+
+
+
+ + + Sender Information + + +
+
+ Sender Address +
+
+ +
+
+ +
+
+ Signer Address +
+
+ +
+
+
+
+ + + Transaction Parameters + + + {transaction.transactionParams && + transaction.transactionParams.length > 0 ? ( + + ) : ( +

+ No transaction parameters available +

+ )} +
+
+ {errorMessage && ( + + + + Error Details + + + +
+ {errorMessage} +
+
+
+ )} + + + Timing Information + + +
+
+ Created At +
+
+ {createdAt ? ( +

+ {formatDistanceToNowStrict(new Date(createdAt), { + addSuffix: true, + })}{" "} + ({format(new Date(createdAt), "PP pp z")}) +

+ ) : ( + "N/A" + )} +
+
+ +
+
+ Confirmed At +
+
+ {confirmedAt ? ( +

+ {formatDistanceToNowStrict(new Date(confirmedAt), { + addSuffix: true, + })}{" "} + ({format(new Date(confirmedAt), "PP pp z")}) +

+ ) : ( + "Pending" + )} +
+
+ + {confirmationTime && ( +
+
+ Confirmation Time +
+
+ {Math.floor(confirmationTime / 1000)} seconds +
+
+ )} +
+
+ + + Gas Information + + +
+
+ Gas Used +
+
{gasUsed}
+
+ +
+
+ Gas Cost +
+
{gasCost}
+
+ + {transaction.confirmedAtBlockNumber && ( +
+
+ Block Number +
+
+ {transaction.confirmedAtBlockNumber} +
+
+ )} +
+
+
+ + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/vault/components/create-vault-account.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/vault/components/create-vault-account.client.tsx new file mode 100644 index 00000000000..7fa2bb264fb --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/vault/components/create-vault-account.client.tsx @@ -0,0 +1,285 @@ +"use client"; +import type { Project } from "@/api/projects"; +import { CopyTextButton } from "@/components/ui/CopyTextButton"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { cn } from "@/lib/utils"; +import { useMutation } from "@tanstack/react-query"; +import { createServiceAccount } from "@thirdweb-dev/vault-sdk"; +import { CheckIcon, DownloadIcon, Loader2Icon, LockIcon } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { useTrack } from "../../../../../../../../../hooks/analytics/useTrack"; +import { + createManagementAccessToken, + createWalletAccessToken, + initVaultClient, + maskSecret, +} from "../../lib/vault.client"; + +export default function CreateVaultAccountButton(props: { + project: Project; + onUserAccessTokenCreated?: (userAccessToken: string) => void; +}) { + const [modalOpen, setModalOpen] = useState(false); + const [keysConfirmed, setKeysConfirmed] = useState(false); + const [keysDownloaded, setKeysDownloaded] = useState(false); + const router = useDashboardRouter(); + const trackEvent = useTrack(); + + const initialiseProjectWithVaultMutation = useMutation({ + mutationFn: async () => { + setModalOpen(true); + + trackEvent({ + category: "engine-cloud", + action: "create_vault_account", + }); + + const vaultClient = await initVaultClient(); + + const serviceAccount = await createServiceAccount({ + client: vaultClient, + request: { + options: { + metadata: { + projectId: props.project.id, + teamId: props.project.teamId, + purpose: "Thirdweb Project Server Wallet Service Account", + }, + }, + }, + }); + + if (!serviceAccount.success) { + throw new Error("Failed to create service account"); + } + + const managementAccessTokenPromise = createManagementAccessToken({ + project: props.project, + adminKey: serviceAccount.data.adminKey, + rotationCode: serviceAccount.data.rotationCode, + vaultClient, + }); + + const userAccesTokenPromise = createWalletAccessToken({ + project: props.project, + adminKey: serviceAccount.data.adminKey, + vaultClient, + }); + + const [userAccessTokenRes, managementAccessTokenRes] = await Promise.all([ + userAccesTokenPromise, + managementAccessTokenPromise, + ]); + + if (!managementAccessTokenRes.success || !userAccessTokenRes.success) { + throw new Error("Failed to create access token"); + } + + props.onUserAccessTokenCreated?.(userAccessTokenRes.data.accessToken); + + return { + serviceAccount: serviceAccount.data, + userAccessToken: userAccessTokenRes.data, + }; + }, + onError: (error) => { + toast.error(error.message); + setModalOpen(false); + }, + }); + + const handleCreateServerWallet = async () => { + await initialiseProjectWithVaultMutation.mutateAsync(); + }; + + const handleDownloadKeys = () => { + if (!initialiseProjectWithVaultMutation.data) { + return; + } + + const fileContent = `Project:\n${props.project.name} (${props.project.publishableKey})\n\nVault Admin Key:\n${initialiseProjectWithVaultMutation.data.serviceAccount.adminKey}\n\nVault Access Token:\n${initialiseProjectWithVaultMutation.data.userAccessToken.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 = `${props.project.name}-vault-keys.txt`; + link.href = url; + link.download = filename; + document.body.appendChild(link); // Required for Firefox + link.click(); + + // Clean up + document.body.removeChild(link); + URL.revokeObjectURL(url); + + toast.success(`Keys downloaded as ${filename}`); + setKeysDownloaded(true); + }; + + const handleCloseModal = () => { + if (!keysConfirmed) { + return; + } + + setModalOpen(false); + setKeysConfirmed(false); + setKeysDownloaded(false); + initialiseProjectWithVaultMutation.reset(); + // invalidate the page to force a reload + router.refresh(); + }; + + const isLoading = initialiseProjectWithVaultMutation.isPending; + + return ( + <> + + + + + {initialiseProjectWithVaultMutation.isPending ? ( + <> + + Generating your Vault management keys + +
+ +

+ This may take a few seconds. +

+
+ + ) : initialiseProjectWithVaultMutation.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. +

+
+
+ + {/*
+

+ Vault Access Token +

+
+ +

+ This access token is used to sign transactions and + messages from your backend. Can be revoked and recreated + with your admin key. +

+
+
*/} +
+ + Secure your admin key + + These keys will not be displayed again. Store them securely + as they provide access to your server wallets. + +
+
+ + {keysDownloaded && ( + + + + )} +
+
+ + setKeysConfirmed(!!v)} + /> + I confirm that I've securely stored my admin key + + +
+ +
+ +
+
+ ) : null} + +
+ + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/vault/components/key-management.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/vault/components/key-management.tsx new file mode 100644 index 00000000000..7f05b0a3da3 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/vault/components/key-management.tsx @@ -0,0 +1,104 @@ +import type { Project } from "@/api/projects"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { InfoIcon } from "lucide-react"; +import Link from "next/link"; +import CreateVaultAccountButton from "./create-vault-account.client"; +import ListAccessTokens from "./list-access-tokens.client"; +import RotateAdminKeyButton from "./rotate-admin-key.client"; + +export function KeyManagement({ + maskedAdminKey, + project, +}: { maskedAdminKey?: string; project: Project }) { + return ( +
+
+
+

Vault

+

+ Secure, non-custodial key management system for your server wallets.{" "} + + Learn more. + +

+
+ {!maskedAdminKey ? ( + + ) : ( +
+ )} +
+ {maskedAdminKey ? ( + <> +
+
+

+ Admin Key +

+

+ This key is used to create new server wallets and access tokens. +
We do not store this key. If you lose it, you can rotate + it to create a new one. Doing so will invalidate all existing + access tokens. +

+
+
+
+

+ {maskedAdminKey} +

+
+ +
+
+
+ + + ) : null} +
+ ); +} + +async function CreateVaultAccountAlert(props: { + project: Project; +}) { + return ( +
+
+ + + + What is Vault? + + + Vault is thirdweb's non-custodial key management system for your + server wallets that allows you to: +
    +
  • Create multiple server wallets.
  • +
  • Create Vault access tokens.
  • +
  • Sign transactions using a Vault access token.
  • +
+ Your keys are stored in a hardware enclave, and all requests are + end-to-end encrypted.{" "} + + Learn more about Vault security model. + +
+ Creating server wallets and access tokens requires a Vault admin + account. Create one below to get started. + +
+ +
+
+ +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/vault/components/list-access-tokens.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/vault/components/list-access-tokens.client.tsx new file mode 100644 index 00000000000..ecd867d4e94 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/vault/components/list-access-tokens.client.tsx @@ -0,0 +1,361 @@ +"use client"; +import type { Project } from "@/api/projects"; +import { CopyTextButton } from "@/components/ui/CopyTextButton"; +import {} from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import {} from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +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 { toDateTimeLocal } from "utils/date-utils"; +import { useTrack } from "../../../../../../../../../hooks/analytics/useTrack"; +import { + SERVER_WALLET_MANAGEMENT_ACCESS_TOKEN_PURPOSE, + createWalletAccessToken, + initVaultClient, +} from "../../lib/vault.client"; + +export default function ListAccessTokens(props: { + project: Project; +}) { + const [modalOpen, setModalOpen] = useState(false); + const [typedAdminKey, setTypedAdminKey] = useState(""); + const [adminKey, setAdminKey] = useState(""); + const [deletingTokenId, setDeletingTokenId] = useState(null); + const queryClient = useQueryClient(); + const trackEvent = useTrack(); + // TODO allow passing permissions to the access token + const createAccessTokenMutation = useMutation({ + mutationFn: async (args: { adminKey: string }) => { + const vaultClient = await initVaultClient(); + + trackEvent({ + category: "engine-cloud", + action: "create_access_token", + }); + + const userAccessTokenRes = await createWalletAccessToken({ + project: props.project, + adminKey: args.adminKey, + vaultClient, + }); + + if (!userAccessTokenRes.success) { + throw new Error( + `Failed to create access token: ${userAccessTokenRes.error.message}`, + ); + } + + return { + userAccessToken: userAccessTokenRes.data, + }; + }, + onError: (error) => { + toast.error(error.message); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["list-access-tokens"], + }); + }, + }); + + const revokeAccessTokenMutation = useMutation({ + mutationFn: async (args: { adminKey: string; accessTokenId: string }) => { + setDeletingTokenId(args.accessTokenId); + const vaultClient = await initVaultClient(); + + trackEvent({ + category: "engine-cloud", + action: "revoke_access_token", + }); + + const revokeAccessTokenRes = await revokeAccessToken({ + client: vaultClient, + request: { + options: { + id: args.accessTokenId, + }, + auth: { + adminKey: args.adminKey, + }, + }, + }); + + if (!revokeAccessTokenRes.success) { + throw new Error( + `Failed to revoke access token: ${revokeAccessTokenRes.error.message}`, + ); + } + + return { + success: true, + }; + }, + onError: (error) => { + toast.error(error.message); + setDeletingTokenId(null); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["list-access-tokens"], + }); + setDeletingTokenId(null); + }, + }); + + const managementAccessToken = props.project.services.find( + (s) => s.name === "engineCloud", + )?.managementAccessToken; + + const listAccessTokensQuery = useQuery({ + queryKey: [ + "list-access-tokens", + maskSecret(managementAccessToken || ""), + maskSecret(adminKey), + ], + 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: {}, + }, + }); + + 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 + }, + enabled: !!managementAccessToken, + }); + + const handleCloseModal = () => { + setModalOpen(false); + }; + + return ( +
+
+
+
+

+ Access Tokens +

+

+ Access tokens let you sign transactions using any of your server + wallets. +

+
+ +
+
+ {listAccessTokensQuery.isLoading ? ( +
+ + + +
+ ) : listAccessTokensQuery.error ? ( +
+

+ Failed to list access tokens. Check your admin key and try again. +

+
+ ) : listAccessTokensQuery.data ? ( +
+
+
+
+
+ {listAccessTokensQuery.data.accessTokens.map((token) => ( +
+
+

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

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

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

+
+ ) : ( + + )} + +
+ {/* TODO (cloud): show policies and let you edit them */} +

+ Created on:{" "} + {toDateTimeLocal(new Date(token.createdAt))} +

+
+
+ ))} +
+
+
+
+
+ ) : ( +
+

+ No access tokens found. +

+
+ )} + +
+ +
+ + + + + Unlock Vault + +
+
+

+ This action requires your + Vault admin key. +

+ setTypedAdminKey(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + setAdminKey(typedAdminKey); + } + }} + /> +
+
+ + +
+
+
+
+
+
+ ); +} + +function maskSecret(secret: string) { + return `${secret.substring(0, 10)}...${secret.substring(secret.length - 10)}`; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/vault/components/rotate-admin-key.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/vault/components/rotate-admin-key.client.tsx new file mode 100644 index 00000000000..243156aa2c2 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/vault/components/rotate-admin-key.client.tsx @@ -0,0 +1,327 @@ +"use client"; + +import type { Project } from "@/api/projects"; +import { CopyTextButton } from "@/components/ui/CopyTextButton"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { cn } from "@/lib/utils"; +import { useMutation } from "@tanstack/react-query"; +import { rotateServiceAccount } from "@thirdweb-dev/vault-sdk"; +import { + CheckIcon, + CircleAlertIcon, + DownloadIcon, + Loader2Icon, + RefreshCcwIcon, +} from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { useTrack } from "../../../../../../../../../hooks/analytics/useTrack"; +import { + createManagementAccessToken, + createWalletAccessToken, + initVaultClient, + maskSecret, +} from "../../lib/vault.client"; + +export default function RotateAdminKeyButton(props: { project: Project }) { + const [modalOpen, setModalOpen] = useState(false); + const [keysConfirmed, setKeysConfirmed] = useState(false); + const [keysDownloaded, setKeysDownloaded] = useState(false); + const router = useDashboardRouter(); + const trackEvent = useTrack(); + + const rotateAdminKeyMutation = useMutation({ + mutationFn: async () => { + trackEvent({ + category: "engine-cloud", + action: "rotate_admin_key", + }); + + const vaultClient = await initVaultClient(); + const rotationCode = props.project.services.find( + (service) => service.name === "engineCloud", + )?.rotationCode; + + if (!rotationCode) { + throw new Error("Rotation code not found"); + } + + const rotateServiceAccountRes = await rotateServiceAccount({ + client: vaultClient, + request: { + auth: { + rotationCode, + }, + }, + }); + + if (rotateServiceAccountRes.error) { + throw new Error(rotateServiceAccountRes.error.message); + } + + // need to recreate the management access token with the new admin key + const managementAccessTokenPromise = createManagementAccessToken({ + project: props.project, + adminKey: rotateServiceAccountRes.data.newAdminKey, + rotationCode: rotateServiceAccountRes.data.newRotationCode, + vaultClient, + }); + + const userAccesTokenPromise = createWalletAccessToken({ + project: props.project, + adminKey: rotateServiceAccountRes.data.newAdminKey, + vaultClient, + }); + + const [userAccessTokenRes, managementAccessTokenRes] = await Promise.all([ + userAccesTokenPromise, + managementAccessTokenPromise, + ]); + + if (!managementAccessTokenRes.success || !userAccessTokenRes.success) { + throw new Error("Failed to create access token"); + } + + return { + success: true, + adminKey: rotateServiceAccountRes.data.newAdminKey, + userAccessToken: userAccessTokenRes.data, + }; + }, + onError: (error) => { + toast.error(error.message); + }, + }); + + const handleDownloadKeys = () => { + if (!rotateAdminKeyMutation.data) { + return; + } + + const fileContent = `Project:\n${props.project.name} (${props.project.publishableKey})\n\nVault Admin Key:\n${rotateAdminKeyMutation.data.adminKey}\n\nVault Access Token:\n${rotateAdminKeyMutation.data.userAccessToken.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 = `${props.project.name}-vault-keys-rotated.txt`; + link.href = url; + link.download = filename; + document.body.appendChild(link); // Required for Firefox + link.click(); + + // Clean up + document.body.removeChild(link); + URL.revokeObjectURL(url); + + toast.success(`Keys downloaded as ${filename}`); + setKeysDownloaded(true); + }; + + const handleCloseModal = () => { + if (!keysConfirmed) { + return; + } + + setModalOpen(false); + setKeysConfirmed(false); + setKeysDownloaded(false); + // invalidate the page to force a reload + rotateAdminKeyMutation.reset(); + router.refresh(); + }; + + const isLoading = rotateAdminKeyMutation.isPending; + + return ( + <> + + + + + {rotateAdminKeyMutation.isPending ? ( + <> + + Generating new keys... + +
+ +

+ This may take a few seconds. +

+
+ + ) : rotateAdminKeyMutation.data ? ( +
+ + New Vault Keys + + +
+
+
+

+ New Vault Admin Key +

+
+ +

+ This key is used to create or revoke your access tokens. +

+
+
+ +
+

+ New Vault Access Token +

+
+ +

+ This access token is used to sign transactions and + messages from your backend. Can be revoked and recreated + with your admin key. +

+
+
+
+ + Secure your keys + + These keys will not be displayed again. Store them securely + as they provide access to your server wallets. + +
+
+ + {keysDownloaded && ( + + + + )} +
+
+ + setKeysConfirmed(!!v)} + /> + I confirm that I've securely stored these keys + + +
+ +
+ +
+
+ ) : ( + <> + + Rotate your Vault admin key + + This action will generate a new Vault admin key and rotation + code.{" "} + + +
+
+

+ Revoke your current keys and generates new ones. +

+ + + Important + + This action will invalidate your current admin key and all + existing access tokens. You will need to update your + backend to use these new access tokens. + + +
+
+ + +
+
+ + )} + +
+ + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/vault/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/vault/page.tsx new file mode 100644 index 00000000000..02857a44b72 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/vault/page.tsx @@ -0,0 +1,33 @@ +import { getProject } from "@/api/projects"; +import { notFound } from "next/navigation"; +import { getAuthToken } from "../../../../../../api/lib/getAuthToken"; +import { KeyManagement } from "./components/key-management"; + +export default async function VaultPage(props: { + params: Promise<{ team_slug: string; project_slug: string }>; +}) { + const { team_slug, project_slug } = await props.params; + const [authToken, project] = await Promise.all([ + getAuthToken(), + getProject(team_slug, project_slug), + ]); + + if (!project || !authToken) { + notFound(); + } + + const projectEngineCloudService = project.services.find( + (service) => service.name === "engineCloud", + ); + + const maskedAdminKey = projectEngineCloudService?.maskedAdminKey; + + return ( +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/engine/(general)/EngineFooterCard.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/dedicated/(general)/EngineFooterCard.stories.tsx similarity index 95% rename from apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/engine/(general)/EngineFooterCard.stories.tsx rename to apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/dedicated/(general)/EngineFooterCard.stories.tsx index 22a678e32f1..c4d3b3f55c2 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/engine/(general)/EngineFooterCard.stories.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/dedicated/(general)/EngineFooterCard.stories.tsx @@ -6,6 +6,7 @@ const meta = { component: EngineFooterCard, args: { team_slug: "demo-team", + project_slug: "demo-project", }, decorators: [ (Story) => ( diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/engine/(general)/_components.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/dedicated/(general)/_components.tsx similarity index 91% rename from apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/engine/(general)/_components.tsx rename to apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/dedicated/(general)/_components.tsx index b3090e55247..0dc6e43b7a1 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/engine/(general)/_components.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/dedicated/(general)/_components.tsx @@ -27,8 +27,8 @@ export function ImportEngineLink(props: { ); } -function EngineInfoSection(props: { team_slug: string }) { - const engineLinkPrefix = `/team/${props.team_slug}/~/engine`; +function EngineInfoSection(props: { team_slug: string; project_slug: string }) { + const engineLinkPrefix = `/team/${props.team_slug}/${props.project_slug}/engine/dedicated`; return (
@@ -84,7 +84,7 @@ function CloudHostedEngineSection(props: { return (

- Get Cloud Hosted Engine + Get Managed Engine

{props.teamPlan !== "pro" ? ( @@ -98,7 +98,7 @@ function CloudHostedEngineSection(props: { Scale {" "} - to get a cloud hosted Engine + to get a managed Engine instance

@@ -131,7 +131,7 @@ function CloudHostedEngineSection(props: { ) : (

- Contact us to get a cloud hosted engine for your team + Contact us to get a managed engine for your team

); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/dedicated/(general)/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/dedicated/(general)/layout.tsx new file mode 100644 index 00000000000..e7bb244cb15 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/dedicated/(general)/layout.tsx @@ -0,0 +1,111 @@ +import type { SidebarLink } from "@/components/blocks/Sidebar"; +import { SidebarLayout } from "@/components/blocks/SidebarLayout"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { DatabaseIcon } from "lucide-react"; +import Link from "next/link"; +import { EngineIcon } from "../../../../../../(dashboard)/(chain)/components/server/icons/EngineIcon"; +import { ImportEngineLink } from "./_components"; + +export default async function Layout(props: { + params: Promise<{ + team_slug: string; + project_slug: string; + }>; + children: React.ReactNode; +}) { + const params = await props.params; + const linkPrefix = `/team/${params.team_slug}/${params.project_slug}/engine/dedicated`; + const sidebarLinks: SidebarLink[] = [ + { + label: "Engine Instances", + href: `${linkPrefix}`, + exactMatch: true, + }, + { + label: "Import Engine", + href: `${linkPrefix}/import`, + }, + ]; + + return ( +
+ {/* header */} +
+
+
+

+ Engine{" "} + + Dedicated + +

+

+ Manage your deployed Engine instances +

+
+
+ +
+
+
+
+ +
+
+ + {/* sidebar layout */} + + {props.children} + +
+ ); +} + +function EngineLegacyBannerUI(props: { + teamSlug: string; + projectSlug: string; +}) { + return ( + + + Engine Cloud (Beta) + +
+

+ Try Engine Cloud (Beta) - now included for free in every thirdweb + project. +

+
+
    +
  • No recurring monthly cost, pay-per-request model
  • +
  • Powered by Vault: our new TEE based key management system
  • +
  • Improved performance and simplified transaction API
  • +
+
+
+ + + + + + +
+ + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/engine/(general)/overview/engine-instances-table.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/dedicated/(general)/overview/engine-instances-table.stories.tsx similarity index 100% rename from apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/engine/(general)/overview/engine-instances-table.stories.tsx rename to apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/dedicated/(general)/overview/engine-instances-table.stories.tsx diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/engine/(general)/overview/engine-instances-table.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/dedicated/(general)/overview/engine-instances-table.tsx similarity index 96% rename from apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/engine/(general)/overview/engine-instances-table.tsx rename to apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/dedicated/(general)/overview/engine-instances-table.tsx index 8f3a194f952..5347d5bc1ab 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/engine/(general)/overview/engine-instances-table.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/dedicated/(general)/overview/engine-instances-table.tsx @@ -83,6 +83,7 @@ type RemovedEngineFromDashboard = ( export function EngineInstancesTable(props: { teamSlug: string; + projectSlug: string; instances: EngineInstance[]; engineLinkPrefix: string; teamPlan: Team["billingPlan"]; @@ -95,6 +96,7 @@ export function EngineInstancesTable(props: { instances={props.instances} engineLinkPrefix={props.engineLinkPrefix} teamSlug={props.teamSlug} + projectSlug={props.projectSlug} deleteCloudHostedEngine={async (params) => { await deleteCloudHostedEngine(params); router.refresh(); @@ -119,6 +121,7 @@ export function EngineInstancesTableUI(props: { removeEngineFromDashboard: RemovedEngineFromDashboard; teamPlan: Team["billingPlan"]; teamSlug: string; + projectSlug: string; }) { return (
@@ -127,14 +130,19 @@ export function EngineInstancesTableUI(props: { {props.instances.length === 0 ? ( - + ) : ( - Engine Instance - Actions + Engine Instance + Version + Actions @@ -177,7 +185,7 @@ function EngineInstanceRow(props: { >
-
+
+ + v2 + setSelectedTab("cloud-hosted")} > - Cloud-hosted + Managed