Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e6daac6dd | ||
|
|
5f6403bb8d | ||
|
|
4c5fc839d6 | ||
|
|
f6eff13492 | ||
|
|
ebbda37d5a | ||
|
|
c81fed3617 | ||
|
|
ec50f8cc3c | ||
|
|
3315a53653 | ||
|
|
4e42158c7b | ||
|
|
5a069fe657 | ||
|
|
490497c68b |
+2
-1
@@ -30,4 +30,5 @@ packages/*/typedoc/*
|
||||
storybook-static
|
||||
.aider*
|
||||
|
||||
tsconfig.tsbuildinfo
|
||||
tsconfig.tsbuildinfo
|
||||
.cursor
|
||||
@@ -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=""
|
||||
@@ -12,6 +12,7 @@
|
||||
"ignoreDependencies": [
|
||||
"@storybook/blocks",
|
||||
"@thirdweb-dev/service-utils",
|
||||
"@thirdweb-dev/vault-sdk",
|
||||
"@types/color",
|
||||
"fast-xml-parser"
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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";
|
||||
|
||||
+1
-1
@@ -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",
|
||||
},
|
||||
|
||||
+16
-4
@@ -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={[
|
||||
{
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
+43
@@ -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>
|
||||
);
|
||||
}
|
||||
+51
@@ -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>
|
||||
);
|
||||
}
|
||||
+134
@@ -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>
|
||||
);
|
||||
}
|
||||
+299
@@ -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>
|
||||
);
|
||||
}
|
||||
+96
@@ -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} />
|
||||
);
|
||||
}
|
||||
+206
@@ -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>
|
||||
);
|
||||
}
|
||||
+73
@@ -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>
|
||||
);
|
||||
}
|
||||
+414
@@ -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>
|
||||
);
|
||||
}
|
||||
+57
@@ -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;
|
||||
}
|
||||
+93
@@ -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;
|
||||
};
|
||||
+24
@@ -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>
|
||||
);
|
||||
}
|
||||
+11
@@ -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>
|
||||
);
|
||||
}
|
||||
+202
@@ -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];
|
||||
}
|
||||
+14
@@ -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",
|
||||
});
|
||||
}
|
||||
+317
@@ -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>
|
||||
);
|
||||
}
|
||||
+179
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+412
@@ -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);
|
||||
}
|
||||
}`;
|
||||
+61
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
export type Wallet = {
|
||||
id: string;
|
||||
address: string;
|
||||
metadata: {
|
||||
type: string;
|
||||
projectId: string;
|
||||
label?: string;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
+191
@@ -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>
|
||||
);
|
||||
}
|
||||
+24
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+25
@@ -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>
|
||||
);
|
||||
}
|
||||
+38
@@ -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>
|
||||
);
|
||||
}
|
||||
+334
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+285
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+104
@@ -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>
|
||||
);
|
||||
}
|
||||
+361
@@ -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)}`;
|
||||
}
|
||||
+327
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+33
@@ -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>
|
||||
);
|
||||
}
|
||||
+1
@@ -6,6 +6,7 @@ const meta = {
|
||||
component: EngineFooterCard,
|
||||
args: {
|
||||
team_slug: "demo-team",
|
||||
project_slug: "demo-project",
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
+10
-6
@@ -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>
|
||||
);
|
||||
}
|
||||
+4
-1
@@ -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`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
+2
-1
@@ -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>
|
||||
);
|
||||
+111
@@ -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>
|
||||
);
|
||||
}
|
||||
+23
-10
@@ -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>
|
||||
+8
-2
@@ -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>
|
||||
);
|
||||
};
|
||||
+6
-2
@@ -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}
|
||||
/>
|
||||
+2
-1
@@ -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 {
|
||||
+3
-2
@@ -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: [
|
||||
+1
@@ -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 (
|
||||
+1
@@ -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} />;
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
>
|
||||
+1
@@ -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 (
|
||||
+8
-1
@@ -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>
|
||||
);
|
||||
};
|
||||
+3
-1
@@ -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
-1
@@ -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;
|
||||
}
|
||||
+2
@@ -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}
|
||||
/>
|
||||
);
|
||||
+1
@@ -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 (
|
||||
+1
@@ -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
Reference in New Issue
Block a user