Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
4e6daac6dd Version Packages (#6972)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-07 16:03:49 -07:00
Joaquim VergesandGitHub 5f6403bb8d [SDK] Fix hex value handling for smart wallet execution (#6974) 2025-05-07 15:56:47 -07:00
saminaGitHubgraphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>Joaquim Verges
4c5fc839d6 New engine docs (#6951)
Signed-off-by: samina <[email protected]>
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
Co-authored-by: Joaquim Verges <[email protected]>
2025-05-07 14:25:21 -07:00
Joe-ThirdwebandGitHub f6eff13492 [Dashboard]: Adding new assets forPlume chains (#6971) 2025-05-07 23:03:59 +02:00
omahsandGitHub ebbda37d5a Fix typos (#6970) 2025-05-07 23:03:11 +02:00
c81fed3617 [TOOL-3337] Dashboard: Engine cloud - DO NOT MERGE (#6639)
Co-authored-by: Prithvish Baidya <[email protected]>
Co-authored-by: Joaquim Verges <[email protected]>
2025-05-08 06:53:10 +12:00
MananTank ec50f8cc3c [NEB-231] Show Image response in Nebula, minor refactor (#6964)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the chat functionality in the `nebula-app` by introducing support for image messages and refactoring the message history structure. It also adds new components for image handling and feedback actions.

### Detailed summary
- Added `request_id` to action messages in `Chats.stories.tsx`.
- Introduced `NebulaSessionHistoryMessage` type in `types.ts`.
- Updated `history` in `SessionInfo` to use `NebulaSessionHistoryMessage`.
- Implemented image message handling in `chat.ts`.
- Refactored `promptNebula` to include `request_id`.
- Created `NebulaImage` component for rendering images.
- Added `MessageActions` component for user feedback.
- Refactored `ChatPageContent` to utilize new message parsing logic.
- Updated message rendering in `Chats` to support image types.
- Removed redundant code for message handling in `ChatPageContent`.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-05-07 14:17:51 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
3315a53653 Version Packages (#6968)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-07 01:57:16 -07:00
Joaquim VergesandGitHub 4e42158c7b [SDK] Handle Hedera native currency decimals for smart wallet calls (#6969) 2025-05-07 01:54:43 -07:00
Joaquim VergesandGitHub 5a069fe657 [PayEmbed] Always connect to destination chain if available (#6967) 2025-05-06 19:05:12 -07:00
Joaquim VergesandGitHub 490497c68b [Checkout] Enhance redirect handling for different payment types (#6959) 2025-05-06 19:04:41 -07:00
319 changed files with 13955 additions and 1055 deletions
+2 -1
View File
@@ -30,4 +30,5 @@ packages/*/typedoc/*
storybook-static
.aider*
tsconfig.tsbuildinfo
tsconfig.tsbuildinfo
.cursor
+5 -1
View File
@@ -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=""
STRIPE_SECRET_KEY=""
# required for server wallet management
NEXT_PUBLIC_THIRDWEB_VAULT_URL=""
NEXT_PUBLIC_ENGINE_CLOUD_URL=""
+1
View File
@@ -12,6 +12,7 @@
"ignoreDependencies": [
"@storybook/blocks",
"@thirdweb-dev/service-utils",
"@thirdweb-dev/vault-sdk",
"@types/color",
"fast-xml-parser"
]
+2
View File
@@ -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",
+5 -1
View File
@@ -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<T>(params: ProxyActionParams) {
return proxy<T>(API_SERVER_URL, params);
}
export async function engineCloudProxy<T>(params: ProxyActionParams) {
return proxy<T>(THIRDWEB_ENGINE_CLOUD_URL, params);
}
export async function payServerProxy<T>(params: ProxyActionParams) {
return proxy<T>(
process.env.NEXT_PUBLIC_PAY_URL
@@ -95,7 +95,7 @@ export function FullWidthSidebarLayout(props: {
links={[...contentSidebarLinks, ...(footerSidebarLinks || [])]}
/>
<main className="z-0 flex min-w-0 grow flex-col max-sm:w-full">
<main className="flex min-w-0 grow flex-col max-sm:w-full">
{children}
</main>
<AppFooter containerClassName="max-w-7xl" />
@@ -129,7 +129,9 @@ export const SelectWithSearch = React.forwardRef<
selectedOption && "text-foreground",
)}
>
{selectedOption?.label || placeholder}
{renderOption && selectedOption
? renderOption(selectedOption)
: selectedOption?.label || placeholder}
</span>
<ChevronDownIcon className="size-4 cursor-pointer text-muted-foreground" />
</div>
@@ -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",
+3 -3
View File
@@ -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 (
<Button
asChild
key={tab.name}
key={tab.href}
disabled={tab.isDisabled}
variant="ghost"
>
@@ -206,7 +206,7 @@ function useUnderline<El extends HTMLElement>() {
export function TabPathLinks(props: {
links: {
name: string;
name: React.ReactNode;
path: string;
exactMatch?: boolean;
isDisabled?: boolean;
+6
View File
@@ -9,6 +9,12 @@ export const NEXT_PUBLIC_NEBULA_APP_CLIENT_ID =
export const NEBULA_APP_SECRET_KEY = process.env.NEBULA_APP_SECRET_KEY || "";
export const THIRDWEB_VAULT_URL =
process.env.NEXT_PUBLIC_THIRDWEB_VAULT_URL || "";
export const THIRDWEB_ENGINE_CLOUD_URL =
process.env.NEXT_PUBLIC_ENGINE_CLOUD_URL || "";
export const THIRDWEB_API_SECRET = process.env.API_SERVER_SECRET || "";
export const IPFS_GATEWAY_URL =
+55
View File
@@ -0,0 +1,55 @@
import { differenceInCalendarDays } from "date-fns";
import {
type DurationId,
type Range,
getLastNDaysRange,
} from "../../components/analytics/date-range-selector";
export function normalizeTime(date: Date) {
const newDate = new Date(date);
newDate.setHours(1, 0, 0, 0);
return newDate;
}
export function normalizeTimeISOString(date: Date) {
return normalizeTime(date).toISOString();
}
export function getFiltersFromSearchParams(params: {
from: string | undefined | string[];
to: string | undefined | string[];
interval: string | undefined | string[];
defaultRange: DurationId;
}) {
const fromStr = params.from;
const toStr = params.to;
const defaultRange = getLastNDaysRange(params.defaultRange);
const range: Range =
fromStr && toStr && typeof fromStr === "string" && typeof toStr === "string"
? {
from: normalizeTime(new Date(fromStr)),
to: normalizeTime(new Date(toStr)),
type: "custom",
}
: {
from: normalizeTime(defaultRange.from),
to: normalizeTime(defaultRange.to),
type: defaultRange.type,
};
const defaultInterval =
differenceInCalendarDays(range.to, range.from) > 30
? "week"
: ("day" as const);
return {
range,
interval:
params.interval === "day"
? ("day" as const)
: params.interval === "week"
? ("week" as const)
: defaultInterval,
};
}
@@ -7,12 +7,12 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import type { ResultItem } from "app/(app)/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/metrics/components/StatusCodes";
import type { EngineBackendWalletType } from "lib/engine";
import { useState } from "react";
import { useActiveAccount } from "thirdweb/react";
import invariant from "tiny-invariant";
import type { EngineStatus } from "../../../app/(app)/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/overview/components/transactions-table";
import type { ResultItem } from "../../../app/(app)/team/[team_slug]/[project_slug]/engine/dedicated/(instance)/[engineId]/metrics/components/StatusCodes";
import type { EngineStatus } from "../../../app/(app)/team/[team_slug]/[project_slug]/engine/dedicated/(instance)/[engineId]/overview/components/transactions-table";
import { engineKeys } from "../cache-keys";
// Engine instances
@@ -1641,7 +1641,11 @@ interface EngineResourceMetrics {
};
}
export function useEngineSystemMetrics(engineId: string, teamIdOrSlug: string) {
export function useEngineSystemMetrics(
engineId: string,
teamIdOrSlug: string,
projectSlug: string,
) {
const [enabled, setEnabled] = useState(true);
return useQuery({
@@ -1649,7 +1653,7 @@ export function useEngineSystemMetrics(engineId: string, teamIdOrSlug: string) {
queryFn: async () => {
const res = await apiServerProxy({
method: "GET",
pathname: `/v1/teams/${teamIdOrSlug}/engine/${engineId}/metrics`,
pathname: `/v1/teams/${teamIdOrSlug}/${projectSlug}/engine/dedicated/${engineId}/metrics`,
});
if (!res.ok) {
@@ -345,7 +345,7 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
const promise = sendAndConfirmTx.mutateAsync(approveTx);
toast.promise(promise, {
loading: "Approving NFT for listing",
success: "NFT approved succesfully",
success: "NFT approved successfully",
error: "Failed to approve NFT",
});
await promise;
@@ -578,6 +578,30 @@ const chainMetaRecord = {
buttonText: "Learn more",
},
},
//Plume Mainnet
98866: {
headerImgUrl: plumeBanner.src,
about:
"Plume Network is the first full-stack L1 RWA Chain and ecosystem purpose-built for RWAfi, enabling the rapid adoption and demand-driven integration of real world assets. With 180+ projects building on the network, Plume offers a composable, EVM-compatible environment for onboarding and managing diverse real world assets. Coupled with an end-to-end tokenization engine and a network of financial infrastructure partners, Plume simplifies asset onboarding and enables seamless DeFi integration for RWAs so anyone can tokenize real world assets, distribute them globally, and make them useful for native crypto users.",
cta: {
backgroundImageUrl: plumeCTA.src,
title: "Bringing the Real World Onchain",
buttonLink: "https://miles.plumenetwork.xyz/",
buttonText: "Learn more",
},
},
//Plume Mainnet
98867: {
headerImgUrl: plumeBanner.src,
about:
"Plume Network is the first full-stack L1 RWA Chain and ecosystem purpose-built for RWAfi, enabling the rapid adoption and demand-driven integration of real world assets. With 180+ projects building on the network, Plume offers a composable, EVM-compatible environment for onboarding and managing diverse real world assets. Coupled with an end-to-end tokenization engine and a network of financial infrastructure partners, Plume simplifies asset onboarding and enables seamless DeFi integration for RWAs so anyone can tokenize real world assets, distribute them globally, and make them useful for native crypto users.",
cta: {
backgroundImageUrl: plumeCTA.src,
title: "Bringing the Real World Onchain",
buttonLink: "https://miles.plumenetwork.xyz/",
buttonText: "Learn more",
},
},
//Rivalz Mainnet
753: {
headerImgUrl: rivalzBanner.src,
@@ -74,10 +74,6 @@ export default async function TeamLayout(props: {
path: `/team/${params.team_slug}/~/analytics`,
name: "Analytics",
},
{
path: `/team/${params.team_slug}/~/engine`,
name: "Engines",
},
{
path: `/team/${params.team_slug}/~/ecosystem`,
name: "Ecosystems",
@@ -1,46 +0,0 @@
import type { SidebarLink } from "@/components/blocks/Sidebar";
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
import { ImportEngineLink } from "./_components";
export default async function Layout(props: {
params: Promise<{
team_slug: string;
}>;
children: React.ReactNode;
}) {
const params = await props.params;
const linkPrefix = `/team/${params.team_slug}/~/engine`;
const sidebarLinks: SidebarLink[] = [
{
label: "Engine Instances",
href: `${linkPrefix}`,
exactMatch: true,
},
{
label: "Import Engine",
href: `${linkPrefix}/import`,
},
];
return (
<div className="flex grow flex-col">
{/* header */}
<header className="border-border border-b py-10">
<div className="container flex flex-col justify-between gap-4 lg:flex-row lg:items-center">
<h1 className="font-semibold text-3xl tracking-tight">Engines</h1>
<div className="flex items-center gap-3">
<ImportEngineLink
label="Import Engine"
engineLinkPrefix={linkPrefix}
/>
</div>
</div>
</header>
{/* sidebar layout */}
<SidebarLayout sidebarLinks={sidebarLinks}>
{props.children}
</SidebarLayout>
</div>
);
}
@@ -3,7 +3,7 @@ import { getTeamBySlug } from "@/api/team";
import { getTeamSubscriptions } from "@/api/team-subscription";
import { fetchRPCUsage } from "@/api/usage/rpc";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { normalizeTimeISOString } from "lib/time";
import { normalizeTimeISOString } from "@/lib/time";
import { redirect } from "next/navigation";
import { getValidAccount } from "../../../../../account/settings/getAccount";
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
@@ -234,7 +234,7 @@ function ProductsSection(props: {
title: "Engine",
description:
"Scale your application with a backend server to read, write, and deploy contracts at production-grade.",
href: `/team/${props.teamSlug}/~/engine`,
href: `/team/${props.teamSlug}/${props.projectSlug}/engine`,
icon: EngineIcon,
trackingLabel: "engine",
},
@@ -8,9 +8,11 @@ import {
WalletIcon,
} from "lucide-react";
import { ContractIcon } from "../../../../(dashboard)/(chain)/components/server/icons/ContractIcon";
import { EngineIcon } from "../../../../(dashboard)/(chain)/components/server/icons/EngineIcon";
import { InsightIcon } from "../../../../(dashboard)/(chain)/components/server/icons/InsightIcon";
import { PayIcon } from "../../../../(dashboard)/(chain)/components/server/icons/PayIcon";
import { SmartAccountIcon } from "../../../../(dashboard)/(chain)/components/server/icons/SmartAccountIcon";
import { Badge } from "../../../../../../@/components/ui/badge";
import { NebulaIcon } from "../../../../../nebula-app/(app)/icons/NebulaIcon";
export function ProjectSidebarLayout(props: {
@@ -60,10 +62,14 @@ export function ProjectSidebarLayout(props: {
tracking: tracking("contracts"),
},
{
href: `${layoutPath}/nebula`,
label: "Nebula",
icon: NebulaIcon,
tracking: tracking("nebula"),
href: `${layoutPath}/engine`,
label: (
<span className="flex items-center gap-2">
Engine <Badge>New</Badge>
</span>
),
icon: EngineIcon,
tracking: tracking("engine"),
},
{
href: `${layoutPath}/insight`,
@@ -71,6 +77,12 @@ export function ProjectSidebarLayout(props: {
icon: InsightIcon,
tracking: tracking("insight"),
},
{
href: `${layoutPath}/nebula`,
label: "Nebula",
icon: NebulaIcon,
tracking: tracking("nebula"),
},
]}
footerSidebarLinks={[
{
@@ -75,7 +75,7 @@ export default async function Page(props: {
});
return (
<div>
<div className="flex grow flex-col">
<AccountAbstractionSummary
teamId={project.teamId}
projectId={project.id}
@@ -0,0 +1,43 @@
import type { Project } from "@/api/projects";
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
import type { Wallet } from "../server-wallets/wallet-table/types";
import { TransactionAnalyticsFilter } from "./filter";
import { TransactionsChartCard } from "./tx-chart/tx-chart";
import { TransactionsTable } from "./tx-table/tx-table";
export function TransactionsAnalyticsPageContent(props: {
searchParams: {
from?: string | undefined | string[];
to?: string | undefined | string[];
interval?: string | undefined | string[];
};
project: Project;
showAnalytics: boolean;
wallets?: Wallet[];
teamSlug: string;
}) {
return (
<ResponsiveSearchParamsProvider value={props.searchParams}>
<div className="flex grow flex-col gap-6">
{props.showAnalytics && (
<>
<div className="flex justify-end">
<TransactionAnalyticsFilter />
</div>
<TransactionsChartCard
searchParams={props.searchParams}
project={props.project}
wallets={props.wallets ?? []}
teamSlug={props.teamSlug}
/>
</>
)}
<TransactionsTable
project={props.project}
wallets={props.wallets}
teamSlug={props.teamSlug}
/>
</div>
</ResponsiveSearchParamsProvider>
);
}
@@ -0,0 +1,51 @@
"use client";
import { normalizeTimeISOString } from "@/lib/time";
import {
useResponsiveSearchParams,
useSetResponsiveSearchParams,
} from "responsive-rsc";
import { DateRangeSelector } from "../../../../../../../../components/analytics/date-range-selector";
import { IntervalSelector } from "../../../../../../../../components/analytics/interval-selector";
import { getTxAnalyticsFiltersFromSearchParams } from "../lib/utils";
export function TransactionAnalyticsFilter() {
const responsiveSearchParams = useResponsiveSearchParams();
const setResponsiveSearchParams = useSetResponsiveSearchParams();
const { range, interval } = getTxAnalyticsFiltersFromSearchParams({
from: responsiveSearchParams.from,
to: responsiveSearchParams.to,
interval: responsiveSearchParams.interval,
});
return (
<div className="no-scrollbar flex items-center gap-3 max-sm:overflow-auto">
<DateRangeSelector
range={range}
popoverAlign="end"
setRange={(newRange) => {
setResponsiveSearchParams((v) => {
return {
...v,
from: normalizeTimeISOString(newRange.from),
to: normalizeTimeISOString(newRange.to),
};
});
}}
/>
<IntervalSelector
intervalType={interval}
setIntervalType={(newInterval) => {
setResponsiveSearchParams((v) => {
return {
...v,
interval: newInterval,
};
});
}}
/>
</div>
);
}
@@ -0,0 +1,134 @@
"use client";
import type { Project } from "@/api/projects";
import { type Step, StepsCard } from "components/dashboard/StepsCard";
import Link from "next/link";
import { useMemo, useState } from "react";
import { Button } from "../../../../../../../../@/components/ui/button";
import CreateServerWallet from "../server-wallets/components/create-server-wallet.client";
import type { Wallet } from "../server-wallets/wallet-table/types";
import CreateVaultAccountButton from "../vault/components/create-vault-account.client";
import { SendTestTransaction } from "./send-test-tx.client";
interface Props {
managementAccessToken: string | undefined;
project: Project;
wallets: Wallet[];
hasTransactions: boolean;
teamSlug: string;
testTxWithWallet?: string | undefined;
}
export const EngineChecklist: React.FC<Props> = (props) => {
const [userAccessToken, setUserAccessToken] = useState<string | undefined>();
const finalSteps = useMemo(() => {
const steps: Step[] = [];
steps.push({
title: "Create a Vault Admin Account",
description:
"Vault is thirdweb's key management system. It allows you to create secure server wallets and manage access tokens.",
children: (
<CreateVaultAccountStep
project={props.project}
teamSlug={props.teamSlug}
onUserAccessTokenCreated={(token) => setUserAccessToken(token)}
/>
),
completed: !!props.managementAccessToken,
showCompletedChildren: false,
});
steps.push({
title: "Create a Server Wallet",
description:
"Server wallets are smart wallets, they don't require any gas funds to send transactions.",
children: (
<CreateServerWalletStep
project={props.project}
teamSlug={props.teamSlug}
managementAccessToken={props.managementAccessToken}
/>
),
completed: props.wallets.length > 0,
showIncompleteChildren: false,
showCompletedChildren: false,
});
steps.push({
title: "Send a Test Transaction",
description:
"Engine handles gas fees, and is designed for scale, speed and security. Send a test transaction to see it in action",
children: (
<SendTestTransaction
wallets={props.wallets}
project={props.project}
userAccessToken={userAccessToken}
teamSlug={props.teamSlug}
/>
),
completed: props.hasTransactions,
showIncompleteChildren: false,
showCompletedChildren: false,
});
return steps;
}, [
props.managementAccessToken,
props.project,
props.wallets,
props.hasTransactions,
userAccessToken,
props.teamSlug,
]);
if (props.testTxWithWallet) {
return (
<SendTestTransaction
wallets={props.wallets}
project={props.project}
userAccessToken={userAccessToken}
walletId={props.testTxWithWallet}
teamSlug={props.teamSlug}
/>
);
}
if (finalSteps.length === 1) {
return null;
}
return (
<StepsCard title="Setup Your Engine" steps={finalSteps} delay={1000} />
);
};
function CreateVaultAccountStep(props: {
project: Project;
teamSlug: string;
onUserAccessTokenCreated: (userAccessToken: string) => void;
}) {
return (
<div className="mt-4 flex flex-row gap-4">
<CreateVaultAccountButton
project={props.project}
onUserAccessTokenCreated={props.onUserAccessTokenCreated}
/>
<Link href="https://portal.thirdweb.com/engine/vault" target="_blank">
<Button variant="outline">Learn more about Vault</Button>
</Link>
</div>
);
}
function CreateServerWalletStep(props: {
project: Project;
teamSlug: string;
managementAccessToken: string | undefined;
}) {
return (
<div className="mt-4 flex flex-row gap-4">
<CreateServerWallet
project={props.project}
teamSlug={props.teamSlug}
managementAccessToken={props.managementAccessToken}
/>
</div>
);
}
@@ -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<typeof formSchema>;
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<FormValues>({
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 (
<div className="mt-3 w-full rounded-md border bg-background p-6">
<form onSubmit={form.handleSubmit(onSubmit)}>
{props.walletId && (
<h3 className="mb-4 font-medium text-lg">Send a test transaction</h3>
)}
<p className="flex items-center gap-2 text-sm text-warning-text">
<LockIcon className="h-4 w-4" />
{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."}
</p>
<div className="h-4" />
{/* Responsive container */}
<div className="flex flex-col gap-2 md:flex-row md:items-end md:gap-2">
<div className="flex-grow">
<div className="flex flex-col gap-2">
<p className="text-sm">Vault Access Token</p>
{props.userAccessToken ? (
<div className="flex flex-col gap-2 ">
<CopyTextButton
copyIconPosition="right"
textToCopy={props.userAccessToken}
textToShow={props.userAccessToken}
tooltip="Copy Vault Access Token"
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
/>
<p className="text-muted-foreground text-xs">
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.
</p>
</div>
) : (
<Input
id="access-token"
type={props.userAccessToken ? "text" : "password"}
placeholder="vt_act_1234....ABCD"
{...form.register("accessToken")}
disabled={isLoading}
className="text-xs"
/>
)}
</div>
</div>
</div>
<div className="h-4" />
{/* Wallet Selector */}
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 md:flex-row md:items-end md:gap-2">
<div className="flex flex-1 flex-col gap-2">
<p className="text-sm">Server Wallet</p>
<Select
value={form.watch("walletIndex")}
onValueChange={(value) => form.setValue("walletIndex", value)}
>
<SelectTrigger className="w-full">
<SelectValue>
<div className="flex items-center gap-2">
<SmartAccountCell wallet={selectedWallet} />
<span className="text-muted-foreground text-sm">
{selectedWallet.metadata.label}
</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
{props.wallets.map((wallet, index) => (
<SelectItem key={wallet.address} value={index.toString()}>
<div className="flex items-center gap-2">
<SmartAccountCell wallet={wallet} />
<span className="text-muted-foreground text-sm">
{wallet.metadata.label}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-1 flex-col gap-2">
<p className="text-sm">Network</p>
<SingleNetworkSelector
className="bg-background"
chainIds={[
baseSepolia.id,
optimismSepolia.id,
arbitrumSepolia.id,
sepolia.id,
1301,
1946,
1993,
919,
6342,
10143,
44787,
80002,
80069,
98985,
128123,
167009,
168587773,
37714555429,
1952959480,
97,
296,
1740,
4202,
10200,
]}
client={thirdwebClient}
chainId={form.watch("chainId")}
onChange={(chainId) => {
form.setValue("chainId", chainId);
}}
/>
</div>
</div>
</div>
<div className="h-6" />
<div className="flex flex-col gap-2 md:flex-row md:justify-end md:gap-2">
<Button
type="submit"
variant={hasSentTx ? "secondary" : "primary"}
disabled={isLoading || !form.formState.isValid}
className="w-full min-w-[200px] md:w-auto"
>
{isLoading ? (
<>
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : (
<>{hasSentTx ? "Send More Transactions" : "Send Transaction"}</>
)}
</Button>
{hasSentTx && (
<Button
variant="primary"
className="w-full md:w-auto"
onClick={() => {
if (props.walletId) {
router.replace(
`/team/${props.teamSlug}/${props.project.slug}/engine/cloud`,
);
} else {
router.refresh();
}
}}
>
{props.walletId ? "Close" : "Complete Setup"}
</Button>
)}
</div>
</form>
</div>
);
}
@@ -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 (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<StatCard
label="Total Transactions"
// Value is a number, standard formatter works
value={props.data?.totalCount}
icon={ActivityIcon}
isPending={props.isPending}
/>
<StatCard
label="Total Gas Spent (includes testnet)"
// If pending, value doesn't matter much.
// If not pending, pass the wei string `as any` if data exists, otherwise pass 0.
// Passing 0 ensures StatCard receives a number if data is missing post-loading.
value={
props.isPending
? undefined
: parseTotalGasCost(props.data?.totalGasCostWei ?? "0")
}
formatter={(v: number) => `${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:
<StatCard
label="Total Gas Units Used"
value={(props.isPending ? undefined : (props.data?.totalGasUnitsUsed ?? 0)) as any} // Pass string as any
icon={SomeOtherIcon}
isPending={props.isPending}
formatter={(value: number) => { // 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";
}
}}
/>
*/}
</div>
);
}
export function TransactionAnalyticsSummary(props: {
teamId: string;
clientId: string;
initialData: TransactionSummaryData | undefined;
}) {
return (
<TransactionAnalyticsSummaryUI data={props.initialData} isPending={false} />
);
}
@@ -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<string, number> & {
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<string, ChartData> = new Map();
const chainIdToVolumeMap: Map<string, number> = 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 (
<ThirdwebBarChart
customHeader={
<div className="relative px-6 pt-6">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
Daily Transactions
</h3>
<p className="text-muted-foreground text-sm">
Amount of daily transactions by chain.
</p>
<div className="top-6 right-6 mb-8 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
<ExportToCSVButton
className="bg-background"
fileName="Sponsored Transactions"
disabled={disableActions}
getData={async () => {
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 };
}}
/>
</div>
</div>
}
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={
<EmptyChartContent
teamSlug={props.teamSlug}
project={props.project}
wallets={props.wallets}
/>
}
/>
);
}
// TODO - update the title and doc links
function EmptyChartContent(props: {
project: Project;
teamSlug: string;
wallets: Wallet[];
}) {
const router = useDashboardRouter();
return (
<div className="flex flex-col items-center justify-center px-4">
{props.wallets.length === 0 ? (
<>
<span className="mb-6 text-center text-lg">
Engine requires a{" "}
<Link
href="https://portal.thirdweb.com/vault"
target="_blank"
className="underline"
>
Vault admin account
</Link>
. Create one to get started.
</span>
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
<Button
variant="primary"
onClick={() => {
router.push(
`/team/${props.teamSlug}/${props.project.slug}/engine/cloud/vault`,
);
}}
>
Create Vault Admin Account
</Button>
</div>
</>
) : (
<p className="flex items-center gap-2 rounded-full border bg-background px-3.5 py-1.5 text-sm">
<span className="!pointer-events-auto relative flex size-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-primary" />
</span>
Waiting for transactions...
</p>
)}
</div>
);
}
@@ -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 (
<TransactionsChartCardUI
isPending={false}
userOpStats={data}
project={props.project}
wallets={props.wallets}
teamSlug={props.teamSlug}
/>
);
}
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 (
<ResponsiveSuspense
// TODO - change this if this component does not end up using these params
searchParamsUsed={["from", "to", "interval"]}
fallback={
<TransactionsChartCardUI
isPending={true}
userOpStats={[]}
project={props.project}
wallets={[]}
teamSlug={props.teamSlug}
/>
}
>
<AsyncTransactionsChartCard
from={range.from.toISOString()}
to={range.to.toISOString()}
interval={interval}
project={props.project}
wallets={props.wallets}
teamSlug={props.teamSlug}
/>
</ResponsiveSuspense>
);
}
@@ -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<TransactionsResponse>;
project: Project;
teamSlug: string;
wallets?: Wallet[];
}) {
const router = useDashboardRouter();
const [autoUpdate, setAutoUpdate] = useState(true);
const [status, setStatus] = useState<TransactionStatus | undefined>(
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 (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<div className="flex flex-col gap-4 rounded-lg rounded-b-none px-6 py-6 lg:flex-row lg:justify-between">
<div>
<h2 className="font-semibold text-xl tracking-tight">
Transaction History
</h2>
<p className="text-muted-foreground text-sm">
Transactions sent from server wallets
</p>
</div>
<div className="flex items-center justify-end gap-5 border-border border-t pt-4 lg:border-none lg:pt-0">
<div className="flex shrink-0 items-center gap-2">
<Label htmlFor="auto-update">Auto Update</Label>
<Switch
checked={autoUpdate}
onCheckedChange={(v) => setAutoUpdate(!!v)}
id="auto-update"
/>
</div>
<StatusSelector
status={status}
setStatus={(v) => {
setStatus(v);
// reset page
setPage(1);
}}
/>
</div>
</div>
<TableContainer className="rounded-none border-x-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Queue ID</TableHead>
<TableHead>Chain</TableHead>
<TableHead>Status</TableHead>
<TableHead>From</TableHead>
<TableHead>Tx Hash</TableHead>
<TableHead>Queued</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{showSkeleton ? (
<>
{new Array(pageSize).fill(0).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<SkeletonRow key={i} />
))}
</>
) : (
<>
{transactions.map((tx) => (
<TableRow
key={`${tx.id}${tx.chainId}`}
className="cursor-pointer hover:bg-accent/50"
onClick={() => {
router.push(
`/team/${props.teamSlug}/${props.project.slug}/engine/cloud/tx/${tx.id}`,
);
}}
>
{/* Queue ID */}
<TableCell className="font-medium">
<CopyAddressButton
address={tx.id ? tx.id : ""}
copyIconPosition="left"
variant="ghost"
className="text-muted-foreground"
/>
</TableCell>
{/* Chain Id */}
<TableCell>
<TxChainCell chainId={tx.chainId || undefined} />
</TableCell>
{/* Status */}
<TableCell>
<TxStatusCell transaction={tx} />
</TableCell>
{/* From Address */}
<TableCell>
{tx.from ? <WalletAddress address={tx.from} /> : "N/A"}
</TableCell>
{/* Tx Hash */}
<TableCell>
<TxHashCell transaction={tx} />
</TableCell>
{/* Queued At */}
<TableCell>
<TxQueuedAtCell transaction={tx} />
</TableCell>
</TableRow>
))}
</>
)}
</TableBody>
</Table>
{!showSkeleton && transactions.length === 0 && (
<div className="flex min-h-[200px] items-center justify-center">
No transactions found
</div>
)}
</TableContainer>
{showPagination && (
<div className="py-6">
<PaginationButtons
activePage={page}
onPageClick={setPage}
totalPages={totalPages}
/>
</div>
)}
</div>
);
}
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 (
<Select
value={props.status || "all"}
onValueChange={(v) => {
if (v === "all") {
props.setStatus(undefined);
} else {
props.setStatus(v as TransactionStatus);
}
}}
>
<SelectTrigger className="min-w-[160px]">
<SelectValue placeholder="All" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">All Transactions</SelectItem>
{statuses.map((item) => {
return (
<SelectItem key={item} value={item}>
{statusDetails[item].name}
</SelectItem>
);
})}
</SelectGroup>
</SelectContent>
</Select>
);
}
function SkeletonRow() {
return (
<TableRow className="h-[73px]">
<TableCell>
<Skeleton className="h-6 w-[152px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[155px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[58px] rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[145px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[160px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[108px]" />
</TableCell>
</TableRow>
);
}
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 (
<div className="flex items-center gap-2">
<ChainIconClient
className="size-5"
client={thirdwebClient}
src={chain.icon?.url}
/>
<div className="max-w-[150px] truncate">
{chain.name ?? `Chain ID: ${chainId}`}
</div>
</div>
);
}
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 (
<ToolTipLabel hoverable label={tooltip}>
<Badge variant={statusDetails[status].type} className="gap-2">
{statusDetails[status].name}
{errorMessage && <InfoIcon className="size-3" />}
</Badge>
</ToolTipLabel>
);
}
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 (
<CopyTextButton
textToCopy={transactionHash}
copyIconPosition="left"
textToShow={shortHash}
variant="ghost"
tooltip="Copy transaction hash"
className="font-mono text-muted-foreground text-sm"
/>
);
}
return (
<Button
variant="ghost"
asChild
className="-translate-x-2 gap-2 font-mono"
size="sm"
>
<Link
href={`${explorer.url}/tx/${transactionHash}`}
target="_blank"
onClick={(e) => {
e.stopPropagation();
}}
>
{shortHash}{" "}
<ExternalLinkIcon className="size-4 text-muted-foreground" />
</Link>
</Button>
);
}
function TxQueuedAtCell(props: { transaction: Transaction }) {
const value = props.transaction.createdAt;
if (!value) {
return;
}
const date = new Date(value);
return (
<ToolTipLabel label={format(date, "PP pp z")}>
<p>{formatDistanceToNowStrict(date, { addSuffix: true })}</p>
</ToolTipLabel>
);
}
@@ -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 (
<TransactionsTableUI
getData={async ({ page }) => {
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;
}
@@ -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;
};
@@ -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 (
<div className="flex flex-col gap-4">
<h2 className="font-bold text-2xl tracking-tight">Full API Reference</h2>
<ApiReferenceReact
configuration={{
url: `${THIRDWEB_ENGINE_CLOUD_URL}/openapi`,
hideModels: true,
servers: [
{
url: THIRDWEB_ENGINE_CLOUD_URL,
description: "Engine Cloud",
},
],
}}
/>
</div>
);
}
@@ -0,0 +1,11 @@
import { TryItOut } from "../server-wallets/components/try-it-out";
import { Scalar } from "./components/scalar";
export default async function TransactionsExplorerPage() {
return (
<div className="flex flex-col gap-4">
<TryItOut />
<Scalar />
</div>
);
}
@@ -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 (
<TransactionsLayout projectSlug={project_slug} teamSlug={team_slug}>
{props.children}
</TransactionsLayout>
);
}
function TransactionsLayout(props: {
projectSlug: string;
teamSlug: string;
children: React.ReactNode;
}) {
const engineBaseSlug = `/team/${props.teamSlug}/${props.projectSlug}/engine`;
const engineLayoutSlug = `${engineBaseSlug}/cloud`;
return (
<div className="flex grow flex-col">
{/* top */}
<div className="pt-4 lg:pt-6">
{/* header */}
<div className="container flex max-w-7xl flex-col gap-4">
<div className="flex flex-row items-center justify-between">
<div className="flex flex-col">
<h1 className="mb-0.5 flex items-center gap-2 font-semibold text-3xl tracking-tight">
Engine{" "}
<Badge
variant="success"
className="mt-0.5 flex items-center gap-2 text-sm"
>
<EngineIcon className="size-4" /> Cloud
</Badge>
<Badge
variant="outline"
className="mt-0.5 flex items-center gap-2 text-sm"
>
Beta
</Badge>
</h1>
<div className="flex items-center gap-2">
<Link
href={`${THIRDWEB_ENGINE_CLOUD_URL}/reference`} // TODO: change this
target="_blank"
className="max-w-full truncate py-1 text-muted-foreground"
>
{THIRDWEB_ENGINE_CLOUD_URL}
</Link>
</div>
</div>
<Link href={`${engineBaseSlug}/dedicated`}>
<Button variant="outline">View dedicated engines</Button>
</Link>
</div>
</div>
<div className="h-4" />
{/* Nav */}
<TabPathLinks
scrollableClassName="container max-w-7xl"
links={[
{
name: "Transactions",
path: `${engineLayoutSlug}`,
exactMatch: true,
},
{
name: "API Explorer",
path: `${engineLayoutSlug}/explorer`,
},
{
name: "Server Wallets",
path: `${engineLayoutSlug}/server-wallets`,
},
{
name: "Vault",
path: `${engineLayoutSlug}/vault`,
},
]}
/>
</div>
<div className="h-6" />
<div className="container flex max-w-7xl grow flex-col gap-6">
<div>{props.children}</div>
</div>
<div className="h-20" />
</div>
);
}
@@ -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<TransactionSummaryData> {
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<TransactionStats[]> {
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<Transaction | undefined> {
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];
}
@@ -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",
});
}
@@ -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)}`;
}
@@ -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 <div>Error: Failed to connect to Vault</div>;
}
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 (
<div className="flex grow flex-col">
<EngineChecklist
teamSlug={params.team_slug}
managementAccessToken={managementAccessToken ?? undefined}
hasTransactions={hasTransactions}
project={project}
wallets={wallets ?? []}
testTxWithWallet={searchParams.testTxWithWallet as string | undefined}
/>
{hasTransactions && !searchParams.testTxWithWallet && (
<TransactionAnalyticsSummary
initialData={initialData}
teamId={project.teamId}
clientId={project.publishableKey}
/>
)}
<div className="h-10" />
<TransactionsAnalyticsPageContent
teamSlug={params.team_slug}
searchParams={searchParams}
project={project}
showAnalytics={hasTransactions && !searchParams.testTxWithWallet}
wallets={wallets}
/>
</div>
);
}
@@ -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 (
<>
<Button
variant={"primary"}
onClick={() =>
props.managementAccessToken
? setModalOpen(true)
: router.push(
`/team/${props.teamSlug}/${props.project.slug}/engine/cloud/vault`,
)
}
className="flex flex-row items-center gap-2"
>
{isLoading ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<WalletIcon className="size-4" />
)}
{props.managementAccessToken
? isLoading
? "Creating..."
: "Create Server Wallet"
: "Get Started"}
</Button>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="p-0">
<DialogHeader className="px-6 pt-6">
<DialogTitle>Create server wallet</DialogTitle>
<DialogDescription>
Enter a label for your server wallet.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 px-6">
<div>
<Input
placeholder="Wallet label (optional)"
value={label}
onChange={(e) => setLabel(e.target.value)}
className="w-full"
/>
</div>
</div>
<div className="flex justify-end gap-3 border-t bg-card px-6 py-4">
<Button
variant="outline"
onClick={() => setModalOpen(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleCreateServerWallet}
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2Icon className="mr-2 size-4 animate-spin" />
Creating...
</>
) : (
"Create"
)}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -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<string>("sdk");
return (
<div className="flex flex-col gap-6 overflow-hidden rounded-lg border border-border bg-card p-6">
<div className="flex flex-row items-center gap-4">
<div className="flex flex-1 flex-col gap-4 rounded-lg rounded-b-none lg:flex-row lg:justify-between">
<div>
<h2 className="font-semibold text-xl tracking-tight">
Usage from your backend
</h2>
<p className="text-muted-foreground text-sm">
Send transactions from your server wallets using the thirdweb SDK
or the HTTP API directly.
</p>
</div>
</div>
</div>
<div>
<TabButtons
tabs={[
{
name: "thirdweb SDK",
onClick: () => 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",
},
]}
/>
<div className="h-4" />
{activeTab === "sdk" && (
<div className="flex flex-col gap-4">
<Alert variant="info" className="bg-background">
<CircleAlertIcon className="size-5" />
<AlertTitle>Using the thirdweb SDK on the backend</AlertTitle>
<AlertDescription className="leading-loose">
<p className="text-foreground text-sm">
You can use the full TypeScript thirdweb SDK in your backend,
allowing you to use:{" "}
<ul className="ml-2 list-disc py-2 pl-4">
<li>
The full catalog of{" "}
<Link
href="https://portal.thirdweb.com/references/typescript/v5/functions#extensions"
className="text-blue-500"
target="_blank"
>
extension functions
</Link>
</li>
<li>
The{" "}
<Link
href="https://portal.thirdweb.com/references/typescript/v5/prepareContractCall"
className="text-blue-500"
target="_blank"
>
prepareContractCall
</Link>{" "}
function to encode your transactions
</li>
<li>
The full{" "}
<Link
href="https://portal.thirdweb.com/references/typescript/v5/Account"
className="text-blue-500"
target="_blank"
>
account
</Link>{" "}
interface, predefined chains, and more
</li>
</ul>
The SDK handles encoding your transactions, signing them to
Engine and polling for status.
</p>
</AlertDescription>
</Alert>
<h3 className="font-semibold text-lg tracking-tight">
Installation
</h3>
<CodeClient
lang="shell"
code={"npm install thirdweb"}
className="bg-background"
/>
<h3 className="font-semibold text-lg tracking-tight">
Usage example: Minting a ERC1155 NFT to a user
</h3>
<CodeClient
lang="ts"
code={sdkExample()}
className="bg-background"
/>
</div>
)}
{activeTab === "curl" && (
<CodeClient
lang="bash"
code={curlExample()}
className="bg-background"
/>
)}
{activeTab === "js" && (
<div className="flex flex-col gap-4">
<p className="text-muted-foreground text-sm">
A lightweight, type safe wrapper package of the Engine HTTP API is
available on{" "}
<Link
href="https://www.npmjs.com/package/@thirdweb-dev/engine"
className="text-blue-500"
target="_blank"
>
NPM
</Link>
.
</p>
<CodeClient
lang="js"
code={jsExample()}
className="bg-background"
/>
</div>
)}
{activeTab === "python" && (
<CodeClient
lang="python"
code={pythonExample()}
className="bg-background"
/>
)}
{activeTab === "go" && (
<CodeClient lang="go" code={goExample()} className="bg-background" />
)}
{activeTab === "csharp" && (
<CodeClient
lang="csharp"
code={csharpExample()}
className="bg-background"
/>
)}
</div>
</div>
);
}
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: "<your-project-secret-key>",
});
// Create a server wallet
const serverWallet = Engine.serverWallet({
client,
address: "<your-server-wallet-address>",
vaultAccessToken: "<your-vault-access-token>",
});
// 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: <your-project-secret-key>" \\
-H "x-vault-access-token: <your-vault-access-token>" \\
-d '{
"executionOptions": {
"from": "<your-server-wallet-address>",
"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": "<your-project-secret-key>",
"x-vault-access-token": "<your-vault-access-token>"
},
body: JSON.stringify({
"executionOptions": {
"from": "<your-server-wallet-address>",
"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": "<your-project-secret-key>",
"x-vault-access-token": "<your-vault-access-token>"
}
payload = {
"executionOptions": {
"from": "<your-server-wallet-address>",
"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 = "<your-server-wallet-address>"
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", "<your-project-secret-key>")
req.Header.Set("x-vault-access-token", "<your-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 = "<your-server-wallet-address>",
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", "<your-project-secret-key>");
httpClient.DefaultRequestHeaders.Add("x-vault-access-token", "<your-vault-access-token>");
var response = await httpClient.PostAsync(url, content);
var responseContent = await response.Content.ReadAsStringAsync();
Console.WriteLine(responseContent);
}
}`;
@@ -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 ? (
<div>Error: {eoas.error.message}</div>
) : (
<div className="flex flex-col gap-8">
<ServerWalletsTable
wallets={eoas.data.items as Wallet[]}
project={project}
teamSlug={team_slug}
managementAccessToken={managementAccessToken ?? undefined}
/>
</div>
)}
</>
);
}
@@ -0,0 +1,11 @@
export type Wallet = {
id: string;
address: string;
metadata: {
type: string;
projectId: string;
label?: string;
};
createdAt: string;
updatedAt: string;
};
@@ -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 (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<div className="flex flex-row items-center gap-4 px-6 py-6">
<div className="flex flex-1 flex-col gap-4 rounded-lg rounded-b-none lg:flex-row lg:justify-between">
<div>
<h2 className="font-semibold text-xl tracking-tight">
Server Wallets
</h2>
<p className="text-muted-foreground text-sm">
Create and manage server wallets for you project
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<label htmlFor="show-signers" className="cursor-pointer text-sm">
Show Signer Addresses
</label>
<Switch
id="show-signers"
checked={showSigners}
onCheckedChange={() => setShowSigners(!showSigners)}
/>
</div>
<CreateServerWallet
project={project}
teamSlug={teamSlug}
managementAccessToken={managementAccessToken}
/>
</div>
<TableContainer className="rounded-none border-x-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Address</TableHead>
<TableHead>Label</TableHead>
<TableHead className="text-right">Created At</TableHead>
<TableHead className="text-right">Updated At</TableHead>
<TableHead className="text-right">Send test tx</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{wallets.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center">
No server wallets found
</TableCell>
</TableRow>
) : (
wallets.map((wallet) => (
<TableRow key={wallet.id} className="hover:bg-accent/50">
<TableCell>
{showSigners ? (
<WalletAddress address={wallet.address} />
) : (
<SmartAccountCell wallet={wallet} />
)}
</TableCell>
<TableCell>{wallet.metadata.label || "none"}</TableCell>
<TableCell className="text-right">
<WalletDateCell date={wallet.createdAt} />
</TableCell>
<TableCell className="text-right">
<WalletDateCell date={wallet.updatedAt} />
</TableCell>
<TableCell className="flex justify-end">
<SendTestTransaction
wallet={wallet}
project={project}
teamSlug={teamSlug}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</div>
);
}
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 (
<div>
{smartAccountAddressQuery.data ? (
<div className="flex items-center gap-2">
<WalletAddress address={smartAccountAddressQuery.data} />
<Badge variant={"default"}>Smart Account</Badge>
</div>
) : (
<Skeleton className="h-4 w-20" />
)}
</div>
);
}
function WalletDateCell({ date }: { date: string }) {
if (!date) {
return "N/A";
}
const dateObj = new Date(date);
return (
<ToolTipLabel label={format(dateObj, "PP pp z")}>
<p>{formatDistanceToNowStrict(dateObj, { addSuffix: true })}</p>
</ToolTipLabel>
);
}
function SendTestTransaction(props: {
wallet: Wallet;
teamSlug: string;
project: Project;
}) {
const router = useDashboardRouter();
return (
<Button
variant="secondary"
size="sm"
onClick={() => {
router.push(
`/team/${props.teamSlug}/${props.project.slug}/engine/cloud?testTxWithWallet=${props.wallet.id}`,
);
}}
>
<SendIcon className="size-4" />
</Button>
);
}
@@ -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 (
<ServerWalletsTableUI
wallets={wallets}
project={project}
teamSlug={teamSlug}
managementAccessToken={managementAccessToken}
/>
);
}
@@ -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 (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Link
href={`/team/${params.team_slug}/${params.project_slug}/engine/cloud`}
className="flex items-center gap-1 text-muted-foreground text-sm hover:text-foreground"
>
<ChevronLeftIcon className="size-4" />
Back to Transactions
</Link>
</div>
<div className="flex-1">{children}</div>
</div>
);
}
@@ -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 (
<div className="space-y-6 p-2">
<TransactionDetailsUI
transaction={transactionData}
teamSlug={team_slug}
project={project}
/>
</div>
);
}
@@ -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 (
<>
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<h1 className="font-semibold text-2xl tracking-tight">
Transaction Details
</h1>
</div>
<div className="flex flex-col gap-6">
{/* Transaction Info Card */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Transaction Information</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
<div className="w-full text-muted-foreground text-sm md:w-1/3">
Status
</div>
<div className="md:w-2/3">
{status && (
<ToolTipLabel
hoverable
label={
errorMessage ||
(status === "CONFIRMED" && confirmedAt
? `Completed ${format(new Date(confirmedAt), "PP pp")}`
: undefined)
}
>
<Badge
variant={statusDetails[status].type}
className="gap-2"
>
{statusDetails[status].name}
{errorMessage && <InfoIcon className="size-3" />}
</Badge>
</ToolTipLabel>
)}
</div>
</div>
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
<div className="w-full text-muted-foreground text-sm md:w-1/3">
Transaction ID
</div>
<div className="md:w-2/3">
<CopyTextButton
textToCopy={id}
copyIconPosition="left"
textToShow={`${id.slice(0, 8)}...${id.slice(-6)}`}
variant="ghost"
tooltip="Copy transaction ID"
className="font-mono text-muted-foreground text-sm"
/>
</div>
</div>
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
<div className="w-full text-muted-foreground text-sm md:w-1/3">
Transaction Hash
</div>
<div className="md:w-2/3">
{transactionHash ? (
<div>
{explorer ? (
<Button
variant="ghost"
asChild
className="-translate-x-2 gap-2 font-mono"
size="sm"
>
<Link
href={`${explorer.url}/tx/${transactionHash}`}
target="_blank"
onClick={(e) => {
e.stopPropagation();
}}
>
{`${transactionHash.slice(0, 8)}...${transactionHash.slice(-6)}`}{" "}
<ExternalLinkIcon className="size-4 text-muted-foreground" />
</Link>
</Button>
) : (
<CopyTextButton
textToCopy={transactionHash}
copyIconPosition="left"
textToShow={`${transactionHash.slice(0, 6)}...${transactionHash.slice(-4)}`}
variant="ghost"
tooltip="Copy transaction hash"
className="font-mono text-muted-foreground text-sm"
/>
)}
</div>
) : (
<div className="text-sm">Not available yet</div>
)}
</div>
</div>
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
<div className="w-full text-muted-foreground text-sm md:w-1/3">
Network
</div>
<div className="md:w-2/3">
{chain ? (
<div className="flex items-center gap-2">
<ChainIconClient
className="size-4"
client={thirdwebClient}
src={chain.icon?.url}
/>
<span className="text-sm">{chain.name}</span>
</div>
) : (
<div>Chain ID: {chainId || "Unknown"}</div>
)}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Sender Information</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
<div className="w-full text-muted-foreground text-sm md:w-1/3">
Sender Address
</div>
<div className="md:w-2/3">
<WalletAddress address={senderAddress} />
</div>
</div>
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
<div className="w-full text-muted-foreground text-sm md:w-1/3">
Signer Address
</div>
<div className="md:w-2/3">
<WalletAddress address={signerAddress} />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Transaction Parameters</CardTitle>
</CardHeader>
<CardContent>
{transaction.transactionParams &&
transaction.transactionParams.length > 0 ? (
<CodeClient
code={JSON.stringify(transaction.transactionParams, null, 2)}
lang="json"
/>
) : (
<p className="text-muted-foreground text-sm">
No transaction parameters available
</p>
)}
</CardContent>
</Card>
{errorMessage && (
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive text-lg">
Error Details
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md bg-destructive/10 p-4 text-destructive">
{errorMessage}
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-lg">Timing Information</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
<div className="w-full text-muted-foreground text-sm md:w-1/3">
Created At
</div>
<div className="md:w-2/3">
{createdAt ? (
<p className="text-sm">
{formatDistanceToNowStrict(new Date(createdAt), {
addSuffix: true,
})}{" "}
({format(new Date(createdAt), "PP pp z")})
</p>
) : (
"N/A"
)}
</div>
</div>
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
<div className="w-full text-muted-foreground text-sm md:w-1/3">
Confirmed At
</div>
<div className="text-sm md:w-2/3">
{confirmedAt ? (
<p>
{formatDistanceToNowStrict(new Date(confirmedAt), {
addSuffix: true,
})}{" "}
({format(new Date(confirmedAt), "PP pp z")})
</p>
) : (
"Pending"
)}
</div>
</div>
{confirmationTime && (
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
<div className="w-full text-muted-foreground text-sm md:w-1/3">
Confirmation Time
</div>
<div className="text-sm md:w-2/3">
{Math.floor(confirmationTime / 1000)} seconds
</div>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Gas Information</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
<div className="w-full text-muted-foreground text-sm md:w-1/3">
Gas Used
</div>
<div className="text-sm md:w-2/3">{gasUsed}</div>
</div>
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
<div className="w-full text-muted-foreground text-sm md:w-1/3">
Gas Cost
</div>
<div className="text-sm md:w-2/3">{gasCost}</div>
</div>
{transaction.confirmedAtBlockNumber && (
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
<div className="w-full text-muted-foreground text-sm md:w-1/3">
Block Number
</div>
<div className="text-sm md:w-2/3">
{transaction.confirmedAtBlockNumber}
</div>
</div>
)}
</CardContent>
</Card>
</div>
</>
);
}
@@ -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 (
<>
<Button
variant={"primary"}
onClick={handleCreateServerWallet}
disabled={isLoading}
className="flex flex-row items-center gap-2"
>
{isLoading ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<LockIcon className="size-4" />
)}
{"Create Vault Admin Account"}
</Button>
<Dialog open={modalOpen} onOpenChange={handleCloseModal} modal={true}>
<DialogContent
className="overflow-hidden p-0"
dialogCloseClassName={cn(!keysConfirmed && "hidden")}
>
{initialiseProjectWithVaultMutation.isPending ? (
<>
<DialogHeader className="p-6">
<DialogTitle>Generating your Vault management keys</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center justify-center gap-4 p-10">
<Spinner className="size-8" />
<p className="text-muted-foreground text-xs">
This may take a few seconds.
</p>
</div>
</>
) : initialiseProjectWithVaultMutation.data ? (
<div>
<DialogHeader className="p-6">
<DialogTitle>Save your Vault Admin Key</DialogTitle>
<p className="text-muted-foreground text-sm">
You'll need this key to create server wallets and access
tokens.
</p>
</DialogHeader>
<div className="space-y-6 p-6 pt-0">
<div className="space-y-4">
<div>
<div className="flex flex-col gap-2">
<CopyTextButton
textToCopy={
initialiseProjectWithVaultMutation.data.serviceAccount
.adminKey
}
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
textToShow={maskSecret(
initialiseProjectWithVaultMutation.data.serviceAccount
.adminKey,
)}
copyIconPosition="right"
tooltip="Copy Admin Key"
/>
<p className="text-muted-foreground text-xs">
Download this key to your local machine or a password
manager.
</p>
</div>
</div>
{/* <div>
<h3 className="mb-2 font-medium text-sm">
Vault Access Token
</h3>
<div className="flex flex-col gap-2 ">
<CopyTextButton
textToCopy={
initialiseProjectWithVaultMutation.data
.userAccessToken.accessToken
}
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
textToShow={maskSecret(
initialiseProjectWithVaultMutation.data
.userAccessToken.accessToken,
)}
copyIconPosition="right"
tooltip="Copy Vault Access Token"
/>
<p className="text-muted-foreground text-xs">
This access token is used to sign transactions and
messages from your backend. Can be revoked and recreated
with your admin key.
</p>
</div>
</div> */}
</div>
<Alert variant="destructive">
<AlertTitle>Secure your admin key</AlertTitle>
<AlertDescription>
These keys will not be displayed again. Store them securely
as they provide access to your server wallets.
</AlertDescription>
<div className="h-4" />
<div className="flex items-center gap-2">
<Button
variant="link"
onClick={handleDownloadKeys}
className="flex h-auto items-center gap-2 p-0 text-sm text-success-text"
>
<DownloadIcon className="size-4" />
{keysDownloaded ? "Key Downloaded" : "Download Admin Key"}
</Button>
{keysDownloaded && (
<span className="text-success-text text-xs">
<CheckIcon className="size-4" />
</span>
)}
</div>
<div className="h-4" />
<CheckboxWithLabel className="text-foreground">
<Checkbox
checked={keysConfirmed}
onCheckedChange={(v) => setKeysConfirmed(!!v)}
/>
I confirm that I've securely stored my admin key
</CheckboxWithLabel>
</Alert>
</div>
<div className="flex justify-end gap-3 border-t bg-card px-6 py-4">
<Button
onClick={handleCloseModal}
disabled={!keysConfirmed}
variant={"primary"}
>
Close
</Button>
</div>
</div>
) : null}
</DialogContent>
</Dialog>
</>
);
}
@@ -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 (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-6 overflow-hidden rounded-lg border border-border bg-card">
<div className="flex flex-col px-6 pt-6">
<h2 className="font-semibold text-xl tracking-tight">Vault</h2>
<p className="text-muted-foreground text-sm">
Secure, non-custodial key management system for your server wallets.{" "}
<Link
href="https://portal.thirdweb.com/vault"
className="underline"
>
Learn more.
</Link>
</p>
</div>
{!maskedAdminKey ? (
<CreateVaultAccountAlert project={project} />
) : (
<div />
)}
</div>
{maskedAdminKey ? (
<>
<div className="flex flex-col gap-6 overflow-hidden rounded-lg border border-border bg-card">
<div className="flex flex-col p-6">
<h2 className="font-semibold text-xl tracking-tight">
Admin Key
</h2>
<p className="text-muted-foreground text-sm">
This key is used to create new server wallets and access tokens.
<br /> 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.
</p>
<div className="h-4" />
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="flex min-w-[300px] flex-row items-center gap-2 rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm">
<p className="text-muted-foreground text-sm">
{maskedAdminKey}
</p>
</div>
<RotateAdminKeyButton project={project} />
</div>
</div>
</div>
<ListAccessTokens project={project} />
</>
) : null}
</div>
);
}
async function CreateVaultAccountAlert(props: {
project: Project;
}) {
return (
<div className="flex flex-col gap-6">
<div className="px-6">
<Alert variant="info" className="bg-background">
<AlertTitle className="flex items-center gap-2">
<InfoIcon className="h-4 w-4" />
What is Vault?
</AlertTitle>
<AlertDescription className="text-primary-foreground text-sm">
Vault is thirdweb's non-custodial key management system for your
server wallets that allows you to:
<ul className="list-disc py-2 pl-4">
<li>Create multiple server wallets.</li>
<li>Create Vault access tokens.</li>
<li>Sign transactions using a Vault access token.</li>
</ul>
Your keys are stored in a hardware enclave, and all requests are
end-to-end encrypted.{" "}
<Link
href="https://portal.thirdweb.com/vault"
className="underline"
>
Learn more about Vault security model.
</Link>
<div className="h-2" />
Creating server wallets and access tokens requires a Vault admin
account. Create one below to get started.
</AlertDescription>
<div className="h-4" />
</Alert>
</div>
<div className="flex flex-row justify-end gap-4 border-border border-t px-6 pt-4 pb-4">
<CreateVaultAccountButton project={props.project} />
</div>
</div>
);
}
@@ -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<string | null>(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 (
<div className="flex flex-col gap-6 overflow-hidden rounded-lg border border-border bg-card">
<div className="flex flex-col px-6 pt-6">
<div className="flex flex-row items-center justify-between">
<div className="flex flex-col">
<h2 className="font-semibold text-xl tracking-tight">
Access Tokens
</h2>
<p className="text-muted-foreground text-sm">
Access tokens let you sign transactions using any of your server
wallets.
</p>
</div>
<Button
onClick={() => {
if (!adminKey) {
setModalOpen(true);
} else {
setAdminKey("");
setTypedAdminKey("");
queryClient.invalidateQueries({
queryKey: ["list-access-tokens"],
});
}
}}
variant={"primary"}
className="flex flex-row items-center gap-2"
>
<LockIcon className="h-4 w-4" />{" "}
{adminKey ? "Lock Vault" : "Unlock Vault"}
</Button>
</div>
<div className="h-4" />
{listAccessTokensQuery.isLoading ? (
<div className="flex flex-col items-center justify-center gap-4 p-10">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : listAccessTokensQuery.error ? (
<div className="flex flex-col gap-4">
<p className="text-destructive-text text-sm">
Failed to list access tokens. Check your admin key and try again.
</p>
</div>
) : listAccessTokensQuery.data ? (
<div>
<div className="space-y-6 pt-0">
<div className="space-y-4">
<div>
<div className="flex flex-col gap-2">
{listAccessTokensQuery.data.accessTokens.map((token) => (
<div key={token.id} className="flex gap-2">
<div className="flex max-w-full flex-1 flex-col justify-between gap-4 rounded-lg border border-border bg-background p-4 text-xs">
<h4 className="font-bold">
{token.metadata?.purpose || "Unnamed Access Token"}
</h4>
<div className="flex flex-row items-center gap-2">
{token.accessToken.includes("**") ? (
<div className="flex flex-grow flex-row items-center gap-2 rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm">
<p className="text-muted-foreground text-sm">
{token.accessToken}{" "}
<span className="text-muted-foreground text-xs">
(unlock vault to reveal the full token)
</span>
</p>
</div>
) : (
<CopyTextButton
textToCopy={token.accessToken}
className="!h-auto min-w-0 flex-grow justify-between bg-background px-3 py-3 font-mono text-xs"
textToShow={token.accessToken}
copyIconPosition="right"
tooltip="Copy Vault Access Token"
/>
)}
<Button
variant="destructive"
size="sm"
className="px-3 py-3"
onClick={() =>
revokeAccessTokenMutation.mutate({
adminKey,
accessTokenId: token.id,
})
}
disabled={
deletingTokenId !== null ||
revokeAccessTokenMutation.isPending ||
!adminKey
}
>
{deletingTokenId === token.id ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<Trash2Icon className="size-4" />
)}
</Button>
</div>
{/* TODO (cloud): show policies and let you edit them */}
<p className="text-muted-foreground text-xs">
Created on:{" "}
{toDateTimeLocal(new Date(token.createdAt))}
</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
) : (
<div className="flex flex-col gap-4">
<p className="text-muted-foreground text-sm">
No access tokens found.
</p>
</div>
)}
<div className="flex justify-end gap-3 bg-card py-4">
<Button
onClick={() => createAccessTokenMutation.mutate({ adminKey })}
disabled={createAccessTokenMutation.isPending || !adminKey}
variant={"primary"}
>
{createAccessTokenMutation.isPending ? (
<>
<Loader2Icon className="mr-2 size-4 animate-spin" />
Creating new access token...
</>
) : (
"Create new access token"
)}
</Button>
</div>
<Dialog open={modalOpen} onOpenChange={handleCloseModal} modal={true}>
<DialogContent className="overflow-hidden p-0">
<DialogHeader className="px-6 pt-6">
<DialogTitle>Unlock Vault</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 px-6">
<p className="flex items-center gap-2 text-sm text-warning-text">
<LockIcon className="h-4 w-4" /> This action requires your
Vault admin key.
</p>
<Input
type="password"
placeholder="sa_adm_ABCD_1234..."
value={typedAdminKey}
onChange={(e) => setTypedAdminKey(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
setAdminKey(typedAdminKey);
}
}}
/>
</div>
<div className="flex justify-end gap-3 border-t bg-card px-6 py-4">
<Button
variant={"outline"}
onClick={() => {
setAdminKey("");
setTypedAdminKey("");
setModalOpen(false);
}}
>
Cancel
</Button>
<Button
variant={"primary"}
onClick={() => {
setAdminKey(typedAdminKey);
handleCloseModal();
}}
disabled={!typedAdminKey || listAccessTokensQuery.isLoading}
>
Unlock Vault
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</div>
);
}
function maskSecret(secret: string) {
return `${secret.substring(0, 10)}...${secret.substring(secret.length - 10)}`;
}
@@ -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 (
<>
<Button
variant="outline"
onClick={() => setModalOpen(true)}
disabled={isLoading}
className="h-auto gap-2 rounded-lg bg-background px-4 py-3"
>
{isLoading && <Loader2Icon className="size-4 animate-spin" />}
{!isLoading && <RefreshCcwIcon className="size-4" />}
Rotate Admin Key
</Button>
<Dialog open={modalOpen} onOpenChange={handleCloseModal} modal={true}>
<DialogContent
className="overflow-hidden p-0"
dialogCloseClassName={cn(!keysConfirmed && "hidden")}
>
{rotateAdminKeyMutation.isPending ? (
<>
<DialogHeader className="p-6">
<DialogTitle>Generating new keys...</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center justify-center gap-4 p-10">
<Spinner className="size-8" />
<p className="text-muted-foreground text-xs">
This may take a few seconds.
</p>
</div>
</>
) : rotateAdminKeyMutation.data ? (
<div>
<DialogHeader className="p-6">
<DialogTitle>New Vault Keys</DialogTitle>
</DialogHeader>
<div className="space-y-6 p-6 pt-0">
<div className="space-y-4">
<div>
<h3 className="mb-2 font-medium text-sm">
New Vault Admin Key
</h3>
<div className="flex flex-col gap-2">
<CopyTextButton
textToCopy={rotateAdminKeyMutation.data.adminKey}
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
textToShow={maskSecret(
rotateAdminKeyMutation.data.adminKey,
)}
copyIconPosition="right"
tooltip="Copy Admin Key"
/>
<p className="text-muted-foreground text-xs">
This key is used to create or revoke your access tokens.
</p>
</div>
</div>
<div>
<h3 className="mb-2 font-medium text-sm">
New Vault Access Token
</h3>
<div className="flex flex-col gap-2 ">
<CopyTextButton
textToCopy={
rotateAdminKeyMutation.data.userAccessToken
.accessToken
}
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
textToShow={maskSecret(
rotateAdminKeyMutation.data.userAccessToken
.accessToken,
)}
copyIconPosition="right"
tooltip="Copy Vault Access Token"
/>
<p className="text-muted-foreground text-xs">
This access token is used to sign transactions and
messages from your backend. Can be revoked and recreated
with your admin key.
</p>
</div>
</div>
</div>
<Alert variant="destructive">
<AlertTitle>Secure your keys</AlertTitle>
<AlertDescription>
These keys will not be displayed again. Store them securely
as they provide access to your server wallets.
</AlertDescription>
<div className="h-4" />
<div className="flex items-center gap-2">
<Button
variant="link"
onClick={handleDownloadKeys}
className="flex h-auto items-center gap-2 p-0 text-sm text-success-text"
>
<DownloadIcon className="size-4" />
{keysDownloaded ? "Keys Downloaded" : "Download Keys"}
</Button>
{keysDownloaded && (
<span className="text-success-text text-xs">
<CheckIcon className="size-4" />
</span>
)}
</div>
<div className="h-4" />
<CheckboxWithLabel className="text-foreground">
<Checkbox
checked={keysConfirmed}
onCheckedChange={(v) => setKeysConfirmed(!!v)}
/>
I confirm that I've securely stored these keys
</CheckboxWithLabel>
</Alert>
</div>
<div className="flex justify-end gap-3 border-t bg-card px-6 py-4">
<Button
onClick={handleCloseModal}
disabled={!keysConfirmed}
variant="primary"
>
Close
</Button>
</div>
</div>
) : (
<>
<DialogHeader className="px-6 pt-6">
<DialogTitle>Rotate your Vault admin key</DialogTitle>
<DialogDescription>
This action will generate a new Vault admin key and rotation
code.{" "}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 px-6">
<p className="text-md text-primary-foreground">
Revoke your current keys and generates new ones.
</p>
<Alert variant="destructive">
<CircleAlertIcon className="size-5" />
<AlertTitle>Important</AlertTitle>
<AlertDescription>
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.
</AlertDescription>
</Alert>
</div>
<div className="flex justify-end gap-3 border-t bg-card px-6 py-4">
<Button
variant="outline"
onClick={() => {
setModalOpen(false);
}}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => rotateAdminKeyMutation.mutate()}
disabled={rotateAdminKeyMutation.isPending}
>
{rotateAdminKeyMutation.isPending ? (
<>
<Loader2Icon className="mr-2 size-4 animate-spin" />
Rotating...
</>
) : (
"Rotate Admin Key"
)}
</Button>
</div>
</div>
</>
)}
</DialogContent>
</Dialog>
</>
);
}
@@ -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 (
<div className="flex flex-col gap-8">
<KeyManagement
maskedAdminKey={maskedAdminKey ?? undefined}
project={project}
/>
</div>
);
}
@@ -6,6 +6,7 @@ const meta = {
component: EngineFooterCard,
args: {
team_slug: "demo-team",
project_slug: "demo-project",
},
decorators: [
(Story) => (
@@ -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 (
<div className="">
@@ -84,7 +84,7 @@ function CloudHostedEngineSection(props: {
return (
<div className="flex flex-col">
<h3 className="mb-0.5 font-semibold text-lg tracking-tight">
Get Cloud Hosted Engine
Get Managed Engine
</h3>
{props.teamPlan !== "pro" ? (
@@ -98,7 +98,7 @@ function CloudHostedEngineSection(props: {
<UnderlineLink href="/pricing" target="_blank">
Scale
</UnderlineLink>{" "}
to get a cloud hosted Engine
to get a managed Engine instance
</p>
<div className="h-5" />
@@ -131,7 +131,7 @@ function CloudHostedEngineSection(props: {
) : (
<div>
<p className="mb-4 text-muted-foreground text-sm">
Contact us to get a cloud hosted engine for your team
Contact us to get a managed engine for your team
</p>
<Button variant="outline" size="sm" asChild className="gap-2">
<Link href={PRO_CONTACT_US_URL} target="_blank">
@@ -148,6 +148,7 @@ function CloudHostedEngineSection(props: {
export function EngineFooterCard(props: {
teamPlan: Team["billingPlan"];
team_slug: string;
project_slug: string;
}) {
return (
<div className="relative rounded-lg border p-6">
@@ -161,7 +162,10 @@ export function EngineFooterCard(props: {
</>
)}
<EngineInfoSection team_slug={props.team_slug} />
<EngineInfoSection
team_slug={props.team_slug}
project_slug={props.project_slug}
/>
</div>
);
}
@@ -55,6 +55,7 @@ async function importEngine({
export function EngineImportCard(props: {
prefillImportUrl: string | undefined;
teamSlug: string;
projectSlug: string;
}) {
const router = useDashboardRouter();
@@ -63,7 +64,9 @@ export function EngineImportCard(props: {
prefillImportUrl={props.prefillImportUrl}
importEngine={async (params) => {
await importEngine({ ...params, teamIdOrSlug: props.teamSlug });
router.push(`/team/${props.teamSlug}/~/engine`);
router.push(
`/team/${props.teamSlug}/${props.projectSlug}/engine/dedicated`,
);
}}
/>
);
@@ -1,7 +1,7 @@
import { EngineImportCard } from "./EngineImportPage";
export default async function Page(props: {
params: Promise<{ team_slug: string }>;
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<{
importUrl?: string | string[];
}>;
@@ -21,6 +21,7 @@ export default async function Page(props: {
<EngineImportCard
prefillImportUrl={importUrl ? decodeURIComponent(importUrl) : undefined}
teamSlug={params.team_slug}
projectSlug={params.project_slug}
/>
</div>
);
@@ -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 (
<div className="container flex max-w-7xl grow flex-col">
{/* header */}
<header className="border-border border-b py-10">
<div className="container flex flex-col justify-between gap-4 lg:flex-row lg:items-center">
<div className="flex flex-col gap-2">
<h1 className="flex items-center gap-2 font-semibold text-3xl tracking-tight">
Engine{" "}
<Badge
variant="warning"
className="flex items-center gap-2 text-sm"
>
<DatabaseIcon className="size-4" /> Dedicated
</Badge>
</h1>
<p className="flex flex-col text-muted-foreground text-sm">
Manage your deployed Engine instances
</p>
</div>
<div className="flex items-center gap-3">
<ImportEngineLink
label="Import Engine"
engineLinkPrefix={linkPrefix}
/>
</div>
</div>
<div className="h-6" />
<div className="container">
<EngineLegacyBannerUI
teamSlug={params.team_slug}
projectSlug={params.project_slug}
/>
</div>
</header>
{/* sidebar layout */}
<SidebarLayout sidebarLinks={sidebarLinks}>
{props.children}
</SidebarLayout>
</div>
);
}
function EngineLegacyBannerUI(props: {
teamSlug: string;
projectSlug: string;
}) {
return (
<Alert variant="info">
<EngineIcon className="size-5" />
<AlertTitle>Engine Cloud (Beta)</AlertTitle>
<AlertDescription>
<div className="h-2" />
<p className="text-primary-foreground text-sm">
Try Engine Cloud (Beta) - now included for free in every thirdweb
project.
</p>
<div className="h-2" />
<ul className="list-disc pl-4 text-muted-foreground text-sm">
<li>No recurring monthly cost, pay-per-request model</li>
<li>Powered by Vault: our new TEE based key management system</li>
<li>Improved performance and simplified transaction API</li>
</ul>
<div className="h-4" />
<div className="flex justify-end gap-2">
<Link href={"https://portal.thirdweb.com/engine/v3"} target="_blank">
<Button variant="outline">Learn More</Button>
</Link>
<Link
href={`/team/${props.teamSlug}/${props.projectSlug}/engine/cloud`}
>
<Button variant={"primary"} className="flex items-center gap-2">
<EngineIcon className="size-4" /> Try Engine Cloud
</Button>
</Link>
</div>
</AlertDescription>
</Alert>
);
}
@@ -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 (
<div className="flex grow flex-col">
@@ -127,14 +130,19 @@ export function EngineInstancesTableUI(props: {
</h2>
{props.instances.length === 0 ? (
<EmptyEngineState teamPlan={props.teamPlan} teamSlug={props.teamSlug} />
<EmptyEngineState
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
projectSlug={props.projectSlug}
/>
) : (
<TableContainer>
<Table>
<TableHeader>
<TableRow>
<TableHead>Engine Instance</TableHead>
<TableHead>Actions</TableHead>
<TableHead className="w-full">Engine Instance</TableHead>
<TableHead className="w-[100px]">Version</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -177,7 +185,7 @@ function EngineInstanceRow(props: {
>
<TableCell>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="flex-grow space-y-0.5">
<InstanceNameLink
instance={instance}
engineLinkPrefix={engineLinkPrefix}
@@ -191,6 +199,9 @@ function EngineInstanceRow(props: {
)}
</div>
</TableCell>
<TableCell>
<Badge variant="warning">v2</Badge>
</TableCell>
<TableCell className="relative z-10 w-[50px]">
<EngineActionsDropdown
instance={instance}
@@ -806,6 +817,7 @@ function DeleteEngineSubscriptionModalContent(props: {
function EmptyEngineState(props: {
teamPlan: Team["billingPlan"];
teamSlug: string;
projectSlug: string;
}) {
const [selectedTab, setSelectedTab] = useState<
"self-hosted" | "cloud-hosted"
@@ -834,7 +846,7 @@ function EmptyEngineState(props: {
className={cn(selectedTab === "cloud-hosted" && "bg-accent")}
onClick={() => setSelectedTab("cloud-hosted")}
>
Cloud-hosted
Managed
</Button>
<Button
@@ -866,7 +878,9 @@ function EmptyEngineState(props: {
size="sm"
asChild
>
<Link href={`/team/${props.teamSlug}/~/engine/import`}>
<Link
href={`/team/${props.teamSlug}/${props.projectSlug}/engine/dedicated/import`}
>
Import self-hosted Engine
<ArrowRightIcon size={16} />
</Link>
@@ -878,21 +892,20 @@ function EmptyEngineState(props: {
{selectedTab === "cloud-hosted" && (
<div className="flex flex-col text-center">
<h3 className="mb-0.5 font-semibold text-base">
Cloud-Hosted Engine
Managed Engine
</h3>
<p className="text-muted-foreground text-sm">
{props.teamPlan !== "pro" ? (
<>
Upgrade your team plan to Accelerate or Scale to get a
fully managed cloud-hosted engine instance.{" "}
fully managed engine instance.{" "}
<UnderlineLink href="/pricing" target="_blank">
View pricing
</UnderlineLink>
</>
) : (
<>
Contact us to add a cloud-hosted engine instance to your
team
Contact us to add a managed engine instance to your team
</>
)}
</p>
@@ -5,22 +5,28 @@ import { EngineInstancesTable } from "./engine-instances-table";
export const EngineInstancesList = (props: {
team_slug: string;
project_slug: string;
instances: EngineInstance[];
teamPlan: Team["billingPlan"];
}) => {
const engineLinkPrefix = `/team/${props.team_slug}/~/engine`;
const engineLinkPrefix = `/team/${props.team_slug}/${props.project_slug}/engine/dedicated`;
return (
<div className="flex grow flex-col">
<EngineInstancesTable
teamSlug={props.team_slug}
projectSlug={props.project_slug}
instances={props.instances}
engineLinkPrefix={engineLinkPrefix}
teamPlan={props.teamPlan}
/>
<div className="h-40" />
<EngineFooterCard teamPlan={props.teamPlan} team_slug={props.team_slug} />
<EngineFooterCard
teamPlan={props.teamPlan}
team_slug={props.team_slug}
project_slug={props.project_slug}
/>
</div>
);
};
@@ -8,6 +8,7 @@ import { EngineInstancesList } from "./overview/engine-list";
export default async function Page(props: {
params: Promise<{
team_slug: string;
project_slug: string;
}>;
searchParams: Promise<{
importUrl?: string;
@@ -20,7 +21,7 @@ export default async function Page(props: {
if (searchParams.importUrl) {
redirect(
`/team/${params.team_slug}/~/engine/import?importUrl=${searchParams.importUrl}`,
`/team/${params.team_slug}/${params.project_slug}/engine/dedicated/import?importUrl=${searchParams.importUrl}`,
);
}
@@ -30,7 +31,9 @@ export default async function Page(props: {
]);
if (!authToken) {
loginRedirect(`/team/${params.team_slug}/~/engine`);
loginRedirect(
`/team/${params.team_slug}/${params.project_slug}/engine/dedicated`,
);
}
if (!team) {
@@ -45,6 +48,7 @@ export default async function Page(props: {
return (
<EngineInstancesList
team_slug={params.team_slug}
project_slug={params.project_slug}
instances={res.data || []}
teamPlan={team.billingPlan}
/>
@@ -51,9 +51,10 @@ const sidebarLinkMeta: Array<{ pathId: string; label: string }> = [
export function EngineSidebarLayout(props: {
engineId: string;
teamSlug: string;
projectSlug: string;
children: React.ReactNode;
}) {
const rootPath = `/team/${props.teamSlug}/~/engine`;
const rootPath = `/team/${props.teamSlug}/${props.projectSlug}/engine/dedicated`;
const links: SidebarLink[] = sidebarLinkMeta.map((linkMeta) => {
return {
@@ -1,9 +1,9 @@
"use client";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
import type { EngineInstance } from "@3rdweb-sdk/react/hooks/useEngine";
import { useQuery } from "@tanstack/react-query";
import type React from "react";
import GenericLoadingPage from "../../../../ecosystem/loading";
import { getEngineAccessPermission } from "../../../_utils/getEngineAccessPermission";
import { EngineErrorPage } from "./EngineErrorPage";
@@ -13,10 +13,11 @@ export function EnsureEnginePermission(props: {
authToken: string;
children: React.ReactNode;
teamSlug: string;
projectSlug: string;
instance: EngineInstance;
}) {
const { instance } = props;
const rootPath = `/team/${props.teamSlug}/~/engine`;
const rootPath = `/team/${props.teamSlug}/${props.projectSlug}/engine/dedicated`;
const permissionQuery = useQuery({
queryKey: [
@@ -7,6 +7,7 @@ export default async function Page(props: EngineInstancePageProps) {
const { instance, authToken } = await engineInstancePageHandler({
engineId: params.engineId,
teamSlug: params.team_slug,
projectSlug: params.project_slug,
});
return (
@@ -7,6 +7,7 @@ export default async function Page(props: EngineInstancePageProps) {
const { instance, authToken } = await engineInstancePageHandler({
engineId: params.engineId,
teamSlug: params.team_slug,
projectSlug: params.project_slug,
});
return <EngineAdmins instanceUrl={instance.url} authToken={authToken} />;
@@ -239,7 +239,7 @@ function CreateAlertButton(props: {
<ToolTipLabel
label={
props.alertRules.length === 0
? "This feature is only available for cloud-hosted Engines running v2.0.10 or later."
? "This feature is only available for managed Engines running v2.0.10 or later."
: undefined
}
>
@@ -7,6 +7,7 @@ export default async function Page(props: EngineInstancePageProps) {
const { instance } = await engineInstancePageHandler({
engineId: params.engineId,
teamSlug: params.team_slug,
projectSlug: params.project_slug,
});
return (
@@ -9,12 +9,14 @@ import { EngineSystem } from "./system";
interface EngineConfigurationProps {
instance: EngineInstance;
teamSlug: string;
projectSlug: string;
authToken: string;
}
export const EngineConfiguration: React.FC<EngineConfigurationProps> = ({
instance,
teamSlug,
projectSlug,
authToken,
}) => {
return (
@@ -22,6 +24,7 @@ export const EngineConfiguration: React.FC<EngineConfigurationProps> = ({
<EngineWalletConfig
instance={instance}
teamSlug={teamSlug}
projectSlug={projectSlug}
authToken={authToken}
/>
<EngineCorsConfig instanceUrl={instance.url} authToken={authToken} />
@@ -29,7 +32,11 @@ export const EngineConfiguration: React.FC<EngineConfigurationProps> = ({
instanceUrl={instance.url}
authToken={authToken}
/>
<EngineSystem instance={instance} teamIdOrSlug={teamSlug} />
<EngineSystem
instance={instance}
teamIdOrSlug={teamSlug}
projectSlug={projectSlug}
/>
</div>
);
};
@@ -23,12 +23,14 @@ import { LocalConfig } from "./local-config";
interface EngineWalletConfigProps {
instance: EngineInstance;
teamSlug: string;
projectSlug: string;
authToken: string;
}
export const EngineWalletConfig: React.FC<EngineWalletConfigProps> = ({
instance,
teamSlug,
projectSlug,
authToken,
}) => {
const { data: walletConfig } = useEngineWalletConfig({
@@ -72,7 +74,7 @@ export const EngineWalletConfig: React.FC<EngineWalletConfigProps> = ({
<p className="text-muted-foreground">
Create backend wallets on the{" "}
<Link
href={`/team/${teamSlug}/~/engine/${instance.id}`}
href={`/team/${teamSlug}/${projectSlug}/engine/dedicated/${instance.id}`}
className="text-link-foreground hover:text-foreground"
>
Overview
@@ -7,14 +7,20 @@ import {
interface EngineSystemProps {
instance: EngineInstance;
teamIdOrSlug: string;
projectSlug: string;
}
export const EngineSystem: React.FC<EngineSystemProps> = ({
instance,
teamIdOrSlug,
projectSlug,
}) => {
const healthQuery = useEngineSystemHealth(instance.url);
const metricsQuery = useEngineSystemMetrics(instance.id, teamIdOrSlug);
const metricsQuery = useEngineSystemMetrics(
instance.id,
teamIdOrSlug,
projectSlug,
);
if (!healthQuery.data) {
return null;
}
@@ -7,12 +7,14 @@ export default async function Page(props: EngineInstancePageProps) {
const { instance, authToken } = await engineInstancePageHandler({
engineId: params.engineId,
teamSlug: params.team_slug,
projectSlug: params.project_slug,
});
return (
<EngineConfiguration
instance={instance}
teamSlug={params.team_slug}
projectSlug={params.project_slug}
authToken={authToken}
/>
);
@@ -7,6 +7,7 @@ export default async function Page(props: EngineInstancePageProps) {
const { instance, authToken } = await engineInstancePageHandler({
engineId: params.engineId,
teamSlug: params.team_slug,
projectSlug: params.project_slug,
});
return (
@@ -7,6 +7,7 @@ export default async function Page(props: EngineInstancePageProps) {
const { instance, authToken } = await engineInstancePageHandler({
engineId: params.engineId,
teamSlug: params.team_slug,
projectSlug: params.project_slug,
});
return <EngineExplorer instanceUrl={instance.url} authToken={authToken} />;

Some files were not shown because too many files have changed in this diff Show More