From 8604340f573bf40cdbe0798f606201cc62f12cae Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Thu, 16 Oct 2025 02:54:42 +0530 Subject: [PATCH] Add Solana transactions and wallets tables --- .changeset/eleven-mangos-travel.md | 5 + .../@/components/blocks/solana-address.tsx | 83 ++ .../components/ProjectSidebarLayout.tsx | 12 +- .../transactions/analytics/analytics-page.tsx | 5 +- .../transactions/analytics/ftux.client.tsx | 17 + .../analytics/send-test-solana-tx.client.tsx | 343 +++++++ .../analytics/solana-tx-table/types.ts | 141 +++ .../analytics/tx-table/tx-table-ui.tsx | 447 --------- .../analytics/tx-table/tx-table.tsx | 72 -- .../transactions/analytics/tx-table/types.ts | 19 + .../server-wallets-table.client.tsx | 774 ++++++++++++++++ .../components/transactions-table.client.tsx | 877 ++++++++++++++++++ .../(sidebar)/transactions/lib/analytics.ts | 99 +- .../transactions/lib/solana-utils.ts | 29 + .../transactions/lib/vault.client.ts | 206 +++- .../(sidebar)/transactions/page.tsx | 87 +- .../wallet-table/wallet-table-ui.client.tsx | 519 ----------- .../wallet-table/wallet-table.tsx | 37 - .../create-solana-wallet.client.tsx | 113 +++ .../upgrade-solana-permissions.client.tsx | 218 +++++ .../solana-wallets/lib/vault.client.ts | 200 ++++ .../solana-wallets/wallet-table/types.ts | 11 + .../(sidebar)/transactions/tx/[id]/layout.tsx | 12 +- .../(sidebar)/transactions/tx/[id]/page.tsx | 57 +- .../tx/[id]/solana-transaction-details-ui.tsx | 459 +++++++++ .../tx/[id]/transaction-details-ui.tsx | 2 +- .../[project_slug]/(sidebar)/wallets/page.tsx | 64 +- packages/vault-sdk/src/types.ts | 2 +- 28 files changed, 3752 insertions(+), 1158 deletions(-) create mode 100644 .changeset/eleven-mangos-travel.md create mode 100644 apps/dashboard/src/@/components/blocks/solana-address.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/send-test-solana-tx.client.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/solana-tx-table/types.ts delete mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/tx-table-ui.tsx delete mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/tx-table.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/components/server-wallets-table.client.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/components/transactions-table.client.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/solana-utils.ts delete mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/server-wallets/wallet-table/wallet-table-ui.client.tsx delete mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/server-wallets/wallet-table/wallet-table.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/solana-wallets/components/create-solana-wallet.client.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/solana-wallets/components/upgrade-solana-permissions.client.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/solana-wallets/lib/vault.client.ts create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/solana-wallets/wallet-table/types.ts create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/solana-transaction-details-ui.tsx diff --git a/.changeset/eleven-mangos-travel.md b/.changeset/eleven-mangos-travel.md new file mode 100644 index 00000000000..f4aecbb5574 --- /dev/null +++ b/.changeset/eleven-mangos-travel.md @@ -0,0 +1,5 @@ +--- +"@thirdweb-dev/vault-sdk": patch +--- + +Fixed bug with listing solana accounts response type diff --git a/apps/dashboard/src/@/components/blocks/solana-address.tsx b/apps/dashboard/src/@/components/blocks/solana-address.tsx new file mode 100644 index 00000000000..4380b34d79d --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/solana-address.tsx @@ -0,0 +1,83 @@ +"use client"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { useClipboard } from "@/hooks/useClipboard"; +import { cn } from "@/lib/utils"; + +export function SolanaAddress(props: { + address: string; + shortenAddress?: boolean; + className?: string; +}) { + const shortenedAddress = useMemo(() => { + return props.shortenAddress !== false + ? `${props.address.slice(0, 4)}...${props.address.slice(-4)}` + : props.address; + }, [props.address, props.shortenAddress]); + + const lessShortenedAddress = useMemo(() => { + return `${props.address.slice(0, 8)}...${props.address.slice(-8)}`; + }, [props.address]); + + const { onCopy, hasCopied } = useClipboard(props.address, 2000); + + return ( + + + + + { + // do not close the hover card when clicking anywhere in the content + e.stopPropagation(); + }} + > +
+
+

Solana Public Key

+ +
+

+ {lessShortenedAddress} +

+
+

+ Solana public key for blockchain transactions. +

+
+
+
+
+ ); +} 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 b763d0e0fe8..aff37b655fe 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 @@ -48,7 +48,11 @@ export function ProjectSidebarLayout(props: { { href: `${props.layoutPath}/transactions`, icon: ArrowLeftRightIcon, - label: "Transactions", + label: ( + + Transactions New + + ), }, { href: `${props.layoutPath}/contracts`, @@ -81,11 +85,7 @@ export function ProjectSidebarLayout(props: { { href: `${props.layoutPath}/tokens`, icon: TokenIcon, - label: ( - - Tokens New - - ), + label: "Tokens", }, ], }, diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/analytics-page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/analytics-page.tsx index 3208d2ba77d..4a558e9ff16 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/analytics-page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/analytics-page.tsx @@ -1,10 +1,10 @@ import { ResponsiveSearchParamsProvider } from "responsive-rsc"; import type { ThirdwebClient } from "thirdweb"; import type { Project } from "@/api/project/projects"; +import { UnifiedTransactionsTable } from "../components/transactions-table.client"; 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: { @@ -34,11 +34,10 @@ export function TransactionsAnalyticsPageContent(props: { /> )} - diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/ftux.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/ftux.client.tsx index c56fb619212..fdde607af18 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/ftux.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/ftux.client.tsx @@ -5,15 +5,19 @@ import type { Project } from "@/api/project/projects"; import { type Step, StepsCard } from "@/components/blocks/StepsCard"; import CreateServerWallet from "../server-wallets/components/create-server-wallet.client"; import type { Wallet } from "../server-wallets/wallet-table/types"; +import type { SolanaWallet } from "../solana-wallets/wallet-table/types"; +import { SendTestSolanaTransaction } from "./send-test-solana-tx.client"; import { SendTestTransaction } from "./send-test-tx.client"; interface Props { managementAccessToken: string | undefined; project: Project; wallets: Wallet[]; + solanaWallets: SolanaWallet[]; hasTransactions: boolean; teamSlug: string; testTxWithWallet?: string | undefined; + testSolanaTxWithWallet?: string | undefined; client: ThirdwebClient; isManagedVault: boolean; } @@ -85,6 +89,19 @@ export const EngineChecklist: React.FC = (props) => { ); } + if (props.testSolanaTxWithWallet) { + return ( + + ); + } + if (finalSteps.length === 0 || isComplete) { return null; } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/send-test-solana-tx.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/send-test-solana-tx.client.tsx new file mode 100644 index 00000000000..48bf43ab304 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/send-test-solana-tx.client.tsx @@ -0,0 +1,343 @@ +"use client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { ExternalLinkIcon, Loader2Icon, LockIcon } from "lucide-react"; +import Link from "next/link"; +import { useQueryState } from "nuqs"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { ThirdwebClient } from "thirdweb"; +import * as z from "zod"; +import { engineCloudProxy } from "@/actions/proxies"; +import type { Project } from "@/api/project/projects"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { TryItOut } from "../server-wallets/components/try-it-out"; +import type { SolanaWallet } from "../solana-wallets/wallet-table/types"; + +const formSchema = z.object({ + secretKey: z.string().min(1, "Secret key is required"), + walletIndex: z.string(), +}); + +type FormValues = z.infer; + +function SendTestSolanaTransactionModal(props: { + wallets?: SolanaWallet[]; + project: Project; + teamSlug: string; + walletId?: string; + isManagedVault: boolean; + client: ThirdwebClient; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const queryClient = useQueryClient(); + const [, setTxChain] = useQueryState("txChain", { + history: "push", + }); + + const form = useForm({ + defaultValues: { + secretKey: "", + walletIndex: + props.wallets && props.walletId + ? props.wallets + .findIndex((w) => w.id === props.walletId) + ?.toString() + .replace("-1", "0") + : "0", + }, + resolver: zodResolver(formSchema), + }); + + const selectedWalletIndex = Number.parseInt(form.watch("walletIndex")); + const selectedWallet = props.wallets?.[selectedWalletIndex]; + + const sendDummySolanaTxMutation = useMutation({ + mutationFn: async (args: { walletAddress: string; secretKey: string }) => { + const response = await engineCloudProxy({ + body: JSON.stringify({ + executionOptions: { + chainId: "solana:devnet", + signerAddress: args.walletAddress, + maxBlockhashRetries: 0, + commitment: "confirmed", + }, + instructions: [ + { + programId: "11111111111111111111111111111111", // System Program + accounts: [ + { + pubkey: args.walletAddress, + isSigner: true, + isWritable: true, + }, + { + pubkey: args.walletAddress, + isSigner: false, + isWritable: true, + }, + ], + data: "AgAAAAAAAAAAAAAAAA==", // Transfer 0 SOL (base64) + encoding: "base64", + }, + ], + }), + headers: { + "Content-Type": "application/json", + "x-client-id": props.project.publishableKey, + "x-team-id": props.project.teamId, + ...(props.isManagedVault + ? { "x-secret-key": args.secretKey } + : { "x-vault-access-token": args.secretKey }), + }, + method: "POST", + pathname: "/v1/solana/transaction", + }); + + if (!response.ok) { + const errorMsg = response.error ?? "Failed to send Solana transaction"; + throw new Error(errorMsg); + } + + return response.data; + }, + onError: (error) => { + toast.error(error.message); + }, + onSuccess: () => { + toast.success("Test Solana transaction sent successfully!"); + // Set the transaction chain to solana so the table switches + setTxChain("solana"); + // Close the modal after successful transaction + setTimeout(() => { + props.onOpenChange(false); + }, 1000); + }, + }); + + const isLoading = sendDummySolanaTxMutation.isPending; + + // Early return in render phase + if (!props.wallets || props.wallets.length === 0 || !selectedWallet) { + return null; + } + + const onSubmit = async (data: FormValues) => { + await sendDummySolanaTxMutation.mutateAsync({ + secretKey: data.secretKey, + walletAddress: selectedWallet.publicKey, + }); + queryClient.invalidateQueries({ + queryKey: ["solana-transactions", props.project.id], + }); + }; + + return ( + + + Send Test Solana Transaction + + Test your Solana server wallet by sending a 0 SOL transfer transaction + on Solana Devnet + + + + {/* Funding Alert */} + + +

⚠️ Fund your wallet first

+

+ Your Solana wallet needs SOL on Devnet to pay for transaction fees. + Get free Devnet SOL from the Solana faucet: +

+ + + https://faucet.solana.com/ + +
+
+ +
+
+ {/* Wallet Selector */} +
+

Select Wallet

+ +
+ + {/* Wallet Address Display */} +
+

Public Key

+ + {selectedWallet.publicKey} + +
+ + {/* Network Info */} +
+

Network

+
+
+ Solana Devnet +
+
+ + {/* Secret Key Input */} +
+

+ {props.isManagedVault ? "Secret Key" : "Vault Access Token"} +

+
+ + +
+ {form.formState.errors.secretKey && ( +

+ {form.formState.errors.secretKey.message} +

+ )} +
+ + {/* Action Buttons */} +
+ + +
+
+ + + ); +} + +export function SendTestSolanaTransaction(props: { + wallets?: SolanaWallet[]; + project: Project; + teamSlug: string; + expanded?: boolean; + walletId?: string; + isManagedVault: boolean; + client: ThirdwebClient; +}) { + const [isModalOpen, setIsModalOpen] = useState(false); + const router = useDashboardRouter(); + + // Early return in render phase + if (!props.wallets || props.wallets.length === 0) { + return null; + } + + return ( +
+ +
+ + + + + + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/solana-tx-table/types.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/solana-tx-table/types.ts new file mode 100644 index 00000000000..075c9d6e778 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/solana-tx-table/types.ts @@ -0,0 +1,141 @@ +export type SolanaTransactionStatus = + | "QUEUED" + | "SUBMITTED" + | "CONFIRMED" + | "FAILED"; + +type SolanaTransactionParamsSerialized = { + instructions: Array<{ + programId: string; + keys: Array<{ + pubkey: string; + isSigner: boolean; + isWritable: boolean; + }>; + data: string; + }>; +}; + +type SolanaExecutionParamsSerialized = { + type: "SOLANA"; + signerAddress: string; + chainId: string; + commitment?: "processed" | "confirmed" | "finalized"; + computeUnitLimit?: number; + computeUnitPrice?: number; + skipPreflight?: boolean; +}; + +type SolanaExecutorError = { + errorCode?: string; + message?: string; + innerError?: unknown; + reason?: string; + [key: string]: unknown; +}; + +type SolanaTransactionMeta = { + err?: unknown | null; + status?: { + Ok?: unknown | null; + }; + fee: number; + preBalances: number[]; + postBalances: number[]; + innerInstructions: unknown[]; + logMessages: string[]; + preTokenBalances: unknown[]; + postTokenBalances: unknown[]; + rewards: unknown[]; + loadedAddresses: { + writable: string[]; + readonly: string[]; + }; + computeUnitsConsumed?: number; + costUnits?: number; +}; + +type SolanaTransactionDetails = { + transaction: { + signatures: string[]; + message: { + header: { + numRequiredSignatures: number; + numReadonlySignedAccounts: number; + numReadonlyUnsignedAccounts: number; + }; + accountKeys: string[]; + recentBlockhash: string; + instructions: Array<{ + programIdIndex: number; + accounts: number[]; + data: string; + stackHeight?: number; + }>; + addressTableLookups: unknown[]; + }; + }; + meta: SolanaTransactionMeta; + version: number | "legacy"; +}; + +type SolanaExecutionResultSerialized = + | { + status: "QUEUED"; + } + | { + status: "FAILED"; + error: SolanaExecutorError; + } + | { + status: "SUBMITTED"; + monitoringStatus: "WILL_MONITOR" | "CANNOT_MONITOR"; + signature: string; + submissionAttemptNumber: number; + } + | ({ + status: "CONFIRMED"; + signature: string; + signerAddress: string; + chainId: string; + submissionAttemptNumber: number; + slot: number; + blockTime: number | null; + transaction: SolanaTransactionDetails; + } & ( + | { + onchainStatus: "SUCCESS"; + } + | { + onchainStatus: "REVERTED"; + error?: string; + } + )); + +export type SolanaTransaction = { + id: string; + clientId: string; + chainId: string; + signerAddress: string; + transactionParams: SolanaTransactionParamsSerialized; + signature: string | null; + status: SolanaTransactionStatus | null; + confirmedAt: Date | null; + confirmedAtSlot: string | null; + blockTime: number | null; + enrichedData: unknown[]; + executionParams: SolanaExecutionParamsSerialized; + executionResult: SolanaExecutionResultSerialized | null; + createdAt: Date; + errorMessage: string | null; + cancelledAt: Date | null; +}; + +export type SolanaTransactionsResponse = { + transactions: SolanaTransaction[]; + pagination: { + totalCount: number; + page: number; + limit: number; + }; +}; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/tx-table-ui.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/tx-table-ui.tsx deleted file mode 100644 index 92f2e1c2056..00000000000 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/tx-table-ui.tsx +++ /dev/null @@ -1,447 +0,0 @@ -"use client"; - -import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { format, formatDistanceToNowStrict } from "date-fns"; -import { ExternalLinkIcon, InfoIcon } from "lucide-react"; -import Link from "next/link"; -import { useId, useState } from "react"; -import type { ThirdwebClient } from "thirdweb"; -import type { Project } from "@/api/project/projects"; -import { PaginationButtons } from "@/components/blocks/pagination-buttons"; -import { WalletAddress } from "@/components/blocks/wallet-address"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { CopyAddressButton } from "@/components/ui/CopyAddressButton"; -import { CopyTextButton } from "@/components/ui/CopyTextButton"; -import { Input } from "@/components/ui/input"; -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 { useAllChainsData } from "@/hooks/chains/allChains"; -import { ChainIconClient } from "@/icons/ChainIcon"; -import { useDashboardRouter } from "@/lib/DashboardRouter"; -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; - status: TransactionStatus | undefined; - id: string | undefined; - from: string | undefined; - }) => Promise; - project: Project; - teamSlug: string; - wallets?: Wallet[]; - client: ThirdwebClient; -}) { - const router = useDashboardRouter(); - const [autoUpdate, setAutoUpdate] = useState(true); - const [status, setStatus] = useState( - undefined, - ); - const [id, setId] = useState(undefined); - const [from, setFrom] = useState(undefined); - const [page, setPage] = useState(1); - - const pageSize = 10; - const transactionsQuery = useQuery({ - placeholderData: keepPreviousData, - queryFn: () => props.getData({ page, status, id, from }), - queryKey: ["transactions", props.project.id, page, status, id, from], - refetchInterval: autoUpdate ? 4_000 : false, - }); - - 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); - - const autoUpdateId = useId(); - - return ( -
-
-
-
-

- Transaction History -

-

- Transactions sent from server wallets -

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

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

-
- ); -} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/tx-table.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/tx-table.tsx deleted file mode 100644 index 5a9366f8b90..00000000000 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/tx-table.tsx +++ /dev/null @@ -1,72 +0,0 @@ -"use client"; - -import type { ThirdwebClient } from "thirdweb"; -import { engineCloudProxy } from "@/actions/proxies"; -import type { Project } from "@/api/project/projects"; -import type { Wallet } from "../../server-wallets/wallet-table/types"; -import { TransactionsTableUI } from "./tx-table-ui"; -import type { TransactionStatus, TransactionsResponse } from "./types"; - -export function TransactionsTable(props: { - project: Project; - wallets?: Wallet[]; - teamSlug: string; - client: ThirdwebClient; -}) { - return ( - { - return await getTransactions({ - page, - project: props.project, - status, - id, - from, - }); - }} - project={props.project} - teamSlug={props.teamSlug} - wallets={props.wallets} - /> - ); -} - -async function getTransactions({ - project, - page, - status, - id, - from, -}: { - project: Project; - page: number; - status: TransactionStatus | undefined; - id: string | undefined; - from: string | undefined; -}) { - const transactions = await engineCloudProxy<{ result: TransactionsResponse }>( - { - headers: { - "Content-Type": "application/json", - "x-client-id": project.publishableKey, - "x-team-id": project.teamId, - }, - method: "GET", - pathname: `/v1/transactions`, - searchParams: { - limit: "20", - page: page.toString(), - status: status ?? undefined, - id: id ?? undefined, - from: from ?? undefined, - }, - }, - ); - - if (!transactions.ok) { - throw new Error(transactions.error); - } - - return transactions.data.result; -} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/types.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/types.ts index 7ea643a251f..6140a9e108d 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/types.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/types.ts @@ -97,3 +97,22 @@ export type TransactionsResponse = { transactions: Transaction[]; pagination: Pagination; }; + +export const statusDetails = { + CONFIRMED: { + name: "Confirmed", + type: "success", + }, + FAILED: { + name: "Failed", + type: "destructive", + }, + QUEUED: { + name: "Queued", + type: "warning", + }, + SUBMITTED: { + name: "Submitted", + type: "warning", + }, +} as const; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/components/server-wallets-table.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/components/server-wallets-table.client.tsx new file mode 100644 index 00000000000..c8b124715fe --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/components/server-wallets-table.client.tsx @@ -0,0 +1,774 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { format, formatDistanceToNowStrict } from "date-fns"; +import { + CheckIcon, + MoreVerticalIcon, + RefreshCcwIcon, + SendIcon, + XIcon, +} from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import { toast } from "sonner"; +import type { ThirdwebClient } from "thirdweb"; +import { useWalletBalance } from "thirdweb/react"; +import { + DEFAULT_ACCOUNT_FACTORY_V0_7, + predictSmartAccountAddress, +} from "thirdweb/wallets/smart"; +import type { Project } from "@/api/project/projects"; +import { FundWalletModal } from "@/components/blocks/fund-wallets-modal"; +import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; +import { SolanaAddress } from "@/components/blocks/solana-address"; +import { WalletAddress } from "@/components/blocks/wallet-address"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Label } from "@/components/ui/label"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; +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 { TabButtons } from "@/components/ui/tabs"; +import { ToolTipLabel } from "@/components/ui/tooltip"; +import { useV5DashboardChain } from "@/hooks/chains/v5-adapter"; +import { WalletProductIcon } from "@/icons/WalletProductIcon"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { cn } from "@/lib/utils"; +import { updateDefaultProjectWallet } from "../lib/vault.client"; +import CreateServerWallet from "../server-wallets/components/create-server-wallet.client"; +import type { Wallet as EVMWallet } from "../server-wallets/wallet-table/types"; +import { CreateSolanaWallet } from "../solana-wallets/components/create-solana-wallet.client"; +import { UpgradeSolanaPermissions } from "../solana-wallets/components/upgrade-solana-permissions.client"; +import type { SolanaWallet } from "../solana-wallets/wallet-table/types"; + +type WalletChain = "evm" | "solana"; + +interface ServerWalletsTableProps { + evmWallets: EVMWallet[]; + evmTotalRecords: number; + evmCurrentPage: number; + evmTotalPages: number; + solanaWallets: SolanaWallet[]; + solanaTotalRecords: number; + solanaCurrentPage: number; + solanaTotalPages: number; + project: Project; + teamSlug: string; + managementAccessToken: string | undefined; + client: ThirdwebClient; + solanaPermissionError?: boolean; +} + +export function ServerWalletsTable(props: ServerWalletsTableProps) { + const { + evmWallets, + solanaWallets, + project, + teamSlug, + managementAccessToken, + evmTotalRecords, + evmCurrentPage, + evmTotalPages, + solanaTotalRecords, + solanaCurrentPage, + solanaTotalPages, + client, + solanaPermissionError, + } = props; + + const [activeChain, setActiveChain] = useState("evm"); + const [selectedChainId, setSelectedChainId] = useState(1); + const [showSmartAccount, setShowSmartAccount] = useState(false); + const queryClient = useQueryClient(); + + const wallets = activeChain === "evm" ? evmWallets : solanaWallets; + const currentPage = + activeChain === "evm" ? evmCurrentPage : solanaCurrentPage; + const totalPages = activeChain === "evm" ? evmTotalPages : solanaTotalPages; + const totalRecords = + activeChain === "evm" ? evmTotalRecords : solanaTotalRecords; + + return ( +
+
+ {/* Header */} +
+
+
+
+ +
+
+

+ Server Wallets +

+

+ Create and manage server wallets for your project +

+
+ +
+
+ {activeChain === "evm" && ( + <> + + + + )} + {activeChain === "solana" && ( + + )} +
+ + {activeChain === "evm" && ( +
+ + +
+ )} +
+
+ + {/* Chain Tabs */} +
+ + EVM Wallets + + {evmTotalRecords} + + + ), + onClick: () => setActiveChain("evm"), + isActive: activeChain === "evm", + }, + { + name: ( + + Solana Wallets + + {solanaTotalRecords} + + + ), + onClick: () => setActiveChain("solana"), + isActive: activeChain === "solana", + }, + ]} + /> +
+ + {/* Table Content */} + {activeChain === "solana" && solanaPermissionError ? ( +
+ +
+ ) : ( + <> + + + + + Label + + {activeChain === "evm" + ? showSmartAccount + ? "Smart Account Address" + : "Wallet Address" + : "Public Key"} + + +
+ Balance + {wallets.length > 0 && ( + + + + )} +
+
+ Created + Actions +
+
+ + {activeChain === "evm" && + evmWallets.map((wallet) => ( + + ))} + {activeChain === "solana" && + solanaWallets.map((wallet) => ( + + ))} + +
+ + {wallets.length === 0 && ( +
+
+ +
+

+ No {activeChain === "evm" ? "EVM" : "Solana"} wallets found +

+
+ )} +
+ + {totalPages > 1 && ( + + )} + + )} +
+
+ ); +} + +// Wallets Pagination Component +function WalletsPagination({ + activeChain, + currentPage, + totalPages, + totalRecords, + teamSlug, + projectSlug, +}: { + activeChain: WalletChain; + currentPage: number; + totalPages: number; + totalRecords: number; + teamSlug: string; + projectSlug: string; +}) { + const pageParam = activeChain === "evm" ? "page" : "solana_page"; + + return ( +
+
+ Found {totalRecords} {activeChain === "evm" ? "EVM" : "Solana"} wallets +
+ + + + 1 ? currentPage - 1 : 1 + }`} + legacyBehavior + passHref + > + + + + {Array.from({ length: totalPages }, (_, i) => i + 1).map( + (pageNumber) => ( + + + + {pageNumber} + + + + ), + )} + + + = totalPages + ? "pointer-events-none opacity-50" + : "" + } + /> + + + + +
+ ); +} + +// EVM Wallet Row Component +function EVMWalletRow({ + wallet, + project, + teamSlug, + client, + chainId, + showSmartAccount, +}: { + wallet: EVMWallet; + project: Project; + teamSlug: string; + client: ThirdwebClient; + chainId: number; + showSmartAccount: boolean; +}) { + const chain = useV5DashboardChain(chainId); + + const smartAccountQuery = useQuery({ + queryFn: async () => { + return await predictSmartAccountAddress({ + adminAddress: wallet.address, + chain: chain, + client: client, + factoryAddress: DEFAULT_ACCOUNT_FACTORY_V0_7, + }); + }, + enabled: showSmartAccount, + queryKey: ["smart-account-address", wallet.address, chainId], + }); + + const engineService = project.services.find((s) => s.name === "engineCloud"); + const isDefault = + engineService?.projectWalletAddress && + wallet.address.toLowerCase() === + engineService.projectWalletAddress.toLowerCase(); + + return ( + + +
+ + {wallet.metadata.label || "N/A"} + + {isDefault && ( + + default + + )} +
+
+ + + {showSmartAccount ? ( + smartAccountQuery.isPending ? ( + + ) : smartAccountQuery.data ? ( + + ) : ( + N/A + ) + ) : ( + + )} + + + + {showSmartAccount ? ( + smartAccountQuery.isPending ? ( + + ) : smartAccountQuery.data ? ( + + ) : ( + N/A + ) + ) : ( + + )} + + + + + + + + + +
+ ); +} + +// Solana Wallet Row Component +function SolanaWalletRow({ + wallet, + project, + teamSlug, + client, +}: { + wallet: SolanaWallet; + project: Project; + teamSlug: string; + client: ThirdwebClient; +}) { + const engineService = project.services.find( + (s) => s.name === "engineCloud", + ) as { projectSolanaWalletPublicKey?: string } | undefined; + + const isDefault = + engineService?.projectSolanaWalletPublicKey && + wallet.publicKey.toLowerCase() === + engineService.projectSolanaWalletPublicKey.toLowerCase(); + + return ( + + +
+ + {wallet.metadata.label || "N/A"} + + {isDefault && ( + + default + + )} +
+
+ + + + + + + + + + + + + + + + +
+ ); +} + +// Shared Components +function DateCell({ date }: { date: string }) { + if (!date) { + return "N/A"; + } + + const dateObj = new Date(date); + return ( + +

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

+
+ ); +} + +function EVMWalletActions({ + wallet, + project, + teamSlug, + client, + chainId, + fundAddress, +}: { + wallet: EVMWallet; + project: Project; + teamSlug: string; + client: ThirdwebClient; + chainId: number; + fundAddress: string | undefined; +}) { + const [showFundModal, setShowFundModal] = useState(false); + const router = useDashboardRouter(); + + const setDefaultMutation = useMutation({ + mutationFn: async () => { + await updateDefaultProjectWallet({ + project, + projectWalletAddress: wallet.address, + }); + }, + onSuccess: () => { + toast.success("Wallet set as project wallet"); + router.refresh(); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to set default wallet", + ); + }, + }); + + return ( + <> + + + + + + + + + Send test transaction + + + {fundAddress && ( + setShowFundModal(true)} + className="flex items-center gap-2 h-9 rounded-lg" + > + + Fund wallet + + )} + setDefaultMutation.mutate()} + disabled={setDefaultMutation.isPending} + className="flex items-center gap-2 h-9 rounded-lg" + > + + Set as default + + + + + {fundAddress && ( + + )} + + ); +} + +function SolanaWalletActions({ + wallet, + project, + teamSlug, +}: { + wallet: SolanaWallet; + project: Project; + teamSlug: string; + client: ThirdwebClient; +}) { + return ( + + + + + + + + + Send test transaction + + + + + ); +} + +function WalletBalance({ + address, + chainId, + client, +}: { + address: string; + chainId: number; + client: ThirdwebClient; +}) { + const chain = useV5DashboardChain(chainId); + const balance = useWalletBalance({ + address, + chain, + client, + }); + + if (balance.isFetching) { + return ; + } + + if (!balance.data) { + return N/A; + } + + return ( + + {balance.data.displayValue} {balance.data.symbol} + + ); +} + +function SolanaWalletBalance({ publicKey }: { publicKey: string }) { + const balance = useQuery({ + queryFn: async () => { + // TODO: Implement actual Solana balance fetching + return { + displayValue: "0", + symbol: "SOL", + }; + }, + queryKey: ["solanaWalletBalance", publicKey], + }); + + if (balance.isFetching) { + return ; + } + + if (!balance.data) { + return N/A; + } + + return ( + + {balance.data.displayValue} {balance.data.symbol} + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/components/transactions-table.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/components/transactions-table.client.tsx new file mode 100644 index 00000000000..06ef9f7ed79 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/components/transactions-table.client.tsx @@ -0,0 +1,877 @@ +"use client"; + +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { format, formatDistanceToNowStrict } from "date-fns"; +import { ExternalLinkIcon, InfoIcon } from "lucide-react"; +import Link from "next/link"; +import { useQueryState } from "nuqs"; +import { useId, useState } from "react"; +import type { ThirdwebClient } from "thirdweb"; +import { engineCloudProxy } from "@/actions/proxies"; +import type { Project } from "@/api/project/projects"; +import { PaginationButtons } from "@/components/blocks/pagination-buttons"; +import { SolanaAddress } from "@/components/blocks/solana-address"; +import { WalletAddress } from "@/components/blocks/wallet-address"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { CopyAddressButton } from "@/components/ui/CopyAddressButton"; +import { CopyTextButton } from "@/components/ui/CopyTextButton"; +import { Input } from "@/components/ui/input"; +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 { useAllChainsData } from "@/hooks/chains/allChains"; +import { ChainIconClient } from "@/icons/ChainIcon"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import type { + SolanaTransaction, + SolanaTransactionStatus, + SolanaTransactionsResponse, +} from "../analytics/solana-tx-table/types"; +import type { + Transaction, + TransactionStatus, + TransactionsResponse, +} from "../analytics/tx-table/types"; +import { getSolanaNetworkName, getSolscanUrl } from "../lib/solana-utils"; + +type TransactionChain = "evm" | "solana"; + +interface UnifiedTransactionsTableProps { + project: Project; + teamSlug: string; + client: ThirdwebClient; +} + +export function UnifiedTransactionsTable({ + project, + teamSlug, + client, +}: UnifiedTransactionsTableProps) { + const router = useDashboardRouter(); + + // Use nuqs to manage chain selection in URL - this is the source of truth + const [activeChainParam, setActiveChainParam] = useQueryState("txChain", { + defaultValue: "evm", + history: "push", + }); + + const activeChain = (activeChainParam || "evm") as TransactionChain; + const setActiveChain = (chain: TransactionChain) => + setActiveChainParam(chain); + + const [autoUpdate, setAutoUpdate] = useState(true); + const [status, setStatus] = useState< + TransactionStatus | SolanaTransactionStatus | undefined + >(undefined); + const [id, setId] = useState(undefined); + const [from, setFrom] = useState(undefined); + const [page, setPage] = useState(1); + + // Fetch EVM transactions + const evmTransactionsQuery = useQuery({ + enabled: activeChain === "evm", + placeholderData: keepPreviousData, + queryFn: () => + getEVMTransactions({ + page, + project, + status: status as TransactionStatus | undefined, + id, + from, + }), + queryKey: ["evm-transactions", project.id, page, status, id, from], + refetchInterval: autoUpdate && activeChain === "evm" ? 4_000 : false, + }); + + // Fetch Solana transactions + const solanaTransactionsQuery = useQuery({ + enabled: activeChain === "solana", + placeholderData: keepPreviousData, + queryFn: () => + getSolanaTransactions({ + page, + project, + status: status as SolanaTransactionStatus | undefined, + id, + from, + }), + queryKey: ["solana-transactions", project.id, page, status, id, from], + refetchInterval: autoUpdate && activeChain === "solana" ? 4_000 : false, + }); + + const autoUpdateId = useId(); + + return ( +
+ {/* Unified Header & Filters */} +
+
+
+

+ Transaction History +

+

+ Transactions sent from server wallets +

+
+ +
+ + setAutoUpdate(!!v)} + /> +
+
+ +
+ { + const value = e.target.value.trim(); + setId(value || undefined); + setPage(1); + }} + placeholder="Filter by Queue ID" + value={id || ""} + /> + { + const value = e.target.value.trim(); + setFrom(value || undefined); + setPage(1); + }} + placeholder="Filter by wallet address" + value={from || ""} + /> +
+ { + setActiveChain(chain); + setPage(1); + setStatus(undefined); + }} + /> + { + setStatus(v); + setPage(1); + }} + status={status} + /> +
+
+
+ + {/* Render different table structures based on chain */} + {activeChain === "evm" ? ( + + ) : ( + + )} +
+ ); +} + +// EVM Transactions Table Component +function EVMTransactionsTable(props: { + query: ReturnType>; + page: number; + setPage: (page: number) => void; + client: ThirdwebClient; + router: ReturnType; + teamSlug: string; + project: Project; +}) { + const { query, page, setPage, client, router, teamSlug, project } = props; + const pageSize = 10; + + const transactions = query.data?.transactions ?? []; + const totalCount = query.data?.pagination.totalCount ?? 0; + const totalPages = Math.ceil(totalCount / pageSize); + const showPagination = totalCount > pageSize; + + const showSkeleton = + (query.isPlaceholderData && query.isFetching) || + (query.isLoading && !query.isPlaceholderData); + + return ( + <> + + + + + Queue ID + Chain + Status + From + Tx Hash + Queued + + + + {showSkeleton + ? new Array(pageSize).fill(0).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: EXPECTED + + )) + : transactions.map((tx) => ( + { + router.push( + `/team/${teamSlug}/${project.slug}/transactions/tx/${tx.id}`, + ); + }} + > + + + + + + + + + + + {tx.from ? ( + + ) : ( + "N/A" + )} + + + + + + + + + ))} + +
+ + {!showSkeleton && transactions.length === 0 && ( +
+ No transactions found +
+ )} +
+ + {showPagination && ( +
+ +
+ )} + + ); +} + +// Solana Transactions Table Component +function SolanaTransactionsTable(props: { + query: ReturnType>; + page: number; + setPage: (page: number) => void; + router: ReturnType; + teamSlug: string; + project: Project; +}) { + const { query, page, setPage, router, teamSlug, project } = props; + const pageSize = 10; + + const transactions = query.data?.transactions ?? []; + const totalCount = query.data?.pagination.totalCount ?? 0; + const totalPages = Math.ceil(totalCount / pageSize); + const showPagination = totalCount > pageSize; + + const showSkeleton = + (query.isPlaceholderData && query.isFetching) || + (query.isLoading && !query.isPlaceholderData); + + return ( + <> + + + + + Queue ID + Network + Status + Signer + Signature + Queued + + + + {showSkeleton + ? new Array(pageSize).fill(0).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: EXPECTED + + )) + : transactions.map((tx) => ( + { + router.push( + `/team/${teamSlug}/${project.slug}/transactions/tx/${tx.id}`, + ); + }} + > + + + + + + + + + + + + + + + + + + + + ))} + +
+ + {!showSkeleton && transactions.length === 0 && ( +
+ No transactions found +
+ )} +
+ + {showPagination && ( +
+ +
+ )} + + ); +} + +// Chain selector component +function ChainSelector(props: { + activeChain: TransactionChain; + setActiveChain: (chain: TransactionChain) => void; +}) { + return ( + + ); +} + +// Status selector component +const evmStatusDetails = { + CONFIRMED: { name: "Confirmed", type: "success" as const }, + FAILED: { name: "Failed", type: "destructive" as const }, + QUEUED: { name: "Queued", type: "warning" as const }, + SUBMITTED: { name: "Submitted", type: "warning" as const }, +}; + +const solanaStatusDetails = { + CONFIRMED: { name: "Confirmed", type: "success" as const }, + FAILED: { name: "Failed", type: "destructive" as const }, + QUEUED: { name: "Queued", type: "warning" as const }, + SUBMITTED: { name: "Submitted", type: "warning" as const }, +}; + +function StatusSelector(props: { + activeChain: TransactionChain; + status: TransactionStatus | SolanaTransactionStatus | undefined; + setStatus: ( + value: TransactionStatus | SolanaTransactionStatus | undefined, + ) => void; +}) { + const statusDetails = + props.activeChain === "evm" ? evmStatusDetails : solanaStatusDetails; + const statuses = Object.keys(statusDetails) as ( + | TransactionStatus + | SolanaTransactionStatus + )[]; + + return ( + + ); +} + +// Skeleton row component +function SkeletonRow() { + return ( + + + + + + + + + + + + + + + + + + + + + ); +} + +// EVM Chain cell component +function EVMChainCell(props: { + chainId: string | undefined; + client: ThirdwebClient; +}) { + const { chainId } = props; + const { idToChain } = useAllChainsData(); + if (!chainId) { + return "N/A"; + } + + const chain = idToChain.get(Number.parseInt(chainId)); + + if (!chain) { + return `Chain ID: ${chainId}`; + } + + return ( +
+ +
+ {chain.name ?? `Chain ID: ${chainId}`} +
+
+ ); +} + +// Solana Chain cell component +function SolanaChainCell(props: { chainId: string | undefined }) { + const { chainId } = props; + if (!chainId) { + return "N/A"; + } + + const network = getSolanaNetworkName(chainId); + const displayName = network.charAt(0).toUpperCase() + network.slice(1); + + return ( +
+
+ Solana {displayName} +
+ ); +} + +// EVM Status cell component +function EVMStatusCell(props: { transaction: Transaction }) { + const { transaction } = props; + const { errorMessage } = transaction; + const minedAt = transaction.confirmedAt; + const status = + (transaction.executionResult?.status as TransactionStatus) ?? "QUEUED"; + + const onchainStatus = + transaction.executionResult && + "onchainStatus" in transaction.executionResult + ? transaction.executionResult.onchainStatus + : null; + + const tooltip = + onchainStatus !== "REVERTED" + ? errorMessage + : status === "CONFIRMED" && minedAt + ? `Completed ${format(new Date(minedAt), "PP pp")}` + : undefined; + + const statusDetails = evmStatusDetails[status] || { + name: status, + type: "default" as const, + }; + + return ( + + + {statusDetails.name} + {errorMessage && } + + + ); +} + +// Solana Status cell component +function SolanaStatusCell(props: { transaction: SolanaTransaction }) { + const { transaction } = props; + const { errorMessage } = transaction; + const status = + (transaction.executionResult?.status as SolanaTransactionStatus) ?? + transaction.status ?? + "QUEUED"; + + const onchainStatus = + transaction.executionResult && + "onchainStatus" in transaction.executionResult + ? transaction.executionResult.onchainStatus + : null; + + const tooltip = + onchainStatus !== "REVERTED" + ? errorMessage + : status === "CONFIRMED" && transaction.confirmedAt + ? `Completed ${format(new Date(transaction.confirmedAt), "PP pp")}` + : undefined; + + const statusDetails = solanaStatusDetails[status] || { + name: status || "QUEUED", + type: "default" as const, + }; + + return ( + + + {statusDetails.name} + {errorMessage && } + + + ); +} + +// EVM Tx Hash cell component +function EVMTxHashCell(props: { transaction: Transaction }) { + const { idToChain } = useAllChainsData(); + const { chainId, transactionHash } = props.transaction; + if (!transactionHash) { + return "N/A"; + } + + const chain = chainId ? idToChain.get(Number.parseInt(chainId)) : undefined; + const explorer = chain?.explorers?.[0]; + + const shortHash = `${transactionHash.slice(0, 6)}...${transactionHash.slice(-4)}`; + if (!explorer) { + return ( + + ); + } + + return ( + + ); +} + +// Solana Tx Hash cell component +function SolanaTxHashCell(props: { transaction: SolanaTransaction }) { + const { chainId, signature, executionResult } = props.transaction; + + // Get signature from executionResult if available, otherwise use top-level signature + let hash = signature; + if ( + executionResult && + "signature" in executionResult && + executionResult.signature + ) { + hash = executionResult.signature; + } + + if (!hash) { + return "N/A"; + } + + const shortHash = `${hash.slice(0, 6)}...${hash.slice(-4)}`; + const explorerUrl = chainId ? getSolscanUrl(hash, chainId) : null; + + if (!explorerUrl) { + return ( + + ); + } + + return ( + + ); +} + +// EVM Queued At cell component +function EVMQueuedAtCell(props: { transaction: Transaction }) { + const value = props.transaction.createdAt; + if (!value) { + return null; + } + + const date = value instanceof Date ? value : new Date(value); + return ( + +

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

+
+ ); +} + +// Solana Queued At cell component +function SolanaQueuedAtCell(props: { transaction: SolanaTransaction }) { + const value = props.transaction.createdAt; + if (!value) { + return null; + } + + const date = value instanceof Date ? value : new Date(value); + return ( + +

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

+
+ ); +} + +async function getEVMTransactions({ + project, + page, + status, + id, + from, +}: { + project: Project; + page: number; + status: TransactionStatus | undefined; + id: string | undefined; + from: string | undefined; +}): Promise { + const transactions = await engineCloudProxy<{ result: TransactionsResponse }>( + { + headers: { + "Content-Type": "application/json", + "x-client-id": project.publishableKey, + "x-team-id": project.teamId, + }, + method: "GET", + pathname: `/v1/transactions`, + searchParams: { + limit: "20", + page: page.toString(), + status: status ?? undefined, + id: id ?? undefined, + from: from ?? undefined, + }, + }, + ); + + if (!transactions.ok) { + return { + transactions: [], + pagination: { + totalCount: 0, + page: 1, + limit: 20, + }, + }; + } + + return transactions.data.result; +} + +async function getSolanaTransactions({ + project, + page, + status, + id, + from, +}: { + project: Project; + page: number; + status: SolanaTransactionStatus | undefined; + id: string | undefined; + from: string | undefined; +}): Promise { + const transactions = await engineCloudProxy<{ + result: SolanaTransactionsResponse; + }>({ + headers: { + "Content-Type": "application/json", + "x-client-id": project.publishableKey, + "x-team-id": project.teamId, + "x-chain-id": "solana:devnet", // TODO: Support multiple Solana networks + }, + method: "GET", + pathname: `/v1/solana/transactions`, + searchParams: { + limit: "20", + page: page.toString(), + status: status ?? undefined, + id: id ?? undefined, + from: from ?? undefined, + }, + }); + + if (!transactions.ok) { + return { + transactions: [], + pagination: { + totalCount: 0, + page: 1, + limit: 20, + }, + }; + } + + return transactions.data.result; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/analytics.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/analytics.ts index 2fec73c25d6..c4618db2b6a 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/analytics.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/analytics.ts @@ -205,6 +205,64 @@ export async function getSingleTransaction({ return data.transactions[0]; } +export async function getSingleSolanaTransaction({ + teamId, + clientId, + transactionId, +}: { + teamId: string; + clientId: string; + transactionId: string; +}): Promise< + import("../analytics/solana-tx-table/types").SolanaTransaction | undefined +> { + const authToken = await getAuthToken(); + + const filters = { + filters: [ + { + field: "id", + operation: "OR", + values: [transactionId], + }, + ], + }; + + const response = await fetch( + `${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/solana/transactions/search`, + { + body: JSON.stringify(filters), + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + "x-client-id": clientId, + "x-team-id": teamId, + "x-chain-id": "solana:devnet", // TODO: Support multiple Solana networks + }, + method: "POST", + }, + ); + + if (!response.ok) { + if (response.status === 401) { + return undefined; + } + + const errorText = await response.text().catch(() => "Unknown error"); + throw new Error( + `Error fetching single Solana transaction data: ${response.status} ${ + response.statusText + } - ${errorText}`, + ); + } + + const rawData = await response.json(); + const data = + rawData.result as import("../analytics/solana-tx-table/types").SolanaTransactionsResponse; + + return data.transactions[0]; +} + // Activity log types export type ActivityLogEntry = { id: string; @@ -260,18 +318,45 @@ export async function getTransactionActivityLogs({ ); if (!response.ok) { - if (response.status === 401) { + if (response.status === 401 || response.status === 404) { return []; } + return []; + } - // Don't throw on 404 - activity logs might not exist for all transactions - if (response.status === 404) { + const data = (await response.json()) as ActivityLogsResponse; + return data.result.activityLogs; +} + +export async function getSolanaTransactionActivityLogs({ + teamId, + clientId, + transactionId, +}: { + teamId: string; + clientId: string; + transactionId: string; +}): Promise { + const authToken = await getAuthToken(); + + const response = await fetch( + `${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/solana/transactions/activity-logs?transactionId=${transactionId}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + "x-client-id": clientId, + "x-team-id": teamId, + "x-chain-id": "solana:devnet", // TODO: Support multiple Solana networks + }, + method: "GET", + }, + ); + + if (!response.ok) { + if (response.status === 401 || response.status === 404) { return []; } - - console.error( - `Error fetching activity logs: ${response.status} ${response.statusText}`, - ); return []; } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/solana-utils.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/solana-utils.ts new file mode 100644 index 00000000000..35a3048afba --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/solana-utils.ts @@ -0,0 +1,29 @@ +/** + * Get the Solscan explorer URL for a given transaction signature and chain ID + * @param signature - The transaction signature + * @param chainId - The chain ID in format "solana:mainnet", "solana:devnet", or "solana:testnet" + * @returns The Solscan URL for the transaction + */ +export function getSolscanUrl(signature: string, chainId: string): string { + const network = chainId.split(":")[1] || "mainnet"; + + // Solscan uses different subdomains for different networks + switch (network.toLowerCase()) { + case "devnet": + return `https://solscan.io/tx/${signature}?cluster=devnet`; + case "testnet": + return `https://solscan.io/tx/${signature}?cluster=testnet`; + default: + return `https://solscan.io/tx/${signature}`; + } +} + +/** + * Get the display network name from a Solana chain ID (capitalized) + * @param chainId - The chain ID in format "solana:mainnet", "solana:devnet", or "solana:testnet" + * @returns The capitalized network name for display (e.g., "Devnet") + */ +export function getSolanaNetworkName(chainId: string): string { + const network = chainId.split(":")[1] || "mainnet"; + return network.charAt(0).toUpperCase() + network.slice(1); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts index 449d9a6e886..ac5ac44220e 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts @@ -1,6 +1,6 @@ "use client"; -import { encrypt } from "@thirdweb-dev/service-utils"; +import { decrypt, encrypt } from "@thirdweb-dev/service-utils"; import { createAccessToken, createEoa, @@ -514,29 +514,6 @@ export async function createWalletAccessToken(props: { }, type: "eoa:signStructuredMessage", }, - { - metadataPatterns: [ - { - key: "projectId", - rule: { - pattern: props.project.id, - }, - }, - { - key: "teamId", - rule: { - pattern: props.project.teamId, - }, - }, - { - key: "type", - rule: { - pattern: "server-wallet", - }, - }, - ], - type: "eoa:read", - }, { requiredMetadataPatterns: [ { @@ -795,6 +772,187 @@ async function createManagementAccessToken(props: { }); } +/** + * Upgrades existing access tokens to include Solana permissions + * This is needed when a project was created before Solana support was added + * + * Returns an object with success/error instead of throwing for Next.js server actions + */ +export async function upgradeAccessTokensForSolana(props: { + project: Project; + projectSecretKey?: string; +}): Promise<{ + success: boolean; + error?: string; + data?: { + managementToken: string; + walletToken?: string; + }; +}> { + const { project, projectSecretKey } = props; + + try { + // Find the engineCloud service + const engineCloudService = project.services.find( + (service) => service.name === "engineCloud", + ); + + if (!engineCloudService) { + return { + success: false, + error: "No engineCloud service found on project", + }; + } + + const vaultClient = await initVaultClient(); + const hasEncryptedAdminKey = !!engineCloudService.encryptedAdminKey; + + // Check if this is an ejected vault (no encrypted admin key stored) + if (!hasEncryptedAdminKey) { + // For ejected vaults, we only need to update the management token + // User manages their own admin key, so we can't create wallet tokens + + // We need the admin key from the user + if (!projectSecretKey) { + return { + success: false, + error: "Admin key required. Please enter your vault admin key.", + }; + } + + // For ejected vault, the "secret key" parameter is actually the admin key + const managementTokenResult = await createManagementAccessToken({ + project, + adminKey: projectSecretKey, + vaultClient, + }); + + if (!managementTokenResult.success) { + return { + success: false, + error: `Failed to create management token: ${managementTokenResult.error}`, + }; + } + + // Update only the management token for ejected vaults + // Keep everything else the same (no encrypted keys to update) + await updateProjectClient( + { + projectId: project.id, + teamId: project.teamId, + }, + { + services: [ + ...project.services.filter( + (service) => service.name !== "engineCloud", + ), + { + ...engineCloudService, + managementAccessToken: managementTokenResult.data.accessToken, + }, + ], + }, + ); + + return { + success: true, + data: { + managementToken: managementTokenResult.data.accessToken, + }, + }; + } + + // For non-ejected vaults (with encrypted admin key) + if (!projectSecretKey) { + return { + success: false, + error: "Project secret key is required to upgrade tokens", + }; + } + + // Verify the project secret key + const projectSecretKeyHash = await hashSecretKey(projectSecretKey); + if (!project.secretKeys.some((key) => key?.hash === projectSecretKeyHash)) { + return { + success: false, + error: "Invalid project secret key", + }; + } + + // Decrypt the admin key (we know it exists from the hasEncryptedAdminKey check) + const adminKey = await decrypt( + engineCloudService.encryptedAdminKey as string, + projectSecretKey, + ); + + // Create new tokens with Solana permissions + const [managementTokenResult, walletTokenResult] = await Promise.all([ + createManagementAccessToken({ project, adminKey, vaultClient }), + createWalletAccessToken({ project, adminKey, vaultClient }), + ]); + + if (!managementTokenResult.success) { + return { + success: false, + error: `Failed to create management token: ${managementTokenResult.error}`, + }; + } + + if (!walletTokenResult.success) { + return { + success: false, + error: `Failed to create wallet token: ${walletTokenResult.error}`, + }; + } + + const managementToken = managementTokenResult.data; + const walletToken = walletTokenResult.data; + + // Encrypt the new wallet token + const [encryptedAdminKey, encryptedWalletAccessToken] = await Promise.all([ + encrypt(adminKey, projectSecretKey), + encrypt(walletToken.accessToken, projectSecretKey), + ]); + + // Update the project with new tokens + await updateProjectClient( + { + projectId: project.id, + teamId: project.teamId, + }, + { + services: [ + ...project.services.filter( + (service) => service.name !== "engineCloud", + ), + { + ...engineCloudService, + managementAccessToken: managementToken.accessToken, + encryptedAdminKey, + encryptedWalletAccessToken, + }, + ], + }, + ); + + return { + success: true, + data: { + managementToken: managementToken.accessToken, + walletToken: walletToken.accessToken, + }, + }; + } catch (error) { + return { + success: false, + error: + error instanceof Error + ? error.message + : "Failed to upgrade access tokens", + }; + } +} + export function maskSecret(secret: string) { return `${secret.substring(0, 11)}...${secret.substring(secret.length - 5)}`; } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/page.tsx index deeaddbefe4..7b4a2cad209 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/page.tsx @@ -9,9 +9,13 @@ import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; import { TransactionsAnalyticsPageContent } from "./analytics/analytics-page"; import { EngineChecklist } from "./analytics/ftux.client"; import { TransactionAnalyticsSummary } from "./analytics/summary"; +import { ServerWalletsTable } from "./components/server-wallets-table.client"; import { getTransactionAnalyticsSummary } from "./lib/analytics"; import type { Wallet } from "./server-wallets/wallet-table/types"; -import { ServerWalletsTable } from "./server-wallets/wallet-table/wallet-table"; +import { listSolanaAccounts } from "./solana-wallets/lib/vault.client"; +import type { SolanaWallet } from "./solana-wallets/wallet-table/types"; + +export const dynamic = "force-dynamic"; export default async function TransactionsAnalyticsPage(props: { params: Promise<{ team_slug: string; project_slug: string }>; @@ -20,7 +24,9 @@ export default async function TransactionsAnalyticsPage(props: { to?: string | string[] | undefined; interval?: string | string[] | undefined; testTxWithWallet?: string | string[] | undefined; + testSolanaTxWithWallet?: string | string[] | undefined; page?: string; + solana_page?: string; }>; }) { const [params, searchParams, authToken] = await Promise.all([ @@ -58,6 +64,7 @@ export default async function TransactionsAnalyticsPage(props: { const pageSize = 10; const currentPage = Number.parseInt(searchParams.page ?? "1"); + const solanCurrentPage = Number.parseInt(searchParams.solana_page ?? "1"); const eoas = managementAccessToken ? await listEoas({ @@ -77,6 +84,32 @@ export default async function TransactionsAnalyticsPage(props: { const wallets = eoas.data?.items as Wallet[] | undefined; + // Fetch Solana accounts - gracefully handle permission errors + let solanaAccounts: { + data: { items: SolanaWallet[]; totalRecords: number }; + error: Error | null; + success: boolean; + }; + + if (managementAccessToken) { + solanaAccounts = await listSolanaAccounts({ + managementAccessToken, + page: solanCurrentPage, + limit: pageSize, + projectId: project.id, + }); + } else { + solanaAccounts = { + data: { items: [], totalRecords: 0 }, + error: null, + success: true, + }; + } + + // Check if error is a permission error + const isSolanaPermissionError = + solanaAccounts.error?.message.includes("AUTH_INSUFFICIENT_SCOPE") ?? false; + const initialData = await getTransactionAnalyticsSummary({ clientId: project.publishableKey, teamId: project.teamId, @@ -127,39 +160,63 @@ export default async function TransactionsAnalyticsPage(props: { project={project} teamSlug={params.team_slug} testTxWithWallet={searchParams.testTxWithWallet as string | undefined} + testSolanaTxWithWallet={ + searchParams.testSolanaTxWithWallet as string | undefined + } wallets={wallets ?? []} + solanaWallets={solanaAccounts.data.items} /> - {hasTransactions && !searchParams.testTxWithWallet && ( - - )} + {hasTransactions && + !searchParams.testTxWithWallet && + !searchParams.testSolanaTxWithWallet && ( + + )} {/* transactions */} - {/* server wallets */} + {/* Server Wallets (EVM + Solana) */} {eoas.error ? ( -
Error: {eoas.error.message}
+
+

+ EVM Wallet Error +

+

+ {eoas.error.message} +

+
) : ( )}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/server-wallets/wallet-table/wallet-table-ui.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/server-wallets/wallet-table/wallet-table-ui.client.tsx deleted file mode 100644 index 68375c24822..00000000000 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/server-wallets/wallet-table/wallet-table-ui.client.tsx +++ /dev/null @@ -1,519 +0,0 @@ -"use client"; - -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { format, formatDistanceToNowStrict } from "date-fns"; -import { - CheckIcon, - MoreVerticalIcon, - RefreshCcwIcon, - SendIcon, - XIcon, -} from "lucide-react"; -import Link from "next/link"; -import { useState } from "react"; -import { toast } from "sonner"; -import type { ThirdwebClient } from "thirdweb"; -import { useWalletBalance } from "thirdweb/react"; -import { - DEFAULT_ACCOUNT_FACTORY_V0_7, - predictSmartAccountAddress, -} from "thirdweb/wallets/smart"; -import type { Project } from "@/api/project/projects"; -import { FundWalletModal } from "@/components/blocks/fund-wallets-modal"; -import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; -import { WalletAddress } from "@/components/blocks/wallet-address"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Label } from "@/components/ui/label"; -import { - Pagination, - PaginationContent, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from "@/components/ui/pagination"; -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 { useV5DashboardChain } from "@/hooks/chains/v5-adapter"; -import { WalletProductIcon } from "@/icons/WalletProductIcon"; -import { useDashboardRouter } from "@/lib/DashboardRouter"; -import { cn } from "@/lib/utils"; -import { updateDefaultProjectWallet } from "../../lib/vault.client"; -import CreateServerWallet from "../components/create-server-wallet.client"; -import type { Wallet } from "./types"; - -export function ServerWalletsTableUI({ - wallets, - project, - teamSlug, - managementAccessToken, - totalRecords, - currentPage, - totalPages, - client, -}: { - wallets: Wallet[]; - project: Project; - teamSlug: string; - managementAccessToken: string | undefined; - totalRecords: number; - currentPage: number; - totalPages: number; - client: ThirdwebClient; -}) { - const [selectedChainId, setSelectedChainId] = useState(1); - const [showSmartAccount, setShowSmartAccount] = useState(false); - const queryClient = useQueryClient(); - - return ( -
-
-
-
-
-
- -
-
-

- Server Wallets -

-

- Create and manage server wallets for your project -

-
- -
-
- - -
- -
- - - -
-
-
- - - - - - Label - - {showSmartAccount - ? "Smart Account Address" - : "Wallet Address"} - - -
- Balance - {wallets.length > 0 && ( - - - - )} -
-
- Created - Actions -
-
- - {wallets.map((wallet) => ( - - ))} - -
- - {wallets.length === 0 && ( -
-
- -
-

No server wallets found

-
- )} -
- - {totalPages > 1 && ( -
-
- Found {totalRecords} server wallets -
- - - - 1 ? currentPage - 1 : 1 - }`} - legacyBehavior - passHref - > - - - - {Array.from({ length: totalPages }, (_, i) => i + 1).map( - (pageNumber) => ( - - - - {pageNumber} - - - - ), - )} - - - = totalPages - ? "pointer-events-none opacity-50" - : "" - } - /> - - - - -
- )} -
-
- ); -} - -function ServerWalletTableRow(props: { - wallet: Wallet; - project: Project; - teamSlug: string; - client: ThirdwebClient; - chainId: number; - showSmartAccount: boolean; -}) { - const { wallet, project, teamSlug, client, chainId, showSmartAccount } = - props; - - const chain = useV5DashboardChain(chainId); - - const smartAccountAddressQuery = useQuery({ - queryFn: async () => { - const smartAccountAddress = await predictSmartAccountAddress({ - adminAddress: wallet.address, - - chain: chain, - client: client, - factoryAddress: DEFAULT_ACCOUNT_FACTORY_V0_7, - }); - return smartAccountAddress; - }, - enabled: showSmartAccount, - queryKey: ["smart-account-address", wallet.address, chainId], - }); - - // Get the project wallet address - const engineCloudService = project.services.find( - (service) => service.name === "engineCloud", - ); - const defaultWalletAddress = engineCloudService?.projectWalletAddress; - const isDefaultWallet = - defaultWalletAddress && - wallet.address.toLowerCase() === defaultWalletAddress.toLowerCase(); - - return ( - - {/* Label */} - -
- - {wallet.metadata.label || "N/A"} - - {isDefaultWallet && ( - - default - - )} -
-
- - {/* Address */} - - {showSmartAccount ? ( -
- {smartAccountAddressQuery.data ? ( - - ) : smartAccountAddressQuery.isPending ? ( - - ) : ( - N/A - )} -
- ) : ( - - )} -
- - {/* Balance */} - - {showSmartAccount && ( - // biome-ignore lint/complexity/noUselessFragments: keep for readability - <> - {smartAccountAddressQuery.isPending ? ( - - ) : smartAccountAddressQuery.data ? ( - - ) : ( - N/A - )} - - )} - - {!showSmartAccount && ( - - )} - - - - - - - - - -
- ); -} - -function WalletDateCell({ date }: { date: string }) { - if (!date) { - return "N/A"; - } - - const dateObj = new Date(date); - return ( - -

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

-
- ); -} - -function WalletActionsDropdown(props: { - fundWalletAddress: string | undefined; - wallet: Wallet; - teamSlug: string; - project: Project; - client: ThirdwebClient; - chainId: number; -}) { - const [showFundModal, setShowFundModal] = useState(false); - const router = useDashboardRouter(); - - const setAsDefaultMutation = useMutation({ - mutationFn: async () => { - await updateDefaultProjectWallet({ - project: props.project, - projectWalletAddress: props.wallet.address, - }); - }, - onSuccess: () => { - toast.success("Wallet set as project wallet"); - router.refresh(); - }, - onError: (error) => { - toast.error( - error instanceof Error ? error.message : "Failed to set default wallet", - ); - }, - }); - - return ( - <> - - - - - - - - - Send test transaction - - - {props.fundWalletAddress && ( - setShowFundModal(true)} - className="flex items-center gap-2 h-9 rounded-lg" - > - - Fund wallet - - )} - setAsDefaultMutation.mutate()} - disabled={setAsDefaultMutation.isPending} - className="flex items-center gap-2 h-9 rounded-lg" - > - - Set as default - - - - - {props.fundWalletAddress && ( - - )} - - ); -} - -function WalletBalanceCell(props: { - address: string; - chainId: number; - client: ThirdwebClient; -}) { - const chain = useV5DashboardChain(props.chainId); - const balance = useWalletBalance({ - address: props.address, - chain: chain, - client: props.client, - }); - - if (balance.isFetching) { - return ; - } - - if (!balance.data) { - return N/A; - } - - return ( - - {balance.data.displayValue} {balance.data.symbol} - - ); -} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/server-wallets/wallet-table/wallet-table.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/server-wallets/wallet-table/wallet-table.tsx deleted file mode 100644 index f47fadcd1de..00000000000 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/server-wallets/wallet-table/wallet-table.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type { ThirdwebClient } from "thirdweb"; -import type { Project } from "@/api/project/projects"; -import type { Wallet } from "./types"; -import { ServerWalletsTableUI } from "./wallet-table-ui.client"; - -export function ServerWalletsTable({ - wallets, - project, - teamSlug, - currentPage, - totalPages, - totalRecords, - managementAccessToken, - client, -}: { - wallets: Wallet[]; - project: Project; - teamSlug: string; - managementAccessToken: string | undefined; - totalRecords: number; - currentPage: number; - totalPages: number; - client: ThirdwebClient; -}) { - return ( - - ); -} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/solana-wallets/components/create-solana-wallet.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/solana-wallets/components/create-solana-wallet.client.tsx new file mode 100644 index 00000000000..c8702748f26 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/solana-wallets/components/create-solana-wallet.client.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { PlusIcon } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import type { Project } from "@/api/project/projects"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { createSolanaAccount } from "../lib/vault.client"; + +export function CreateSolanaWallet(props: { + managementAccessToken: string | undefined; + project: Project; + teamSlug: string; + disabled?: boolean; +}) { + const [open, setOpen] = useState(false); + const [label, setLabel] = useState(""); + const router = useDashboardRouter(); + + const createMutation = useMutation({ + mutationFn: async () => { + if (!props.managementAccessToken) { + throw new Error("No management access token"); + } + + const result = await createSolanaAccount({ + managementAccessToken: props.managementAccessToken, + label: label.trim(), + projectId: props.project.id, + teamId: props.project.teamId, + }); + + if (!result.success || !result.data) { + throw result.error || new Error("Failed to create Solana wallet"); + } + + return result.data; + }, + onSuccess: () => { + toast.success("Solana wallet created successfully"); + setOpen(false); + setLabel(""); + router.refresh(); + }, + onError: (error) => { + toast.error( + error instanceof Error + ? error.message + : "Failed to create Solana wallet", + ); + }, + }); + + return ( + + + + + + + Create Solana Wallet + + Create a new Solana server wallet for your project + + +
{ + e.preventDefault(); + createMutation.mutate(); + }} + > +
+
+ + setLabel(e.target.value)} + required + /> +
+ +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/solana-wallets/components/upgrade-solana-permissions.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/solana-wallets/components/upgrade-solana-permissions.client.tsx new file mode 100644 index 00000000000..dfa0ff77b2e --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/solana-wallets/components/upgrade-solana-permissions.client.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { KeyRoundIcon, Loader2Icon, ShieldCheckIcon } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import * as z from "zod"; +import type { Project } from "@/api/project/projects"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { upgradeAccessTokensForSolana } from "../../lib/vault.client"; + +const formSchema = z.object({ + secretKey: z.string().min(1, "Secret key is required"), +}); + +type FormValues = z.infer; + +export function UpgradeSolanaPermissions(props: { project: Project }) { + const router = useDashboardRouter(); + const [isComplete, setIsComplete] = useState(false); + + // Check if this is an ejected vault + const engineCloudService = props.project.services.find( + (s) => s.name === "engineCloud", + ); + const isEjectedVault = !engineCloudService?.encryptedAdminKey; + + const form = useForm({ + defaultValues: { + secretKey: "", + }, + resolver: zodResolver(formSchema), + }); + + const upgradeMutation = useMutation({ + mutationFn: async (data: FormValues) => { + return upgradeAccessTokensForSolana({ + project: props.project, + projectSecretKey: data.secretKey, + }); + }, + onSuccess: (result) => { + if (!result.success) { + toast.error(result.error || "Failed to upgrade Solana permissions"); + return; + } + setIsComplete(true); + toast.success("Solana permissions enabled successfully!"); + // Refresh the page after a short delay to show updated state + setTimeout(() => { + router.refresh(); + }, 1500); + }, + onError: (error) => { + toast.error( + error instanceof Error + ? error.message + : "Failed to upgrade Solana permissions", + ); + }, + }); + + if (isComplete) { + return ( + + +
+
+ +
+
+ + Solana Access Enabled + + + Your project now has full Solana functionality + +
+
+
+ +
+ +

+ Refreshing page to load Solana wallets... +

+
+
+
+ ); + } + + return ( + + +
+
+ +
+
+ Enable Solana Functionality + + Upgrade your project to support Solana wallets and transactions + +
+
+
+ + + + {isEjectedVault + ? "Why do you need my admin key?" + : "Why do you need my secret key?"} + + +

+ Your project was created before Solana support was added. To + enable Solana features, we need to update your access tokens with + new permissions. +

+ {isEjectedVault ? ( + <> +

+ Since you're using an ejected vault, your admin key is used + to: +

+
    +
  • Create new access tokens with Solana permissions
  • +
  • Update your management token with the new permissions
  • +
+

+ Your admin key is never stored and is only used during this + one-time upgrade process. +

+ + ) : ( + <> +

Your secret key is used to:

+
    +
  • Decrypt your existing admin credentials
  • +
  • Create new access tokens with Solana permissions
  • +
  • Re-encrypt and securely store the updated credentials
  • +
+

+ Your secret key is never stored and is only used during this + one-time upgrade process. +

+ + )} +
+
+ +
upgradeMutation.mutate(data))} + > +
+ + + {form.formState.errors.secretKey && ( +

+ {form.formState.errors.secretKey.message} +

+ )} +

+ {isEjectedVault + ? "This is the admin key you received when you ejected your vault" + : "You can find your secret key in your project settings"} +

+
+ +
+ +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/solana-wallets/lib/vault.client.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/solana-wallets/lib/vault.client.ts new file mode 100644 index 00000000000..c493bd58219 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/solana-wallets/lib/vault.client.ts @@ -0,0 +1,200 @@ +"use server"; + +import { + createVaultClient, + createSolanaAccount as vaultCreateSolanaAccount, + listSolanaAccounts as vaultListSolanaAccounts, +} from "@thirdweb-dev/vault-sdk"; +import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs"; +import type { SolanaWallet } from "../wallet-table/types"; + +interface VaultSolanaAccountListItem { + id: string; + pubkey: string; + createdAt: string; + updatedAt: string; + metadata: { + projectId?: string; + type?: string; + label?: string; + } | null; +} + +interface SolanaAccountResponse { + pubkey: string; + createdAt: string; + updatedAt: string; +} + +export async function listSolanaAccounts(params: { + managementAccessToken: string; + page?: number; + limit?: number; + projectId?: string; +}): Promise<{ + data: { + items: SolanaWallet[]; + totalRecords: number; + }; + error: Error | null; + success: boolean; +}> { + const { managementAccessToken, page = 1, limit = 100, projectId } = params; + + if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) { + return { + data: { + items: [], + totalRecords: 0, + }, + error: new Error("Missing managementAccessToken or vault URL"), + success: false, + }; + } + + try { + const vaultClient = await createVaultClient({ + baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL, + }); + + const response = await vaultListSolanaAccounts({ + client: vaultClient, + request: { + auth: { + accessToken: managementAccessToken, + }, + options: { + page: page - 1, // Vault SDK uses 0-based pagination + pageSize: limit, + }, + }, + }); + + if (!response.success || !response.data) { + return { + data: { + items: [], + totalRecords: 0, + }, + error: response.error + ? new Error(JSON.stringify(response.error)) + : new Error("Failed to fetch Solana accounts"), + success: false, + }; + } + + const items = (response.data.items || []) as VaultSolanaAccountListItem[]; + + // Filter by projectId and type, transform to SolanaWallet type + const wallets: SolanaWallet[] = items + .filter((item) => { + // Filter by projectId + if (projectId && item.metadata?.projectId !== projectId) { + return false; + } + // Only include server-wallet type + return !item.metadata?.type || item.metadata.type === "server-wallet"; + }) + .map((item) => ({ + id: item.id, + publicKey: item.pubkey, + metadata: { + type: "server-wallet", + projectId: item.metadata?.projectId || projectId || "", + label: item.metadata?.label, + }, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + })); + + return { + data: { + items: wallets, + totalRecords: wallets.length, // Use filtered count since we're filtering by projectId + }, + error: null, + success: true, + }; + } catch (error) { + console.error("Failed to list Solana accounts", error); + return { + data: { + items: [], + totalRecords: 0, + }, + error: error instanceof Error ? error : new Error("Unknown error"), + success: false, + }; + } +} + +export async function createSolanaAccount(params: { + managementAccessToken: string; + label: string; + projectId: string; + teamId: string; +}): Promise<{ + data: SolanaAccountResponse | null; + error: Error | null; + success: boolean; +}> { + const { managementAccessToken, label, projectId, teamId } = params; + + if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) { + return { + data: null, + error: new Error("Missing managementAccessToken or vault URL"), + success: false, + }; + } + + try { + const vaultClient = await createVaultClient({ + baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL, + }); + + const response = await vaultCreateSolanaAccount({ + client: vaultClient, + request: { + auth: { + accessToken: managementAccessToken, + }, + options: { + metadata: { + label, + projectId, + teamId, + type: "server-wallet", + }, + }, + }, + }); + + if (!response.success || !response.data) { + return { + data: null, + error: response.error + ? new Error(JSON.stringify(response.error)) + : new Error("Failed to create Solana account"), + success: false, + }; + } + + return { + data: { + pubkey: response.data.pubkey, + createdAt: response.data.createdAt, + updatedAt: response.data.updatedAt, + }, + error: null, + success: true, + }; + } catch (error) { + console.error("Failed to create Solana account", error); + return { + data: null, + error: error instanceof Error ? error : new Error("Unknown error"), + success: false, + }; + } +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/solana-wallets/wallet-table/types.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/solana-wallets/wallet-table/types.ts new file mode 100644 index 00000000000..65efa09b5bd --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/solana-wallets/wallet-table/types.ts @@ -0,0 +1,11 @@ +export type SolanaWallet = { + id: string; + publicKey: string; + metadata: { + type: string; + projectId: string; + label?: string; + }; + createdAt: string; + updatedAt: string; +}; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/layout.tsx index 33fec59a3a2..7ddfde1918b 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/layout.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/layout.tsx @@ -1,25 +1,27 @@ import { ChevronLeftIcon } from "lucide-react"; import Link from "next/link"; -export default function TransactionLayout({ +export default async function TransactionLayout({ children, params, }: { children: React.ReactNode; - params: { team_slug: string; project_slug: string }; + params: Promise<{ team_slug: string; project_slug: string }>; }) { + const { team_slug, project_slug } = await params; + return ( -
+
Back to Transactions
-
{children}
+
{children}
); } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/page.tsx index 536a350e77b..3fcf0b5aab3 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/page.tsx @@ -15,9 +15,12 @@ import { serverThirdwebClient } from "@/constants/thirdweb-client.server"; import { loginRedirect } from "@/utils/redirects"; import type { Transaction } from "../../analytics/tx-table/types"; import { + getSingleSolanaTransaction, getSingleTransaction, + getSolanaTransactionActivityLogs, getTransactionActivityLogs, } from "../../lib/analytics"; +import { SolanaTransactionDetailsUI } from "./solana-transaction-details-ui"; import { TransactionDetailsUI } from "./transaction-details-ui"; type AbiItem = @@ -195,17 +198,18 @@ export default async function TransactionPage({ redirect(`/team/${team_slug}`); } - const [transactionData, activityLogs] = await Promise.all([ + // Try fetching both EVM and Solana transactions + const [evmTransactionData, solanaTransactionData] = await Promise.all([ getSingleTransaction({ clientId: project.publishableKey, teamId: project.teamId, transactionId: id, - }), - getTransactionActivityLogs({ + }).catch(() => null), + getSingleSolanaTransaction({ clientId: project.publishableKey, teamId: project.teamId, transactionId: id, - }), + }).catch(() => null), ]); const client = getClientThirdwebClient({ @@ -213,23 +217,50 @@ export default async function TransactionPage({ teamId: project.teamId, }); - if (!transactionData) { - notFound(); + // Determine which transaction type we have and fetch appropriate activity logs + if (solanaTransactionData) { + const activityLogs = await getSolanaTransactionActivityLogs({ + clientId: project.publishableKey, + teamId: project.teamId, + transactionId: id, + }); + + // Render Solana transaction details + return ( + + ); } - // Decode transaction data on the server - const decodedTransactionData = await decodeTransactionData(transactionData); + if (evmTransactionData) { + const activityLogs = await getTransactionActivityLogs({ + clientId: project.publishableKey, + teamId: project.teamId, + transactionId: id, + }); - return ( -
+ // Decode transaction data on the server for EVM + const decodedTransactionData = + await decodeTransactionData(evmTransactionData); + + // Render EVM transaction details + return ( -
- ); + ); + } + + // No transaction found + notFound(); } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/solana-transaction-details-ui.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/solana-transaction-details-ui.tsx new file mode 100644 index 00000000000..534073947b6 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/solana-transaction-details-ui.tsx @@ -0,0 +1,459 @@ +"use client"; + +import { format, formatDistanceToNowStrict } from "date-fns"; +import { + ChevronDownIcon, + ChevronRightIcon, + ExternalLinkIcon, +} from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import type { ThirdwebClient } from "thirdweb"; +import { stringify } from "thirdweb/utils"; +import type { Project } from "@/api/project/projects"; +import { SolanaAddress } from "@/components/blocks/solana-address"; +import { Badge } from "@/components/ui/badge"; +import { CopyTextButton } from "@/components/ui/CopyTextButton"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { CodeClient } from "@/components/ui/code/code.client"; +import { TabButtons } from "@/components/ui/tabs"; +import { ToolTipLabel } from "@/components/ui/tooltip"; +import type { SolanaTransaction } from "../../analytics/solana-tx-table/types"; +import type { ActivityLogEntry } from "../../lib/analytics"; +import { getSolanaNetworkName, getSolscanUrl } from "../../lib/solana-utils"; + +const solanaStatusDetails = { + CONFIRMED: { name: "Confirmed", variant: "success" as const }, + FAILED: { name: "Failed", variant: "destructive" as const }, + QUEUED: { name: "Queued", variant: "warning" as const }, + SUBMITTED: { name: "Submitted", variant: "warning" as const }, +}; + +export function SolanaTransactionDetailsUI({ + transaction, + activityLogs, +}: { + transaction: SolanaTransaction; + teamSlug: string; + client: ThirdwebClient; + project: Project; + activityLogs: ActivityLogEntry[]; +}) { + const [activeTab, setActiveTab] = useState<"overview" | "logs" | "raw">( + "overview", + ); + + // Extract relevant data from transaction + const { + id, + chainId, + signerAddress, + signature, + confirmedAt, + createdAt, + executionResult, + errorMessage, + } = transaction; + + const status = (executionResult?.status || + transaction.status || + "QUEUED") as keyof typeof solanaStatusDetails; + + // Parse network from chainId + const network = getSolanaNetworkName(chainId); + const networkDisplay = network.charAt(0).toUpperCase() + network.slice(1); + + // Calculate time difference between creation and confirmation + const confirmationTime = + confirmedAt && createdAt + ? new Date(confirmedAt).getTime() - + (createdAt instanceof Date ? createdAt : new Date(createdAt)).getTime() + : null; + + // Get signature from executionResult if available + const txSignature = + (executionResult && "signature" in executionResult + ? executionResult.signature + : signature) || null; + + // Get slot and blockTime + const slot = + executionResult && "slot" in executionResult + ? executionResult.slot + : transaction.confirmedAtSlot; + const blockTime = + executionResult && "blockTime" in executionResult + ? executionResult.blockTime + : transaction.blockTime; + + return ( + <> + {/* Transaction ID Header */} +
+
+

+ Transaction Details +

+
+ Queue ID: + +
+
+ + {solanaStatusDetails[status].name} + +
+ + {/* Tabs */} + setActiveTab("overview"), + isActive: activeTab === "overview", + }, + { + name: "Activity Logs", + onClick: () => setActiveTab("logs"), + isActive: activeTab === "logs", + }, + { + name: "Raw Data", + onClick: () => setActiveTab("raw"), + isActive: activeTab === "raw", + }, + ]} + /> + + {/* Overview Tab */} + {activeTab === "overview" && ( +
+ {/* Transaction Information */} + + + Transaction Information + + + + + + + + {txSignature && ( + + + + {txSignature.slice(0, 12)}...{txSignature.slice(-12)} + + + + + )} + {slot && } + {errorMessage && ( + + + {errorMessage} + + + )} + + + + {/* Timing Information */} + + + Timing Information + + + + + + {formatDistanceToNowStrict( + createdAt instanceof Date + ? createdAt + : new Date(createdAt), + { addSuffix: true }, + )} + + + + {confirmedAt && ( + <> + + + + {formatDistanceToNowStrict(new Date(confirmedAt), { + addSuffix: true, + })} + + + + {confirmationTime && ( + + )} + + )} + {blockTime && ( + + )} + + + + {/* Transaction Instructions */} + {transaction.transactionParams && + transaction.transactionParams.instructions.length > 0 && ( + + + Instructions + + +
+ {transaction.transactionParams.instructions.map( + (instruction, index) => ( + + ), + )} +
+
+
+ )} + + {/* Execution Details */} + {executionResult && ( + + + Execution Details + + + + + + )} +
+ )} + + {/* Activity Logs Tab */} + {activeTab === "logs" && ( + + + Activity Logs + + + {activityLogs.length === 0 ? ( +

+ No activity logs available +

+ ) : ( +
+ {activityLogs.map((log) => ( + + ))} +
+ )} +
+
+ )} + + {/* Raw Data Tab */} + {activeTab === "raw" && ( + + + Raw Transaction Data + + + + + + )} + + ); +} + +function InfoRow({ + label, + value, + children, +}: { + label: string; + value?: string; + children?: React.ReactNode; +}) { + return ( +
+
{label}
+
+ {children || {value || "N/A"}} +
+
+ ); +} + +function InstructionCard({ + instruction, + index, +}: { + instruction: { + programId: string; + keys: Array<{ + pubkey: string; + isSigner: boolean; + isWritable: boolean; + }>; + data: string; + }; + index: number; +}) { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+ + + {isExpanded && ( +
+
+

+ Program ID +

+ + {instruction.programId} + +
+ +
+

+ Accounts ({instruction.keys.length}) +

+
+ {instruction.keys.map((key, idx) => ( +
+ + {key.pubkey} + +
+ {key.isSigner && ( + + Signer + + )} + {key.isWritable && ( + + Writable + + )} +
+
+ ))} +
+
+ +
+

+ Data +

+ + {instruction.data || "No data"} + +
+
+ )} +
+ ); +} + +function ActivityLogItem({ log }: { log: ActivityLogEntry }) { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+ + + {isExpanded && log.payload && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/transaction-details-ui.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/transaction-details-ui.tsx index c6e4324820b..0d8f03639c7 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/transaction-details-ui.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/transaction-details-ui.tsx @@ -22,8 +22,8 @@ import { TabButtons } from "@/components/ui/tabs"; import { ToolTipLabel } from "@/components/ui/tooltip"; import { useAllChainsData } from "@/hooks/chains/allChains"; import { ChainIconClient } from "@/icons/ChainIcon"; -import { statusDetails } from "../../analytics/tx-table/tx-table-ui"; import type { Transaction } from "../../analytics/tx-table/types"; +import { statusDetails } from "../../analytics/tx-table/types"; import type { ActivityLogEntry } from "../../lib/analytics"; import type { DecodedTransactionData, DecodedTransactionResult } from "./page"; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/page.tsx index 523b38c5332..4019cd88dda 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/page.tsx @@ -12,11 +12,15 @@ import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; import { WalletProductIcon } from "@/icons/WalletProductIcon"; import { getFiltersFromSearchParams } from "@/lib/time"; import { loginRedirect } from "@/utils/redirects"; +import { ServerWalletsTable } from "../transactions/components/server-wallets-table.client"; import type { Wallet } from "../transactions/server-wallets/wallet-table/types"; -import { ServerWalletsTable } from "../transactions/server-wallets/wallet-table/wallet-table"; +import { listSolanaAccounts } from "../transactions/solana-wallets/lib/vault.client"; +import type { SolanaWallet } from "../transactions/solana-wallets/wallet-table/types"; import { InAppWalletAnalytics } from "./analytics/chart"; import { InAppWalletsSummary } from "./analytics/chart/Summary"; +export const dynamic = "force-dynamic"; + export default async function Page(props: { params: Promise<{ team_slug: string; project_slug: string }>; searchParams: Promise<{ @@ -25,6 +29,7 @@ export default async function Page(props: { type?: string; interval?: string; page?: string; + solana_page?: string; }>; }) { const [searchParams, params] = await Promise.all([ @@ -66,7 +71,9 @@ export default async function Page(props: { // Fetch server wallets with pagination (5 per page) const pageSize = 5; const currentPage = Number.parseInt(searchParams.page ?? "1"); + const solanCurrentPage = Number.parseInt(searchParams.solana_page ?? "1"); + // Fetch EVM wallets const eoas = vaultClient && managementAccessToken ? await listEoas({ @@ -84,7 +91,32 @@ export default async function Page(props: { }) : { data: { items: [], totalRecords: 0 }, error: null, success: true }; - const serverWallets = eoas.data?.items as Wallet[] | undefined; + // Fetch Solana wallets + let solanaAccounts: { + data: { items: SolanaWallet[]; totalRecords: number }; + error: Error | null; + success: boolean; + }; + + if (managementAccessToken) { + solanaAccounts = await listSolanaAccounts({ + managementAccessToken, + page: solanCurrentPage, + limit: pageSize, + projectId: project.id, + }); + } else { + solanaAccounts = { + data: { items: [], totalRecords: 0 }, + error: null, + success: true, + }; + } + + // Check for Solana permission errors + const isSolanaPermissionError = solanaAccounts.error?.message?.includes( + "AUTH_INSUFFICIENT_SCOPE", + ); const client = getClientThirdwebClient({ jwt: authToken, @@ -143,18 +175,34 @@ export default async function Page(props: { authToken={authToken} /> - {/* Server Wallets Section */} + {/* Server Wallets Section (EVM + Solana) */}
- {eoas.error ? null : ( + {eoas.error ? ( +
+

+ EVM Wallet Error +

+

+ {eoas.error.message || "Failed to load EVM wallets"} +

+
+ ) : ( )}
diff --git a/packages/vault-sdk/src/types.ts b/packages/vault-sdk/src/types.ts index 4d1b07a7128..00cfc805dff 100644 --- a/packages/vault-sdk/src/types.ts +++ b/packages/vault-sdk/src/types.ts @@ -503,7 +503,7 @@ type CreateSolanaAccountData = { }; type GetSolanaAccountsData = { - accounts: CreateSolanaAccountData[]; + items: CreateSolanaAccountData[]; totalCount: number; page: number; pageSize: number;