diff --git a/.changeset/some-mice-own.md b/.changeset/some-mice-own.md new file mode 100644 index 00000000000..f82f41450b4 --- /dev/null +++ b/.changeset/some-mice-own.md @@ -0,0 +1,5 @@ +--- +"@thirdweb-dev/service-utils": patch +--- + +add dedicated relayer to service utils diff --git a/apps/dashboard/public/assets/dedicated-relayer/monitoring-dark.png b/apps/dashboard/public/assets/dedicated-relayer/monitoring-dark.png new file mode 100644 index 00000000000..a55074a25c4 Binary files /dev/null and b/apps/dashboard/public/assets/dedicated-relayer/monitoring-dark.png differ diff --git a/apps/dashboard/public/assets/dedicated-relayer/monitoring-light.png b/apps/dashboard/public/assets/dedicated-relayer/monitoring-light.png new file mode 100644 index 00000000000..f20dc8f646a Binary files /dev/null and b/apps/dashboard/public/assets/dedicated-relayer/monitoring-light.png differ diff --git a/apps/dashboard/public/assets/dedicated-relayer/no-config-dark.png b/apps/dashboard/public/assets/dedicated-relayer/no-config-dark.png new file mode 100644 index 00000000000..376c199b139 Binary files /dev/null and b/apps/dashboard/public/assets/dedicated-relayer/no-config-dark.png differ diff --git a/apps/dashboard/public/assets/dedicated-relayer/no-config-light.png b/apps/dashboard/public/assets/dedicated-relayer/no-config-light.png new file mode 100644 index 00000000000..d509a4c7718 Binary files /dev/null and b/apps/dashboard/public/assets/dedicated-relayer/no-config-light.png differ diff --git a/apps/dashboard/public/assets/dedicated-relayer/server-wallet-dark.png b/apps/dashboard/public/assets/dedicated-relayer/server-wallet-dark.png new file mode 100644 index 00000000000..09ad42b1b91 Binary files /dev/null and b/apps/dashboard/public/assets/dedicated-relayer/server-wallet-dark.png differ diff --git a/apps/dashboard/public/assets/dedicated-relayer/server-wallet-light.png b/apps/dashboard/public/assets/dedicated-relayer/server-wallet-light.png new file mode 100644 index 00000000000..aec90e46796 Binary files /dev/null and b/apps/dashboard/public/assets/dedicated-relayer/server-wallet-light.png differ diff --git a/apps/dashboard/src/@/types/billing.ts b/apps/dashboard/src/@/types/billing.ts index cfa6f45df3d..211c5421919 100644 --- a/apps/dashboard/src/@/types/billing.ts +++ b/apps/dashboard/src/@/types/billing.ts @@ -13,9 +13,16 @@ export type ProductSKU = | "usage:in_app_wallet" | "usage:aa_sponsorship" | "usage:aa_sponsorship_op_grant" + // dedicated relayer SKUs + | DedicatedRelayerSKU | null; export type ChainInfraSKU = | "chain:infra:rpc" | "chain:infra:insight" | "chain:infra:account_abstraction"; + +export type DedicatedRelayerSKU = + | "product:dedicated_relayer_standard" + | "product:dedicated_relayer_premium" + | "product:dedicated_relayer_enterprise"; diff --git a/apps/dashboard/src/app/(app)/(stripe)/checkout/[team_slug]/[sku]/page.tsx b/apps/dashboard/src/app/(app)/(stripe)/checkout/[team_slug]/[sku]/page.tsx index 3d684adad47..92d38a54a78 100644 --- a/apps/dashboard/src/app/(app)/(stripe)/checkout/[team_slug]/[sku]/page.tsx +++ b/apps/dashboard/src/app/(app)/(stripe)/checkout/[team_slug]/[sku]/page.tsx @@ -17,15 +17,21 @@ export default async function CheckoutPage(props: { searchParams: Promise<{ amount?: string; invoice_id?: string; + project_id?: string; + chain_id?: string | string[]; }>; }) { - const params = await props.params; + const [params, searchParams] = await Promise.all([ + props.params, + props.searchParams, + ]); + + console.log("params", params); + console.log("searchParams", searchParams); switch (params.sku) { case "topup": { - const amountUSD = Number.parseInt( - (await props.searchParams).amount || "10", - ); + const amountUSD = Number.parseInt(searchParams.amount || "10"); if (Number.isNaN(amountUSD)) { return ; } @@ -43,7 +49,7 @@ export default async function CheckoutPage(props: { break; } case "invoice": { - const invoiceId = (await props.searchParams).invoice_id; + const invoiceId = searchParams.invoice_id; if (!invoiceId) { return ; } @@ -59,10 +65,12 @@ export default async function CheckoutPage(props: { redirect(invoice); break; } + default: { const response = await getBillingCheckoutUrl({ sku: decodeURIComponent(params.sku) as Exclude, teamSlug: params.team_slug, + params: searchParams, }); if (response.status === "error") { diff --git a/apps/dashboard/src/app/(app)/(stripe)/utils/billing.ts b/apps/dashboard/src/app/(app)/(stripe)/utils/billing.ts index 906fc538695..16af4bc82f8 100644 --- a/apps/dashboard/src/app/(app)/(stripe)/utils/billing.ts +++ b/apps/dashboard/src/app/(app)/(stripe)/utils/billing.ts @@ -7,6 +7,10 @@ import { getAbsoluteUrl } from "@/utils/vercel"; export async function getBillingCheckoutUrl(options: { teamSlug: string; sku: Exclude; + params: { + project_id?: string; + chain_id?: string | string[]; + }; }) { const token = await getAuthToken(); @@ -17,13 +21,26 @@ export async function getBillingCheckoutUrl(options: { } as const; } + const chainIds = options.params.chain_id + ? (Array.isArray(options.params.chain_id) + ? options.params.chain_id + : [options.params.chain_id] + ) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value)) + : undefined; + + const body = { + redirectTo: getAbsoluteStripeRedirectUrl(), + sku: options.sku, + projectId: options.params.project_id, + chainIds: chainIds, + }; + const res = await fetch( `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamSlug}/checkout/create-link`, { - body: JSON.stringify({ - redirectTo: getAbsoluteStripeRedirectUrl(), - sku: options.sku, - }), + body: JSON.stringify(body), headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx index e0eefd78b12..ffec22ee25d 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx @@ -22,6 +22,7 @@ export function ProjectSidebarLayout(props: { children: React.ReactNode; hasEngines: boolean; showContracts: boolean; + teamId: string; }) { const contentSidebarLinks = [ { @@ -51,6 +52,14 @@ export function ProjectSidebarLayout(props: { href: `${props.layoutPath}/wallets/sponsored-gas`, label: "Gas Sponsorship", }, + ...(props.teamId === "team_clmb33q9w00gn1x0u2ri8z0k0" + ? [ + { + href: `${props.layoutPath}/wallets/dedicated-relayer`, + label: "Dedicated Relayer", + }, + ] + : []), ], }, ...(props.showContracts diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/layout.tsx index 9d23f672934..aa61eb25fd8 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/layout.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/layout.tsx @@ -107,6 +107,7 @@ export default async function ProjectLayout(props: { layoutPath={layoutPath} hasEngines={hasLegacyDedicatedEngines} showContracts={showContracts} + teamId={team.id} > {props.children} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/components/active-state.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/components/active-state.tsx new file mode 100644 index 00000000000..04b52f5517a --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/components/active-state.tsx @@ -0,0 +1,379 @@ +"use client"; + +import { format, formatDistance } from "date-fns"; +import { ExternalLinkIcon, XIcon } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import type { ThirdwebClient } from "thirdweb"; +import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; +import { PaginationButtons } from "@/components/blocks/pagination-buttons"; +import { WalletAddress } from "@/components/blocks/wallet-address"; +import { Button } from "@/components/ui/button"; +import { CopyTextButton } from "@/components/ui/CopyTextButton"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { ToolTipLabel } from "@/components/ui/tooltip"; +import { useAllChainsData } from "@/hooks/chains/allChains"; +import { ChainIconClient } from "@/icons/ChainIcon"; +import { cn } from "@/lib/utils"; +import { + useFleetTransactions, + useFleetTransactionsSummary, +} from "../lib/hooks"; +import type { Fleet, FleetTransaction } from "../types"; + +type DedicatedRelayerActiveStateProps = { + fleet: Fleet; + teamId: string; + fleetId: string; + client: ThirdwebClient; + from: string; + to: string; + className?: string; +}; + +export function DedicatedRelayerActiveState( + props: DedicatedRelayerActiveStateProps, +) { + const { fleet, teamId, fleetId, client, from, to } = props; + + const pageSize = 10; + const [page, setPage] = useState(1); + const [chainIdFilter, setChainIdFilter] = useState(); + + const summaryQuery = useFleetTransactionsSummary({ + teamId, + fleetId, + from, + to, + }); + + const transactionsQuery = useFleetTransactions({ + teamId, + fleetId, + from, + to, + limit: pageSize, + offset: (page - 1) * pageSize, + chainId: chainIdFilter, + }); + + const totalPages = transactionsQuery.data + ? Math.ceil(transactionsQuery.data.meta.total / pageSize) + : 0; + + return ( +
+ {/* Summary Stats */} +
+ + + +
+ + {/* Executors Info */} +
+
+

+ Fleet Executors +

+
+
+
+ {fleet.executors.map((address) => ( +
+ +
+ ))} +
+
+
+ + {/* Transactions Table */} +
+
+

Fleet Transactions

+
+ { + setChainIdFilter(chainId); + setPage(1); + }} + client={client} + /> +
+
+ + + + + + Transaction Hash + Chain + Wallet + Executor + Timestamp + Fee + + + + {!transactionsQuery.isPending + ? transactionsQuery.data?.data.map((tx) => ( + + )) + : Array.from({ length: pageSize }).map((_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton rows have no unique id + + ))} + +
+
+ + {!transactionsQuery.isPending && + (transactionsQuery.isError ? ( +
+
+
+ +
+
+

+ Failed to load transactions +

+
+ ) : transactionsQuery.data?.data.length === 0 ? ( +
+
+
+ +
+
+

+ No transactions yet +

+
+ ) : null)} + + {totalPages > 1 && ( +
+ +
+ )} +
+
+ ); +} + +function StatCard(props: { + title: string; + value: string; + isLoading?: boolean; +}) { + return ( +
+

{props.title}

+ {props.isLoading ? ( + + ) : ( +

{props.value}

+ )} +
+ ); +} + +function TransactionRow(props: { + transaction: FleetTransaction; + client: ThirdwebClient; +}) { + const { transaction, client } = props; + const utcTimestamp = transaction.timestamp.endsWith("Z") + ? transaction.timestamp + : `${transaction.timestamp}Z`; + + return ( + + + + + + + + + + + + + + + + + {formatDistance(new Date(utcTimestamp), new Date(), { + addSuffix: true, + })} + + + + + + + + ); +} + +function SkeletonRow() { + return ( + + + + + + + + + + + + + + + + + + + + + ); +} + +function TransactionHashCell(props: { hash: string; chainId: string }) { + const { idToChain } = useAllChainsData(); + const chain = idToChain.get(Number(props.chainId)); + + const explorerUrl = chain?.explorers?.[0]?.url; + const txHashToShow = `${props.hash.slice(0, 6)}...${props.hash.slice(-4)}`; + + if (explorerUrl) { + return ( + + ); + } + + return ( + + ); +} + +function ChainCell(props: { chainId: string; client: ThirdwebClient }) { + const { idToChain, allChains } = useAllChainsData(); + const chain = idToChain.get(Number(props.chainId)); + + if (allChains.length === 0) { + return ; + } + + return ( +
+ + + {chain ? chain.name : `Chain #${props.chainId}`} + +
+ ); +} + +function TransactionFeeCell(props: { usdValue: number }) { + return ( + + ${props.usdValue < 0.01 ? "<0.01" : props.usdValue.toFixed(2)} + + ); +} + +function ChainFilter(props: { + chainId: number | undefined; + setChainId: (chainId: number | undefined) => void; + client: ThirdwebClient; +}) { + return ( +
+ props.setChainId(chainId)} + popoverContentClassName="z-[10001]" + align="end" + placeholder="All Chains" + className="min-w-[150px]" + /> +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/components/empty-state.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/components/empty-state.tsx new file mode 100644 index 00000000000..af1f07381a1 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/components/empty-state.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { Img } from "@workspace/ui/components/img"; +import { useTheme } from "next-themes"; +import { useState } from "react"; +import { toast } from "sonner"; +import type { ThirdwebClient } from "thirdweb"; +import { MultiNetworkSelector } from "@/components/blocks/NetworkSelectors"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import type { DedicatedRelayerSKU } from "@/types/billing"; +import { RELAYER_SUPPORTED_CHAINS } from "../constants"; +import { PlanSection } from "./tier-selection"; + +type DedicatedRelayerEmptyStateProps = { + teamSlug: string; + projectSlug: string; + onPurchaseTier: ( + tier: DedicatedRelayerSKU, + chainIds: number[], + ) => Promise; + client: ThirdwebClient; + className?: string; +}; + +/** + * Empty state shown when user hasn't purchased a dedicated relayer fleet. + * Shows tier selection for purchasing. + */ +export function DedicatedRelayerEmptyState( + props: DedicatedRelayerEmptyStateProps, +) { + const [isLoading, setIsLoading] = useState(false); + const [selectedTier, setSelectedTier] = useState( + null, + ); + const [isModalOpen, setIsModalOpen] = useState(false); + const [selectedChainIds, setSelectedChainIds] = useState([]); + + const handleSelectTier = async (tier: DedicatedRelayerSKU) => { + if (tier === "product:dedicated_relayer_enterprise") { + window.open("https://thirdweb.com/contact-us", "_blank"); + return; + } + + setSelectedTier(tier); + setSelectedChainIds([]); + setIsModalOpen(true); + }; + + const handlePurchase = async () => { + if (!selectedTier) return; + setIsLoading(true); + try { + await props.onPurchaseTier(selectedTier, selectedChainIds); + setIsModalOpen(false); + setSelectedTier(null); + setSelectedChainIds([]); + } catch (error) { + console.error(error); + toast.error("Failed to purchase dedicated relayer"); + } finally { + setIsLoading(false); + } + }; + + const requiredChains = + selectedTier === "product:dedicated_relayer_standard" ? 2 : 4; + + return ( +
+ + + + + + + Select Chains + +
+

+ Select {requiredChains} chains for your dedicated relayer. +

+ +
+ + + +
+
+
+ ); +} + +function FeatureSection() { + return ( +
+ + + + + +
+ ); +} + +function FeatureCard(props: { + title: string; + description: string; + className?: string; + images: { + darkSrc: string; + lightSrc: string; + }; +}) { + const { resolvedTheme } = useTheme(); + const imageSrc = + resolvedTheme === "light" ? props.images.lightSrc : props.images.darkSrc; + + return ( +
+ +
+

+ {props.title} +

+

+ {props.description} +

+
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/components/page-client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/components/page-client.tsx new file mode 100644 index 00000000000..4e90c38aa65 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/components/page-client.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { ThirdwebClient } from "thirdweb"; +import type { DedicatedRelayerSKU } from "@/types/billing"; +import { getAbsoluteUrl } from "@/utils/vercel"; +import { useFleetStatus, useFleetTransactionsSummary } from "../lib/hooks"; +import type { Fleet, FleetStatus } from "../types"; +import { DedicatedRelayerActiveState } from "./active-state"; +import { DedicatedRelayerEmptyState } from "./empty-state"; +import { DedicatedRelayerPendingState } from "./pending-state"; + +type DedicatedRelayerPageClientProps = { + teamId: string; + projectId: string; + teamSlug: string; + projectSlug: string; + client: ThirdwebClient; + fleetId: string; + from: string; + to: string; + initialFleet: Fleet | null; +}; + +export function DedicatedRelayerPageClient( + props: DedicatedRelayerPageClientProps, +) { + const [fleet, _setFleet] = useState(props.initialFleet); + const [fleetStatus, _setFleetStatus] = useState(() => + getInitialStatus(props.initialFleet), + ); + + // Poll for fleet status when not purchased or pending setup + const fleetStatusQuery = useFleetStatus( + props.teamSlug, + props.projectSlug, + fleetStatus === "not-purchased" || fleetStatus === "pending-setup", + ); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (fleetStatusQuery.data) { + _setFleet(fleetStatusQuery.data); + _setFleetStatus(getInitialStatus(fleetStatusQuery.data)); + } + }, [fleetStatusQuery.data]); + + // Only fetch transactions summary when we have an active fleet with executors + const summaryQuery = useFleetTransactionsSummary({ + teamId: props.teamId, + fleetId: props.fleetId, + from: props.from, + to: props.to, + enabled: fleetStatus === "active", + refetchInterval: 5000, + }); + + const totalTransactions = summaryQuery.data?.data.totalTransactions ?? 0; + const hasTransactions = totalTransactions > 0; + + // TODO-FLEET: Implement purchase flow + // 1. Call Stripe checkout API to create a checkout session for the selected tier + // 2. Redirect user to Stripe checkout + // 3. On success callback, call API server to provision fleet + // 4. API server should: + // - Create fleet record in DB + // - Call bundler service to provision executor wallets + // - Update ProjectBundlerService with fleet config + // 5. Refetch fleet status and update UI + const handlePurchaseTier = async ( + sku: DedicatedRelayerSKU, + chainIds: number[], + ) => { + const search = new URLSearchParams(); + search.set("project_id", props.projectId); + for (const chainId of chainIds) { + search.append("chain_id", chainId.toString()); + } + + // Redirect to Stripe checkout + window.open( + `${getAbsoluteUrl()}/checkout/${props.teamSlug}/${sku}?${search.toString()}`, + "_blank", + ); + }; + + return ( +
+ {fleetStatus === "not-purchased" && ( + + )} + + {fleetStatus === "pending-setup" && fleet && ( + + )} + + {fleetStatus === "active" && fleet && !hasTransactions && ( + + )} + + {fleetStatus === "active" && fleet && hasTransactions && ( + + )} +
+ ); +} + +function getInitialStatus(fleet: Fleet | null): FleetStatus { + if (!fleet) { + return "not-purchased"; + } + if (fleet.executors.length === 0) { + return "pending-setup"; + } + return "active"; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/components/pending-state.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/components/pending-state.tsx new file mode 100644 index 00000000000..2e9cefdb052 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/components/pending-state.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { ClockIcon, Loader2Icon } from "lucide-react"; +import { CodeClient } from "@/components/ui/code/code.client"; +import { useV5DashboardChain } from "@/hooks/chains/v5-adapter"; +import { cn } from "@/lib/utils"; +import type { Fleet } from "../types"; + +type DedicatedRelayerPendingStateProps = { + fleet: Fleet; + hasTransactions?: boolean; + className?: string; +}; + +/** + * Pending state shown when fleet is purchased but executors are not yet set up. + * Shows the requested chain IDs and a "setting up" status. + */ +export function DedicatedRelayerPendingState( + props: DedicatedRelayerPendingStateProps, +) { + const { fleet, hasTransactions } = props; + const hasExecutors = fleet.executors.length > 0; + + return ( +
+ {/* Status Banner */} +
+ +
+

+ {hasExecutors + ? "Waiting for first transaction" + : "Setting up your dedicated relayer"} +

+

+ {hasExecutors + ? "Your dedicated relayer is ready. Waiting for the first transaction to appear." + : "Your relayer is being provisioned. This usually takes 48 to 72 hours."} +

+
+
+ + {/* Fleet Details Preview */} +
+
+

+ Fleet Configuration +

+
+ +
+
+ {/* Requested Chains */} +
+

+ Requested Chains +

+
+ {fleet.chainIds.map((chainId) => ( + + ))} +
+
+ + {/* Status */} +
+

+ Status +

+
+ + + {hasExecutors + ? "Active, waiting for transactions" + : "Awaiting executor deployment"} + +
+
+
+
+
+ + {/* What to Expect */} +
+
+

+ What to Expect +

+
+ +
+
    + + + +
+
+
+ + {/* Test Transaction Snippet */} + {hasExecutors && ( +
+
+

+ Send a Test Transaction +

+
+
+

+ You can send a test transaction using the thirdweb API. Replace{" "} + + YOUR_SECRET_KEY + {" "} + with your actual secret key. +

+ +
+
+ )} +
+ ); +} + +function ChainBadge(props: { chainId: number }) { + const chain = useV5DashboardChain(props.chainId); + const label = chain?.name ?? `Chain ${props.chainId}`; + return ( + + {label} + + ); +} + +function SetupStep(props: { + number: number; + title: string; + completed: boolean; + inProgress?: boolean; +}) { + return ( +
  • +
    + {props.inProgress ? ( + + ) : ( + props.number + )} +
    + + {props.title} + +
  • + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/components/tier-selection.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/components/tier-selection.tsx new file mode 100644 index 00000000000..5024778355e --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/components/tier-selection.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { + ArrowUpRightIcon, + BoxesIcon, + FolderCogIcon, + FolderIcon, + FoldersIcon, +} from "lucide-react"; +import Link from "next/link"; +import type React from "react"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/Spinner"; +import { WalletProductIcon } from "@/icons/WalletProductIcon"; +import { cn } from "@/lib/utils"; +import type { DedicatedRelayerSKU } from "@/types/billing"; + +type TierConfig = { + id: DedicatedRelayerSKU; + icon: React.FC<{ className?: string }>; + name: string; + description: string; + price: string; + isPerMonth?: boolean; + features: Array<{ icon: React.FC<{ className?: string }>; label: string }>; + cta: string; + isRecommended?: boolean; +}; + +const TIERS: TierConfig[] = [ + { + id: "product:dedicated_relayer_standard", + icon: FolderIcon, + name: "Standard", + description: + "Most suitable for small startups and apps doing less than 10,000 transactions per day", + price: "$99", + isPerMonth: true, + features: [ + { icon: WalletProductIcon, label: "Single executor wallet" }, + { icon: BoxesIcon, label: "Support for up to 2 chains" }, + ], + cta: "Select", + }, + { + id: "product:dedicated_relayer_premium", + icon: FoldersIcon, + name: "Premium", + description: + "Best for large enterprise companies and apps doing 100,000+ transactions per day", + price: "$299", + isPerMonth: true, + features: [ + { + icon: WalletProductIcon, + label: "10 executor wallets (10x throughput)", + }, + { icon: BoxesIcon, label: "Support for up to 4 chains" }, + ], + cta: "Select", + isRecommended: true, + }, + { + id: "product:dedicated_relayer_enterprise", + icon: FolderCogIcon, + name: "Custom", + description: + "Contact us for more throughput with custom number of chains and executor wallets", + price: "Custom", + features: [ + { icon: WalletProductIcon, label: "Unlimited executor wallets" }, + { icon: BoxesIcon, label: "Unlimited chains" }, + ], + cta: "Contact Sales", + }, +]; + +export function PlanSection(props: { + onSelectTier: (tier: DedicatedRelayerSKU) => void; + isLoading?: boolean; + selectedTier?: DedicatedRelayerSKU | null; + className?: string; +}) { + return ( +
    +
    +

    Select a plan

    +
    +
    + {TIERS.map((tier, index) => ( + props.onSelectTier(tier.id)} + className={ + index !== 0 + ? "border-t lg:border-t-0 lg:border-l border-dashed" + : "" + } + isRecommended={tier.isRecommended} + /> + ))} +
    +
    + ); +} + +function PlanCard(props: { + tier: TierConfig; + onSelect: () => void; + isLoading?: boolean; + isDisabled?: boolean; + className?: string; + isRecommended?: boolean; +}) { + const { tier } = props; + const isEnterprise = tier.id === "product:dedicated_relayer_enterprise"; + + return ( +
    +
    +
    + +
    +
    +
    +

    + {tier.name} +

    + {props.isRecommended && ( + + Recommended + + )} +
    +
    +

    + {tier.description} +

    +
    + +
    + + {tier.price} + + {tier.isPerMonth && ( + / month + )} +
    + +

    Includes:

    + +
      + {tier.features.map((feature) => ( +
    • + + {feature.label} +
    • + ))} +
    + + {isEnterprise ? ( + + ) : ( + + )} +
    + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/constants.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/constants.ts new file mode 100644 index 00000000000..37bb552eac6 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/constants.ts @@ -0,0 +1,72 @@ +export const RELAYER_SUPPORTED_CHAINS = [ + // MAINNETS + 1, // ethereum mainnet + 100, // gnosis mainnet + 56, // bsc mainnet + 10, // optimism mainnet + 8453, // base mainnet + 57073, // ink mainnet + 130, // unichain mainnet + 7777777, // zora mainnet + 1868, // soneium mainnet + 34443, // mode mainnet + 534352, // scroll mainnet + 42161, // arbitrum mainnet + 80094, // berachain mainnet + 137, // polygon mainnet + 185, // Mint Mainnet mainnet + 747, // EVM on Flow mainnet + 1135, // Lisk mainnet + 2020, // Ronin mainnet + 5330, // Superseed mainnet + 7897, // Arena-Z mainnet + 42170, // Arbitrum Nova mainnet + 60808, // BOB mainnet + 747474, // Katana mainnet + 1628, // T-REX mainnet + 466, // Appchain mainnet + 80888, // Onyx mainnet + 42220, // Celo mainnet + 1329, // Sei mainnet + 42793, // Etherlink mainnet + 143, // Monad mainnet + 252, // Fraxtal mainnet + // TESTNETS + 11155111, // sepolia testnet + 10200, // gnosis chiado testnet + 97, // bsc testnet + 11155420, // op sepolia testnet + 84532, // base sepolia testnet + 763373, // ink sepolia testnet + 1301, // unichain sepolia testnet + 999999999, // zora sepolia testnet + 1946, // soneium minato testnet + 919, // mode sepolia testnet + 534351, // scroll sepolia testnet + 421614, // arbitrum sepolia testnet + 80069, // berachain bepolia testnet + 9899, // Arena-Z Testnet testnet testnet + 1962, // T-Rex testnet testnet + 545, // EVM on Flow Testnet testnet + 1001, // Kaia Testnet Kairos testnet + 2021, // Saigon testnet testnet + 4202, // Lisk Sepolia Testnet testnet + 14853, // Humanode Testnet 5 Israfel testnet + 53302, // Superseed Sepolia Testnet testnet + 59141, // Linea Sepolia testnet + 129399, // Tatara testnet + 808813, // BOB Sepolia testnet + 1992, // Sanko Testnet testnet + 2030232745, // Lumia Beam Testnet testnet + 11142220, // Celo Sepolia Testnet testnet + 4661, // Appchain Testnet + 1328, // Sei Testnet + 128123, // Etherlink Testnet + 123420001114, // Basecamp testnet + 5042002, // Arc Testnet (Circle USDC Native) + 127823, // Etherlink Shadownet Testnet + 10143, // Monad Testnet (MON) + 1417429182, // Zephyr Testnet + 42429, // Tempo Testnet + 2523, // Fraxtal Testnet testnet +]; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/layout.tsx new file mode 100644 index 00000000000..93318436f87 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/layout.tsx @@ -0,0 +1,55 @@ +import { redirect } from "next/navigation"; +import { getAuthToken } from "@/api/auth-token"; +import { getProject } from "@/api/project/projects"; +import { ProjectPage } from "@/components/blocks/project-page/project-page"; +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import { WalletProductIcon } from "@/icons/WalletProductIcon"; +import { loginRedirect } from "@/utils/redirects"; + +export default async function Layout(props: { + children: React.ReactNode; + params: Promise<{ team_slug: string; project_slug: string }>; +}) { + const params = await props.params; + const basePath = `/team/${params.team_slug}/${params.project_slug}/wallets/dedicated-relayer`; + + const [authToken, project] = await Promise.all([ + getAuthToken(), + getProject(params.team_slug, params.project_slug), + ]); + + if (!authToken) { + loginRedirect(basePath); + } + + if (!project) { + redirect(`/team/${params.team_slug}`); + } + + const client = getClientThirdwebClient({ + jwt: authToken, + teamId: project.teamId, + }); + + return ( + + {props.children} + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/lib/api.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/lib/api.ts new file mode 100644 index 00000000000..fcfcf697eef --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/lib/api.ts @@ -0,0 +1,111 @@ +"use server"; + +import { analyticsServerProxy } from "@/actions/proxies"; +import { getProject } from "@/api/project/projects"; +import { + buildFleetId, + type Fleet, + type FleetTransaction, + type FleetTransactionsSummary, +} from "../types"; + +type GetFleetTransactionsParams = { + teamId: string; + fleetId: string; + from: string; + to: string; + limit: number; + offset: number; + chainId?: number; +}; + +/** + * Fetches paginated fleet transactions from the analytics service. + */ +export async function getFleetTransactions(params: GetFleetTransactionsParams) { + const res = await analyticsServerProxy<{ + data: FleetTransaction[]; + meta: { total: number }; + }>({ + method: "GET", + pathname: "/v2/bundler/fleet-transactions", + searchParams: { + teamId: params.teamId, + fleetId: params.fleetId, + from: params.from, + to: params.to, + limit: params.limit.toString(), + offset: params.offset.toString(), + ...(params.chainId && { chainId: params.chainId.toString() }), + }, + }); + + if (!res.ok) { + throw new Error(res.error); + } + + return res.data; +} + +type GetFleetTransactionsSummaryParams = { + teamId: string; + fleetId: string; + from: string; + to: string; +}; + +/** + * Fetches fleet transactions summary from the analytics service. + */ +export async function getFleetTransactionsSummary( + params: GetFleetTransactionsSummaryParams, +) { + const res = await analyticsServerProxy<{ + data: FleetTransactionsSummary; + }>({ + method: "GET", + pathname: "/v2/bundler/fleet-transactions/summary", + searchParams: { + teamId: params.teamId, + fleetId: params.fleetId, + from: params.from, + to: params.to, + }, + }); + + if (!res.ok) { + throw new Error(res.error); + } + + return res.data; +} + +/** + * Fetches the current fleet status for a project. + */ +export async function getFleetStatus( + teamSlug: string, + projectSlug: string, +): Promise { + const project = await getProject(teamSlug, projectSlug); + if (!project) return null; + + const bundlerService = project.services.find((s) => s.name === "bundler"); + const fleetConfig = + bundlerService && "dedicatedRelayer" in bundlerService + ? (bundlerService.dedicatedRelayer as { + sku: string; + chainIds: number[]; + executors: string[]; + } | null) + : null; + + if (fleetConfig) { + return { + id: buildFleetId(project.teamId, project.id), + chainIds: fleetConfig.chainIds, + executors: fleetConfig.executors, + }; + } + return null; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/lib/hooks.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/lib/hooks.ts new file mode 100644 index 00000000000..25319aa1f96 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/lib/hooks.ts @@ -0,0 +1,71 @@ +"use client"; + +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { + getFleetStatus, + getFleetTransactions, + getFleetTransactionsSummary, +} from "./api"; + +type UseFleetTransactionsParams = { + teamId: string; + fleetId: string; + from: string; + to: string; + limit: number; + offset: number; + chainId?: number; +}; + +/** + * React Query hook for fetching paginated fleet transactions. + */ +export function useFleetTransactions(params: UseFleetTransactionsParams) { + return useQuery({ + queryKey: ["fleet-transactions", params], + queryFn: () => getFleetTransactions(params), + placeholderData: keepPreviousData, + refetchOnWindowFocus: false, + }); +} + +type UseFleetTransactionsSummaryParams = { + teamId: string; + fleetId: string; + from: string; + to: string; + enabled?: boolean; + refetchInterval?: number; +}; + +/** + * React Query hook for fetching fleet transactions summary. + */ +export function useFleetTransactionsSummary( + params: UseFleetTransactionsSummaryParams, +) { + return useQuery({ + queryKey: ["fleet-transactions-summary", params], + queryFn: () => getFleetTransactionsSummary(params), + refetchOnWindowFocus: false, + enabled: params.enabled !== false, + refetchInterval: params.refetchInterval, + }); +} + +/** + * React Query hook for fetching fleet status. + * Polls every 5 seconds if enabled. + */ +export function useFleetStatus( + teamSlug: string, + projectSlug: string, + enabled: boolean, +) { + return useQuery({ + queryKey: ["fleet-status", teamSlug, projectSlug], + queryFn: () => getFleetStatus(teamSlug, projectSlug), + enabled, + refetchInterval: 5000, + }); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/page.tsx new file mode 100644 index 00000000000..54ba757b3fc --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/page.tsx @@ -0,0 +1,85 @@ +import { redirect } from "next/navigation"; +import { getAuthToken } from "@/api/auth-token"; +import { getProject } from "@/api/project/projects"; +import { getTeamBySlug } from "@/api/team/get-team"; +import { getLastNDaysRange } from "@/components/analytics/date-range-selector"; +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import { loginRedirect } from "@/utils/redirects"; +import { DedicatedRelayerPageClient } from "./components/page-client"; +import { buildFleetId, type Fleet } from "./types"; + +export const dynamic = "force-dynamic"; + +export default async function DedicatedRelayerPage(props: { + params: Promise<{ team_slug: string; project_slug: string }>; +}) { + const [params, authToken] = await Promise.all([props.params, getAuthToken()]); + + if (!authToken) { + loginRedirect( + `/team/${params.team_slug}/${params.project_slug}/wallets/dedicated-relayer`, + ); + } + + const [team, project] = await Promise.all([ + getTeamBySlug(params.team_slug), + getProject(params.team_slug, params.project_slug), + ]); + + if (!team) { + redirect("/team"); + } + + if (!project) { + redirect(`/team/${params.team_slug}`); + } + + const client = getClientThirdwebClient({ + jwt: authToken, + teamId: project.teamId, + }); + + // Build fleet ID from team and project + const fleetId = buildFleetId(team.id, project.id); + + // Default date range: last 30 days + const range = getLastNDaysRange("last-30"); + + // Extract fleet configuration from bundler service + const bundlerService = project.services.find((s) => s.name === "bundler"); + const fleetConfig = + bundlerService && "dedicatedRelayer" in bundlerService + ? (bundlerService.dedicatedRelayer as { + sku: string; + chainIds: number[]; + executors: string[]; + } | null) + : null; + + // Convert fleet config to Fleet type + // If fleet is undefined/null, show empty state (not-purchased) + // If fleet.executors is empty, show pending state + // If fleet.executors has addresses, show active state + let initialFleet: Fleet | null = null; + if (fleetConfig) { + initialFleet = { + id: fleetId, + chainIds: fleetConfig.chainIds, + executors: fleetConfig.executors, + }; + } + + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/types.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/types.ts new file mode 100644 index 00000000000..3430e8608ca --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/dedicated-relayer/types.ts @@ -0,0 +1,61 @@ +/** + * Represents a dedicated relayer fleet configuration. + * Fleet config is fetched from ProjectBundlerService.fleet (service-utils). + */ +export type Fleet = { + id: string; + chainIds: number[]; + executors: string[]; // executor wallet addresses +}; + +/** + * Fleet status based on its state. + */ +export type FleetStatus = "not-purchased" | "pending-setup" | "active"; + +/** + * Derives the fleet status from the fleet object. + */ +function _getFleetStatus(fleet: Fleet | null): FleetStatus { + if (!fleet) { + return "not-purchased"; + } + if (fleet.executors.length === 0) { + return "pending-setup"; + } + return "active"; +} + +/** + * A single transaction from the fleet. + */ +export type FleetTransaction = { + timestamp: string; + chainId: string; + transactionFee: number; + transactionFeeUsd: number; + walletAddress: string; + transactionHash: string; + userOpHash: string; + executorAddress: string; +}; + +/** + * Summary statistics for fleet transactions. + */ +export type FleetTransactionsSummary = { + totalTransactions: number; + totalGasSpentUsd: number; + transactionsByChain: { + chainId: string; + count: number; + gasSpentUsd: number; + }[]; +}; + +/** + * Build the fleet ID from team and project. + */ +export function buildFleetId(teamId: string, projectId: string): string { + return `fleet-${teamId}-${projectId}`; +} diff --git a/packages/service-utils/src/core/api.ts b/packages/service-utils/src/core/api.ts index 5e66043b957..641ce9737e1 100644 --- a/packages/service-utils/src/core/api.ts +++ b/packages/service-utils/src/core/api.ts @@ -195,6 +195,11 @@ export type ProjectBundlerService = { value: string; }>; } | null; + dedicatedRelayer?: { + sku: string; + chainIds: number[]; + executors: string[]; + } | null; }; export type ProjectEmbeddedWalletsService = {