diff --git a/bun.lock b/bun.lock index 45917fce..0a612bb7 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "openscan", "dependencies": { "@erc7730/sdk": "^0.1.3", - "@openscan/network-connectors": "1.6.0", + "@openscan/network-connectors": "1.7", "@rainbow-me/rainbowkit": "^2.2.8", "@react-native-async-storage/async-storage": "^1.24.0", "@tanstack/react-query": "^5.90.21", @@ -44,6 +44,7 @@ "overrides": { "@noble/curves": "^1.8.0", "@noble/hashes": "^1.8.0", + "axios": "^1.15.0", }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], @@ -282,7 +283,7 @@ "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], - "@openscan/network-connectors": ["@openscan/network-connectors@1.6.0", "", {}, "sha512-f7Ox20+NIJ4DXzcbj0OyyuVjiHB0zjm1xv+yhzrYhh6TfW6bfHpq62+++oF6/5ud5VLptRXSkdaMrLcAOJL0vQ=="], + "@openscan/network-connectors": ["@openscan/network-connectors@1.7.0", "", {}, "sha512-eXW/r2AxWLEogm5eZBpqZ/BEunlG9fcCN5pf6piXncetEhf3soN1JLX5aSAOQ5fYKF3M0lIcnDB9uaPdq3n6nA=="], "@paulmillr/qr": ["@paulmillr/qr@0.2.1", "", {}, "sha512-IHnV6A+zxU7XwmKFinmYjUcwlyK9+xkG3/s9KcQhI9BjQKycrJ1JRO+FbNYPwZiPKW3je/DR0k7w8/gLa5eaxQ=="], @@ -678,7 +679,7 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], - "axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="], + "axios": ["axios@1.15.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="], "axios-retry": ["axios-retry@4.5.0", "", { "dependencies": { "is-retry-allowed": "^2.2.0" }, "peerDependencies": { "axios": "0.x || 1.x" } }, "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ=="], @@ -1420,7 +1421,7 @@ "proxy-compare": ["proxy-compare@2.6.0", "", {}, "sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw=="], - "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], diff --git a/package.json b/package.json index 75eb1cc1..26637827 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,12 @@ }, "overrides": { "@noble/hashes": "^1.8.0", - "@noble/curves": "^1.8.0" + "@noble/curves": "^1.8.0", + "axios": "^1.15.0" }, "dependencies": { "@erc7730/sdk": "^0.1.3", - "@openscan/network-connectors": "1.6.0", + "@openscan/network-connectors": "1.7", "@rainbow-me/rainbowkit": "^2.2.8", "@react-native-async-storage/async-storage": "^1.24.0", "@tanstack/react-query": "^5.90.21", diff --git a/src/App.tsx b/src/App.tsx index cfbb290b..0f2843fe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -44,6 +44,14 @@ import { LazyRpcs, LazySearch, LazySettings, + LazySolanaAccount, + LazySolanaNetwork, + LazySolanaSlot, + LazySolanaSlots, + LazySolanaToken, + LazySolanaTx, + LazySolanaTxs, + LazySolanaValidators, LazySupporters, LazyTokenDetails, LazyTx, @@ -151,6 +159,33 @@ function AppContent() { } /> } /> } /> + {/* Solana Mainnet routes (must come before :networkId catch-all) */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* Solana Devnet routes */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* Solana Testnet routes */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> {/* EVM network routes — validated */} }> } /> diff --git a/src/components/LazyComponents.tsx b/src/components/LazyComponents.tsx index 6d608e33..1777fd01 100644 --- a/src/components/LazyComponents.tsx +++ b/src/components/LazyComponents.tsx @@ -20,6 +20,16 @@ const BitcoinTransactionsPage = lazy(() => import("./pages/bitcoin/BitcoinTransa const BitcoinAddressPage = lazy(() => import("./pages/bitcoin/BitcoinAddressPage")); const BitcoinMempoolPage = lazy(() => import("./pages/bitcoin/BitcoinMempoolPage")); +// Lazy load page components - Solana +const SolanaNetwork = lazy(() => import("./pages/solana")); +const SolanaSlotsPage = lazy(() => import("./pages/solana/SolanaSlotsPage")); +const SolanaSlotPage = lazy(() => import("./pages/solana/SolanaSlotPage")); +const SolanaTransactionsPage = lazy(() => import("./pages/solana/SolanaTransactionsPage")); +const SolanaTransactionPage = lazy(() => import("./pages/solana/SolanaTransactionPage")); +const SolanaAccountPage = lazy(() => import("./pages/solana/SolanaAccountPage")); +const SolanaTokenPage = lazy(() => import("./pages/solana/SolanaTokenPage")); +const SolanaValidatorsPage = lazy(() => import("./pages/solana/SolanaValidatorsPage")); + // Lazy load page components - EVM const Chain = lazy(() => import("./pages/evm/network")); const Blocks = lazy(() => import("./pages/evm/blocks")); @@ -54,6 +64,14 @@ export const LazyBitcoinTx = withSuspense(BitcoinTransactionPage); export const LazyBitcoinTxs = withSuspense(BitcoinTransactionsPage); export const LazyBitcoinAddress = withSuspense(BitcoinAddressPage); export const LazyBitcoinMempool = withSuspense(BitcoinMempoolPage); +export const LazySolanaNetwork = withSuspense(SolanaNetwork); +export const LazySolanaSlots = withSuspense(SolanaSlotsPage); +export const LazySolanaSlot = withSuspense(SolanaSlotPage); +export const LazySolanaTxs = withSuspense(SolanaTransactionsPage); +export const LazySolanaTx = withSuspense(SolanaTransactionPage); +export const LazySolanaAccount = withSuspense(SolanaAccountPage); +export const LazySolanaToken = withSuspense(SolanaTokenPage); +export const LazySolanaValidators = withSuspense(SolanaValidatorsPage); export const LazyBlocks = withSuspense(Blocks); export const LazyBlock = withSuspense(Block); export const LazyTxs = withSuspense(Txs); @@ -91,6 +109,15 @@ export function preloadAllRoutes() { import("./pages/bitcoin/BitcoinTransactionsPage"); import("./pages/bitcoin/BitcoinAddressPage"); import("./pages/bitcoin/BitcoinMempoolPage"); + // Solana pages + import("./pages/solana"); + import("./pages/solana/SolanaSlotsPage"); + import("./pages/solana/SolanaSlotPage"); + import("./pages/solana/SolanaTransactionsPage"); + import("./pages/solana/SolanaTransactionPage"); + import("./pages/solana/SolanaAccountPage"); + import("./pages/solana/SolanaTokenPage"); + import("./pages/solana/SolanaValidatorsPage"); // EVM pages import("./pages/evm/network"); import("./pages/evm/blocks"); diff --git a/src/components/navbar/NetworkBlockIndicator.tsx b/src/components/navbar/NetworkBlockIndicator.tsx index a149279a..2049c45f 100644 --- a/src/components/navbar/NetworkBlockIndicator.tsx +++ b/src/components/navbar/NetworkBlockIndicator.tsx @@ -57,6 +57,15 @@ export function NetworkBlockIndicator({ className }: NetworkBlockIndicatorProps) setGasPrice(null); // Bitcoin doesn't have gas setIsLoading(false); } + } else if (network.type === "solana" && dataService?.isSolana()) { + // Fetch Solana current slot + const adapter = dataService.getSolanaAdapter(); + const slot = await adapter.getLatestSlot(); + if (isMounted) { + setBlockNumber(slot); + setGasPrice(null); // Solana doesn't have gas in the EVM sense + setIsLoading(false); + } } else if (network.type === "evm") { // Fetch EVM block number const urls = getRPCUrls(networkRpcKey, rpcUrls); diff --git a/src/components/pages/solana/SolanaAccountDisplay.tsx b/src/components/pages/solana/SolanaAccountDisplay.tsx new file mode 100644 index 00000000..f8c44d18 --- /dev/null +++ b/src/components/pages/solana/SolanaAccountDisplay.tsx @@ -0,0 +1,190 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import type { SolanaAccount, SolanaSignatureInfo } from "../../../types"; +import { formatSlotNumber, formatSol, shortenSolanaAddress } from "../../../utils/solanaUtils"; +import CopyButton from "../../common/CopyButton"; + +interface SolanaAccountDisplayProps { + account: SolanaAccount; + signatures: SolanaSignatureInfo[]; + networkId: string; +} + +const SolanaAccountDisplay: React.FC = React.memo( + ({ account, signatures, networkId }) => { + const { t } = useTranslation("solana"); + + const accountTypeLabel = account.executable ? t("account.program") : t("account.wallet"); + + return ( +
+
+
+ {t("account.title")} + {accountTypeLabel} +
+ + {/* Address — full width on top */} +
+
+ {t("account.address")}: + + {account.address} + + +
+
+ + {/* Two-column layout: account details | token holdings */} +
+ {/* Left column — account details */} +
+
+ {t("account.balance")}: + {formatSol(account.lamports)} +
+ +
+ {t("account.owner")}: + + + {shortenSolanaAddress(account.owner, 10, 10)} + + +
+ +
+ {t("account.executable")}: + + {account.executable ? t("account.yes") : t("account.no")} + +
+ +
+ {t("account.dataSize")}: + {account.space.toLocaleString()} bytes +
+ +
+ {t("account.rentEpoch")}: + {account.rentEpoch} +
+
+ + {/* Right column — token holdings */} +
+
+

+ {t("account.tokenHoldings")} + {account.tokenAccounts && account.tokenAccounts.length > 0 + ? ` (${account.tokenAccounts.length})` + : ""} +

+ {account.tokenAccounts && account.tokenAccounts.length > 0 ? ( +
+ + + + + + + + + {account.tokenAccounts.map((holding) => ( + + + + + ))} + +
{t("token.mint")}{t("token.amount")}
+ + {shortenSolanaAddress(holding.mint, 8, 8)} + + {holding.amount.uiAmountString}
+
+ ) : ( +
+

{t("account.noTokens")}

+
+ )} +
+
+
+ + {/* Recent Transactions */} +
+

+ {t("account.recentTransactions")}{" "} + {signatures.length > 0 ? `(${signatures.length})` : ""} +

+ {signatures.length > 0 ? ( +
+ + + + + + + + + + {signatures.map((sig) => ( + + + + + + ))} + +
{t("transaction.signature")}{t("transaction.status")}{t("transaction.slot")}
+ + {shortenSolanaAddress(sig.signature, 12, 12)} + + + {sig.err ? ( + + ✗ {t("transactions.failed")} + + ) : ( + + ✓ {t("transactions.success")} + + )} + + + {formatSlotNumber(sig.slot)} + +
+
+ ) : ( +
+

{t("account.noTransactions")}

+
+ )} +
+
+
+ ); + }, +); + +SolanaAccountDisplay.displayName = "SolanaAccountDisplay"; + +export default SolanaAccountDisplay; diff --git a/src/components/pages/solana/SolanaAccountPage.tsx b/src/components/pages/solana/SolanaAccountPage.tsx new file mode 100644 index 00000000..724b097d --- /dev/null +++ b/src/components/pages/solana/SolanaAccountPage.tsx @@ -0,0 +1,121 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation, useParams } from "react-router-dom"; +import { getNetworkBySlug } from "../../../config/networks"; +import { useDataService } from "../../../hooks/useDataService"; +import type { SolanaAccount, SolanaSignatureInfo } from "../../../types"; +import { shortenSolanaAddress } from "../../../utils/solanaUtils"; +import Breadcrumb from "../../common/Breadcrumb"; +import LoaderWithTimeout from "../../common/LoaderWithTimeout"; +import SolanaAccountDisplay from "./SolanaAccountDisplay"; + +export default function SolanaAccountPage() { + const { address } = useParams<{ address: string }>(); + const location = useLocation(); + const { t } = useTranslation("solana"); + + const networkSlug = location.pathname.split("/")[1] || "sol"; + const dataService = useDataService(networkSlug); + const networkConfig = getNetworkBySlug(networkSlug); + const networkLabel = networkConfig?.shortName || networkConfig?.name || networkSlug.toUpperCase(); + + const [account, setAccount] = useState(null); + const [signatures, setSignatures] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!dataService || !dataService.isSolana() || !address) { + setLoading(false); + return; + } + + let cancelled = false; + const fetchAccount = async () => { + setLoading(true); + setError(null); + try { + const adapter = dataService.getSolanaAdapter(); + const [accountResult, sigsResult] = await Promise.all([ + adapter.getAccount(address), + adapter.getSignaturesForAddress(address, { limit: 25 }).catch(() => []), + ]); + if (!cancelled) { + setAccount(accountResult.data); + setSignatures(sigsResult); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to fetch account"); + } + } finally { + if (!cancelled) setLoading(false); + } + }; + + fetchAccount(); + return () => { + cancelled = true; + }; + }, [dataService, address]); + + const breadcrumbItems = [ + { label: "Home", to: "/" }, + { label: networkLabel, to: `/${networkSlug}` }, + { label: t("account.title") }, + { label: address ? shortenSolanaAddress(address, 6, 6) : "" }, + ]; + + if (loading) { + return ( +
+ +
+
+ {t("account.title")} + + {address ? shortenSolanaAddress(address, 8, 8) : ""} + +
+
+ window.location.reload()} + /> +
+
+
+ ); + } + + if (error) { + return ( +
+ +
+
+ {t("account.title")} +
+
+

Error: {error}

+
+
+
+ ); + } + + return ( +
+ + {account ? ( + + ) : ( +
+
+

Account not found

+
+
+ )} +
+ ); +} diff --git a/src/components/pages/solana/SolanaBlocksTable.tsx b/src/components/pages/solana/SolanaBlocksTable.tsx new file mode 100644 index 00000000..4bace0fd --- /dev/null +++ b/src/components/pages/solana/SolanaBlocksTable.tsx @@ -0,0 +1,78 @@ +import type React from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import type { SolanaBlock } from "../../../types"; +import { formatSlotNumber, shortenSolanaAddress } from "../../../utils/solanaUtils"; + +interface SolanaBlocksTableProps { + blocks: SolanaBlock[]; + loading: boolean; + networkId: string; +} + +function formatTimeAgo(blockTime: number | null): string { + if (blockTime === null) return "—"; + const seconds = Math.floor(Date.now() / 1000 - blockTime); + if (seconds < 60) return `${seconds}s ago`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + return `${Math.floor(seconds / 3600)}h ago`; +} + +const SolanaBlocksTable: React.FC = ({ blocks, loading, networkId }) => { + const { t } = useTranslation("solana"); + + return ( +
+
+ +

{t("blocks.title")}

+ + + + {t("blocks.viewAll")} → + +
+ + {loading && blocks.length === 0 ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton placeholder +
+
+ +
+
+ ))} +
+ ) : blocks.length === 0 ? ( +
{t("blocks.noBlocks")}
+ ) : ( +
+ {blocks.map((block) => ( +
+
+ + #{formatSlotNumber(block.slot)} + + {formatTimeAgo(block.blockTime)} +
+
+ {block.transactionCount} txns +
+
+ + {shortenSolanaAddress(block.blockhash, 6, 6)} + +
+
+ ))} +
+ )} +
+ ); +}; + +export default SolanaBlocksTable; diff --git a/src/components/pages/solana/SolanaDashboardStats.tsx b/src/components/pages/solana/SolanaDashboardStats.tsx new file mode 100644 index 00000000..9aa1b6b0 --- /dev/null +++ b/src/components/pages/solana/SolanaDashboardStats.tsx @@ -0,0 +1,63 @@ +import type React from "react"; +import { useTranslation } from "react-i18next"; +import type { SolanaNetworkStats } from "../../../types"; +import { calculateEpochProgress, formatSlotNumber } from "../../../utils/solanaUtils"; + +interface SolanaDashboardStatsProps { + stats: SolanaNetworkStats | null; + solPrice: number | null; + loading: boolean; +} + +const SolanaDashboardStats: React.FC = ({ + stats, + solPrice, + loading, +}) => { + const { t } = useTranslation("solana"); + + const skeleton = (width: string) => ( + + ); + + const epochProgress = stats + ? calculateEpochProgress(stats.epochSlotIndex, stats.epochSlotsTotal) + : 0; + + return ( +
+
+
{t("dashboard.solPrice")}
+
+ {loading && solPrice === null + ? skeleton("80px") + : solPrice + ? `$${solPrice.toFixed(2)}` + : "—"} +
+
+ +
+
{t("dashboard.currentSlot")}
+
+ {loading && !stats ? skeleton("100px") : formatSlotNumber(stats?.currentSlot ?? 0)} +
+
+ {stats ? `${t("dashboard.blockHeight")}: ${formatSlotNumber(stats.blockHeight)}` : ""} +
+
+ +
+
{t("dashboard.epoch")}
+
+ {loading && !stats ? skeleton("60px") : (stats?.epoch ?? "—")} +
+
+ {stats ? `${epochProgress.toFixed(1)}% complete` : ""} +
+
+
+ ); +}; + +export default SolanaDashboardStats; diff --git a/src/components/pages/solana/SolanaSlotDisplay.tsx b/src/components/pages/solana/SolanaSlotDisplay.tsx new file mode 100644 index 00000000..005d656c --- /dev/null +++ b/src/components/pages/solana/SolanaSlotDisplay.tsx @@ -0,0 +1,183 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import type { SolanaBlock } from "../../../types"; +import { + formatBlockTime, + formatSlotNumber, + formatSol, + shortenSolanaAddress, +} from "../../../utils/solanaUtils"; +import CopyButton from "../../common/CopyButton"; + +interface SolanaSlotDisplayProps { + block: SolanaBlock; + networkId: string; +} + +const SolanaSlotDisplay: React.FC = React.memo(({ block, networkId }) => { + const [showTransactions, setShowTransactions] = useState(false); + const { t } = useTranslation("solana"); + + const totalRewards = block.rewards.reduce((sum, r) => sum + r.lamports, 0); + + return ( +
+
+
+
+ {block.slot > 0 && ( + + ← + + )} +
+ {t("block.title")} + #{formatSlotNumber(block.slot)} +
+ + → + + + + {formatBlockTime(block.blockTime)} + +
+
+ +
+ {/* Block Hash */} +
+ {t("block.blockHash")}: + + {block.blockhash} + + +
+ + {/* Previous Blockhash */} +
+ {t("block.previousBlockhash")}: + {block.previousBlockhash} +
+ + {/* Parent Slot */} +
+ {t("block.parentSlot")}: + + + #{formatSlotNumber(block.parentSlot)} + + +
+ + {/* Block Height */} + {block.blockHeight !== null && ( +
+ {t("block.blockHeight")}: + {formatSlotNumber(block.blockHeight)} +
+ )} + + {/* Transaction count */} +
+ {t("block.transactionCount")}: + + {block.transactionCount.toLocaleString()}{" "} + transactions + +
+ + {/* Total rewards */} + {block.rewards.length > 0 && ( +
+ {t("block.rewards")}: + {formatSol(totalRewards)} +
+ )} +
+ + {/* Rewards breakdown */} + {block.rewards.length > 0 && ( +
+

{t("block.rewards")}

+
+ + + + + + + + + + {block.rewards.map((reward) => ( + + + + + + ))} + +
Pubkey{t("block.rewardType")}{t("block.amount")}
+ + {shortenSolanaAddress(reward.pubkey, 8, 8)} + + {reward.rewardType ?? "—"}{formatSol(reward.lamports)}
+
+
+ )} + + {/* Transactions */} + {block.signatures && block.signatures.length > 0 && ( +
+ + + {showTransactions && ( +
+
+ {block.signatures.map((sig, index) => ( +
+ {index} + + + {sig} + + +
+ ))} +
+
+ )} +
+ )} +
+
+ ); +}); + +SolanaSlotDisplay.displayName = "SolanaSlotDisplay"; + +export default SolanaSlotDisplay; diff --git a/src/components/pages/solana/SolanaSlotPage.tsx b/src/components/pages/solana/SolanaSlotPage.tsx new file mode 100644 index 00000000..06951ac6 --- /dev/null +++ b/src/components/pages/solana/SolanaSlotPage.tsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation, useParams } from "react-router-dom"; +import { getNetworkBySlug } from "../../../config/networks"; +import { useDataService } from "../../../hooks/useDataService"; +import type { DataWithMetadata, SolanaBlock } from "../../../types"; +import Breadcrumb from "../../common/Breadcrumb"; +import LoaderWithTimeout from "../../common/LoaderWithTimeout"; +import SolanaSlotDisplay from "./SolanaSlotDisplay"; + +export default function SolanaSlotPage() { + const { filter } = useParams<{ filter: string }>(); + const location = useLocation(); + const { t } = useTranslation("solana"); + + const networkSlug = location.pathname.split("/")[1] || "sol"; + const dataService = useDataService(networkSlug); + const networkConfig = getNetworkBySlug(networkSlug); + const networkLabel = networkConfig?.shortName || networkConfig?.name || networkSlug.toUpperCase(); + + const [blockResult, setBlockResult] = useState | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!dataService || !dataService.isSolana() || !filter) { + setLoading(false); + return; + } + + let cancelled = false; + const fetchBlock = async () => { + setLoading(true); + setError(null); + try { + const adapter = dataService.getSolanaAdapter(); + const slot = Number(filter); + if (Number.isNaN(slot)) { + throw new Error(`Invalid slot: ${filter}`); + } + const result = await adapter.getBlock(slot); + if (!cancelled) setBlockResult(result); + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to fetch block"); + } + } finally { + if (!cancelled) setLoading(false); + } + }; + + fetchBlock(); + return () => { + cancelled = true; + }; + }, [dataService, filter]); + + const breadcrumbItems = [ + { label: "Home", to: "/" }, + { label: networkLabel, to: `/${networkSlug}` }, + { label: t("blocks.blocksTitle"), to: `/${networkSlug}/slots` }, + { label: `${t("block.title")} #${filter}` }, + ]; + + if (loading) { + return ( +
+ +
+
+ {t("block.title")} + #{filter} +
+
+ window.location.reload()} + /> +
+
+
+ ); + } + + if (error) { + return ( +
+ +
+
+ {t("block.title")} +
+
+

Error: {error}

+
+
+
+ ); + } + + return ( +
+ + {blockResult?.data ? ( + + ) : ( +
+
+

{t("blocks.noBlocks")}

+
+
+ )} +
+ ); +} diff --git a/src/components/pages/solana/SolanaSlotsPage.tsx b/src/components/pages/solana/SolanaSlotsPage.tsx new file mode 100644 index 00000000..e338a9ff --- /dev/null +++ b/src/components/pages/solana/SolanaSlotsPage.tsx @@ -0,0 +1,180 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useLocation } from "react-router-dom"; +import { getNetworkBySlug } from "../../../config/networks"; +import { useDataService } from "../../../hooks/useDataService"; +import type { SolanaBlock } from "../../../types"; +import { formatSlotNumber, shortenSolanaAddress } from "../../../utils/solanaUtils"; +import { logger } from "../../../utils/logger"; +import Breadcrumb from "../../common/Breadcrumb"; + +const BLOCKS_PER_PAGE = 25; + +function formatBlockTimeAgo(blockTime: number | null): string { + if (blockTime === null) return "—"; + const seconds = Math.floor(Date.now() / 1000 - blockTime); + if (seconds < 60) return `${seconds}s ago`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + return `${Math.floor(seconds / 86400)}d ago`; +} + +export default function SolanaSlotsPage() { + const location = useLocation(); + const { t } = useTranslation("solana"); + + const networkSlug = location.pathname.split("/")[1] || "sol"; + const dataService = useDataService(networkSlug); + const networkConfig = getNetworkBySlug(networkSlug); + const networkLabel = networkConfig?.shortName || networkConfig?.name || networkSlug.toUpperCase(); + + const [blocks, setBlocks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!dataService || !dataService.isSolana()) { + setLoading(false); + return; + } + + let cancelled = false; + const fetchBlocks = async () => { + setLoading(true); + setError(null); + try { + const adapter = dataService.getSolanaAdapter(); + const result = await adapter.getLatestBlocks(BLOCKS_PER_PAGE); + if (!cancelled) setBlocks(result); + } catch (err) { + logger.error("Error fetching Solana blocks:", err); + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to fetch blocks"); + } + } finally { + if (!cancelled) setLoading(false); + } + }; + + fetchBlocks(); + return () => { + cancelled = true; + }; + }, [dataService]); + + const breadcrumbItems = [ + { label: "Home", to: "/" }, + { label: networkLabel, to: `/${networkSlug}` }, + { label: t("blocks.blocksTitle") }, + ]; + + if (loading) { + return ( +
+ +
+
+ {t("blocks.title")} +
+
+ + + + + + + + + + + {Array.from({ length: BLOCKS_PER_PAGE }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton placeholder + + + + + + + ))} + +
{t("blocks.slot")}{t("blocks.blockHash")}{t("blocks.time")}{t("blocks.txCount")}
+ + + + + + + +
+
+
+
+ ); + } + + if (error) { + return ( +
+ +
+
+ {t("blocks.title")} +
+
+

Error: {error}

+
+
+
+ ); + } + + return ( +
+ +
+
+
+ {t("blocks.title")} + + Showing {blocks.length} most recent blocks +
+
+ +
+ + + + + + + + + + + {blocks.map((block) => ( + + + + + + + ))} + +
{t("blocks.slot")}{t("blocks.blockHash")}{t("blocks.time")}{t("blocks.txCount")}
+ + {formatSlotNumber(block.slot)} + + + + {shortenSolanaAddress(block.blockhash, 8, 8)} + + {formatBlockTimeAgo(block.blockTime)}{block.transactionCount.toLocaleString()}
+
+
+
+ ); +} diff --git a/src/components/pages/solana/SolanaTokenPage.tsx b/src/components/pages/solana/SolanaTokenPage.tsx new file mode 100644 index 00000000..9dbf8d4e --- /dev/null +++ b/src/components/pages/solana/SolanaTokenPage.tsx @@ -0,0 +1,190 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useLocation, useParams } from "react-router-dom"; +import { getNetworkBySlug } from "../../../config/networks"; +import { useDataService } from "../../../hooks/useDataService"; +import type { SolanaTokenAmount, SolanaTokenLargestAccount } from "../../../types"; +import { shortenSolanaAddress } from "../../../utils/solanaUtils"; +import Breadcrumb from "../../common/Breadcrumb"; +import CopyButton from "../../common/CopyButton"; +import LoaderWithTimeout from "../../common/LoaderWithTimeout"; + +export default function SolanaTokenPage() { + const { mint } = useParams<{ mint: string }>(); + const location = useLocation(); + const { t } = useTranslation("solana"); + + const networkSlug = location.pathname.split("/")[1] || "sol"; + const dataService = useDataService(networkSlug); + const networkConfig = getNetworkBySlug(networkSlug); + const networkLabel = networkConfig?.shortName || networkConfig?.name || networkSlug.toUpperCase(); + + const [supply, setSupply] = useState(null); + const [holders, setHolders] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!dataService || !dataService.isSolana() || !mint) { + setLoading(false); + return; + } + + let cancelled = false; + const fetchToken = async () => { + setLoading(true); + setError(null); + try { + const adapter = dataService.getSolanaAdapter(); + const [supplyResult, holdersResult] = await Promise.all([ + adapter.getTokenSupply(mint), + adapter.getTokenLargestAccounts(mint), + ]); + if (!cancelled) { + setSupply(supplyResult); + setHolders(holdersResult); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to fetch token"); + } + } finally { + if (!cancelled) setLoading(false); + } + }; + + fetchToken(); + return () => { + cancelled = true; + }; + }, [dataService, mint]); + + const breadcrumbItems = [ + { label: "Home", to: "/" }, + { label: networkLabel, to: `/${networkSlug}` }, + { label: t("token.title") }, + { label: mint ? shortenSolanaAddress(mint, 6, 6) : "" }, + ]; + + if (loading) { + return ( +
+ +
+
+ {t("token.title")} +
+
+ window.location.reload()} + /> +
+
+
+ ); + } + + if (error) { + return ( +
+ +
+
+ {t("token.title")} +
+
+

Error: {error}

+
+
+
+ ); + } + + const totalSupplyNum = supply ? Number(supply.amount) : 0; + + return ( +
+ +
+
+
+ {t("token.title")} + SPL +
+ +
+
+ {t("token.mint")}: + + {mint} + {mint && } + +
+ {supply && ( + <> +
+ {t("token.totalSupply")}: + {supply.uiAmountString} +
+
+ {t("token.decimals")}: + {supply.decimals} +
+ + )} +
+ +
+

+ {t("token.topHolders")} {holders.length > 0 ? `(${holders.length})` : ""} +

+ {holders.length > 0 ? ( +
+ + + + + + + + + + + {holders.map((holder, idx) => { + const pct = + totalSupplyNum > 0 ? (Number(holder.amount) / totalSupplyNum) * 100 : 0; + return ( + + + + + + + ); + })} + +
{t("token.holderRank")}{t("token.holderAddress")}{t("token.amount")}{t("token.percentage")}
#{idx + 1} + + {shortenSolanaAddress(holder.address, 8, 8)} + + {holder.uiAmountString}{pct.toFixed(2)}%
+
+ ) : ( +
+

{t("token.noHolders")}

+
+ )} +
+
+
+
+ ); +} diff --git a/src/components/pages/solana/SolanaTransactionDisplay.tsx b/src/components/pages/solana/SolanaTransactionDisplay.tsx new file mode 100644 index 00000000..de6127a5 --- /dev/null +++ b/src/components/pages/solana/SolanaTransactionDisplay.tsx @@ -0,0 +1,245 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import type { SolanaTransaction } from "../../../types"; +import { + formatBlockTime, + formatSlotNumber, + formatSol, + shortenSolanaAddress, +} from "../../../utils/solanaUtils"; +import CopyButton from "../../common/CopyButton"; + +interface SolanaTransactionDisplayProps { + tx: SolanaTransaction; + networkId: string; +} + +const SolanaTransactionDisplay: React.FC = React.memo( + ({ tx, networkId }) => { + const { t } = useTranslation("solana"); + const [showLogs, setShowLogs] = useState(false); + const [showInner, setShowInner] = useState(false); + + return ( +
+
+
+
+
+ {t("transaction.title")} +
+ + + {formatBlockTime(tx.blockTime)} + +
+ + {tx.status === "success" ? t("transactions.success") : t("transactions.failed")} + +
+ +
+ {/* Signature */} +
+ {t("transaction.signature")}: + + {tx.signature} + + +
+ + {/* Slot */} +
+ {t("transaction.slot")}: + + + #{formatSlotNumber(tx.slot)} + + +
+ + {/* Fee */} +
+ {t("transaction.fee")}: + {formatSol(tx.fee)} +
+ + {/* Compute units */} + {tx.computeUnitsConsumed !== undefined && ( +
+ {t("transaction.computeUnits")}: + {tx.computeUnitsConsumed.toLocaleString()} +
+ )} + + {/* Version */} + {tx.version !== undefined && ( +
+ {t("transaction.version")}: + {String(tx.version)} +
+ )} +
+ + {/* Account Keys */} + {tx.accountKeys.length > 0 && ( +
+

+ {t("transaction.accountKeys")} ({tx.accountKeys.length}) +

+
+ + + + + + + + + + + {tx.accountKeys.map((key, idx) => ( + + + + + + + ))} + +
#Pubkey{t("transaction.signer")}{t("transaction.writable")}
{idx} + + {shortenSolanaAddress(key.pubkey, 8, 8)} + + {key.signer ? "✓" : "—"}{key.writable ? "✓" : "—"}
+
+
+ )} + + {/* Instructions */} + {tx.instructions.length > 0 && ( +
+

+ {t("transaction.instructions")} ({tx.instructions.length}) +

+
+ {tx.instructions.map((ix, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: instructions are ordered +
+
+ {t("transaction.programId")}: + + + {shortenSolanaAddress(ix.programId, 10, 10)} + + +
+ {ix.accounts.length > 0 && ( +
+ {t("transaction.accounts")}: + + {ix.accounts.length} account{ix.accounts.length === 1 ? "" : "s"} + +
+ )} + {ix.data && ( +
+ {t("transaction.data")}: + + {ix.data.length > 80 ? `${ix.data.slice(0, 80)}…` : ix.data} + +
+ )} +
+ ))} +
+
+ )} + + {/* Inner Instructions */} + {tx.innerInstructions.length > 0 && ( +
+ + {showInner && ( +
+ {tx.innerInstructions.map((group) => ( +
+
+ Index: + {group.index} +
+ {group.instructions.map((ix, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: instructions are ordered +
+ {t("transaction.programId")}: + + {shortenSolanaAddress(ix.programId, 8, 8)} + +
+ ))} +
+ ))} +
+ )} +
+ )} + + {/* Logs */} + {tx.logMessages.length > 0 && ( +
+ + {showLogs && ( +
+ {tx.logMessages.map((msg, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: log messages are ordered +
+ + {i} + + {msg} +
+ ))} +
+ )} +
+ )} +
+
+ ); + }, +); + +SolanaTransactionDisplay.displayName = "SolanaTransactionDisplay"; + +export default SolanaTransactionDisplay; diff --git a/src/components/pages/solana/SolanaTransactionPage.tsx b/src/components/pages/solana/SolanaTransactionPage.tsx new file mode 100644 index 00000000..9213f1fc --- /dev/null +++ b/src/components/pages/solana/SolanaTransactionPage.tsx @@ -0,0 +1,114 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation, useParams } from "react-router-dom"; +import { getNetworkBySlug } from "../../../config/networks"; +import { useDataService } from "../../../hooks/useDataService"; +import type { DataWithMetadata, SolanaTransaction } from "../../../types"; +import { shortenSolanaAddress } from "../../../utils/solanaUtils"; +import Breadcrumb from "../../common/Breadcrumb"; +import LoaderWithTimeout from "../../common/LoaderWithTimeout"; +import SolanaTransactionDisplay from "./SolanaTransactionDisplay"; + +export default function SolanaTransactionPage() { + const { filter: signature } = useParams<{ filter: string }>(); + const location = useLocation(); + const { t } = useTranslation("solana"); + + const networkSlug = location.pathname.split("/")[1] || "sol"; + const dataService = useDataService(networkSlug); + const networkConfig = getNetworkBySlug(networkSlug); + const networkLabel = networkConfig?.shortName || networkConfig?.name || networkSlug.toUpperCase(); + + const [txResult, setTxResult] = useState | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!dataService || !dataService.isSolana() || !signature) { + setLoading(false); + return; + } + + let cancelled = false; + const fetchTx = async () => { + setLoading(true); + setError(null); + try { + const adapter = dataService.getSolanaAdapter(); + const result = await adapter.getTransaction(signature); + if (!cancelled) setTxResult(result); + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to fetch transaction"); + } + } finally { + if (!cancelled) setLoading(false); + } + }; + + fetchTx(); + return () => { + cancelled = true; + }; + }, [dataService, signature]); + + const breadcrumbItems = [ + { label: "Home", to: "/" }, + { label: networkLabel, to: `/${networkSlug}` }, + { label: t("transactions.txsTitle"), to: `/${networkSlug}/txs` }, + { label: signature ? shortenSolanaAddress(signature, 8, 8) : t("transaction.title") }, + ]; + + if (loading) { + return ( +
+ +
+
+ {t("transaction.title")} + + {signature ? shortenSolanaAddress(signature, 10, 10) : ""} + +
+
+ window.location.reload()} + /> +
+
+
+ ); + } + + if (error) { + return ( +
+ +
+
+ {t("transaction.title")} +
+
+

Error: {error}

+
+
+
+ ); + } + + return ( +
+ + {txResult?.data ? ( + + ) : ( +
+
+

{t("transactions.noTransactions")}

+
+
+ )} +
+ ); +} diff --git a/src/components/pages/solana/SolanaTransactionsPage.tsx b/src/components/pages/solana/SolanaTransactionsPage.tsx new file mode 100644 index 00000000..d08eae1a --- /dev/null +++ b/src/components/pages/solana/SolanaTransactionsPage.tsx @@ -0,0 +1,198 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useLocation } from "react-router-dom"; +import { getNetworkBySlug } from "../../../config/networks"; +import { useDataService } from "../../../hooks/useDataService"; +import type { SolanaTransaction } from "../../../types"; +import { logger } from "../../../utils/logger"; +import { formatSlotNumber, formatSol, shortenSolanaAddress } from "../../../utils/solanaUtils"; +import Breadcrumb from "../../common/Breadcrumb"; + +const TXS_PER_PAGE = 30; +const SKELETON_ROWS = 15; + +export default function SolanaTransactionsPage() { + const location = useLocation(); + const { t } = useTranslation("solana"); + + const networkSlug = location.pathname.split("/")[1] || "sol"; + const dataService = useDataService(networkSlug); + const networkConfig = getNetworkBySlug(networkSlug); + const networkLabel = networkConfig?.shortName || networkConfig?.name || networkSlug.toUpperCase(); + + const [transactions, setTransactions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!dataService || !dataService.isSolana()) { + setLoading(false); + return; + } + + let cancelled = false; + const fetchTxs = async () => { + setLoading(true); + setError(null); + try { + const adapter = dataService.getSolanaAdapter(); + const blocks = await adapter.getLatestBlocks(3); + const sigs: string[] = []; + for (const b of blocks) { + if (b.signatures) sigs.push(...b.signatures.slice(0, 15)); + if (sigs.length >= TXS_PER_PAGE) break; + } + const txResults = await Promise.all( + sigs.slice(0, TXS_PER_PAGE).map((s) => + adapter + .getTransaction(s) + .then((r) => r.data) + .catch(() => null), + ), + ); + const txs = txResults.filter((tx): tx is SolanaTransaction => tx !== null); + if (!cancelled) setTransactions(txs); + } catch (err) { + logger.error("Error fetching Solana transactions:", err); + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to fetch transactions"); + } + } finally { + if (!cancelled) setLoading(false); + } + }; + + fetchTxs(); + return () => { + cancelled = true; + }; + }, [dataService]); + + const breadcrumbItems = [ + { label: "Home", to: "/" }, + { label: networkLabel, to: `/${networkSlug}` }, + { label: t("transactions.txsTitle") }, + ]; + + if (loading) { + return ( +
+ +
+
+ {t("transactions.title")} +
+
+ + + + + + + + + + + {Array.from({ length: SKELETON_ROWS }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton placeholder + + + + + + + ))} + +
{t("transactions.signature")}{t("transactions.status")}{t("transactions.slot")}{t("transactions.fee")}
+ + + + + + + +
+
+
+
+ ); + } + + if (error) { + return ( +
+ +
+
+ {t("transactions.title")} +
+
+

Error: {error}

+
+
+
+ ); + } + + return ( +
+ +
+
+
+ {t("transactions.title")} + + + Showing {transactions.length} most recent transactions + +
+
+ +
+ + + + + + + + + + + {transactions.map((tx) => ( + + + + + + + ))} + +
{t("transactions.signature")}{t("transactions.status")}{t("transactions.slot")}{t("transactions.fee")}
+ + {shortenSolanaAddress(tx.signature, 12, 12)} + + + {tx.status === "success" ? ( + + ✓ {t("transactions.success")} + + ) : ( + + ✗ {t("transactions.failed")} + + )} + + + {formatSlotNumber(tx.slot)} + + {formatSol(tx.fee)}
+
+
+
+ ); +} diff --git a/src/components/pages/solana/SolanaTransactionsTable.tsx b/src/components/pages/solana/SolanaTransactionsTable.tsx new file mode 100644 index 00000000..73f13b65 --- /dev/null +++ b/src/components/pages/solana/SolanaTransactionsTable.tsx @@ -0,0 +1,73 @@ +import type React from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import type { SolanaTransaction } from "../../../types"; +import { formatSol, shortenSolanaAddress } from "../../../utils/solanaUtils"; + +interface SolanaTransactionsTableProps { + transactions: SolanaTransaction[]; + loading: boolean; + networkId: string; +} + +const SolanaTransactionsTable: React.FC = ({ + transactions, + loading, + networkId, +}) => { + const { t } = useTranslation("solana"); + + return ( +
+
+ +

{t("transactions.title")}

+ + + + {t("transactions.viewAll")} → + +
+ + {loading && transactions.length === 0 ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton placeholder +
+ +
+ ))} +
+ ) : transactions.length === 0 ? ( +
{t("transactions.noTransactions")}
+ ) : ( +
+ {transactions.map((tx) => ( +
+
+ + {shortenSolanaAddress(tx.signature, 8, 6)} + + + {tx.status === "success" ? t("transactions.success") : t("transactions.failed")} + +
+
+ {formatSol(tx.fee)} +
+
+ ))} +
+ )} +
+ ); +}; + +export default SolanaTransactionsTable; diff --git a/src/components/pages/solana/SolanaValidatorsPage.tsx b/src/components/pages/solana/SolanaValidatorsPage.tsx new file mode 100644 index 00000000..e0613e3a --- /dev/null +++ b/src/components/pages/solana/SolanaValidatorsPage.tsx @@ -0,0 +1,217 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useLocation } from "react-router-dom"; +import { getNetworkBySlug } from "../../../config/networks"; +import { useDataService } from "../../../hooks/useDataService"; +import type { SolanaEpochInfo, SolanaValidator } from "../../../types"; +import { + calculateEpochProgress, + formatStake, + shortenSolanaAddress, +} from "../../../utils/solanaUtils"; +import Breadcrumb from "../../common/Breadcrumb"; +import LoaderWithTimeout from "../../common/LoaderWithTimeout"; + +export default function SolanaValidatorsPage() { + const location = useLocation(); + const { t } = useTranslation("solana"); + + const networkSlug = location.pathname.split("/")[1] || "sol"; + const dataService = useDataService(networkSlug); + const networkConfig = getNetworkBySlug(networkSlug); + const networkLabel = networkConfig?.shortName || networkConfig?.name || networkSlug.toUpperCase(); + + const [current, setCurrent] = useState([]); + const [delinquent, setDelinquent] = useState([]); + const [epochInfo, setEpochInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!dataService || !dataService.isSolana()) { + setLoading(false); + return; + } + + let cancelled = false; + const fetchValidators = async () => { + setLoading(true); + setError(null); + try { + const adapter = dataService.getSolanaAdapter(); + const [voteAccounts, epoch] = await Promise.all([ + adapter.getVoteAccounts(), + adapter.getEpochInfo(), + ]); + if (!cancelled) { + const sortedCurrent = [...voteAccounts.current].sort( + (a, b) => b.activatedStake - a.activatedStake, + ); + setCurrent(sortedCurrent); + setDelinquent(voteAccounts.delinquent); + setEpochInfo(epoch); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to fetch validators"); + } + } finally { + if (!cancelled) setLoading(false); + } + }; + + fetchValidators(); + return () => { + cancelled = true; + }; + }, [dataService]); + + const totalStake = useMemo( + () => current.reduce((sum, v) => sum + v.activatedStake, 0), + [current], + ); + + const breadcrumbItems = [ + { label: "Home", to: "/" }, + { label: networkLabel, to: `/${networkSlug}` }, + { label: t("validators.title") }, + ]; + + if (loading) { + return ( +
+ +
+
+ {t("validators.title")} +
+
+ window.location.reload()} + /> +
+
+
+ ); + } + + if (error) { + return ( +
+ +
+
+ {t("validators.title")} +
+
+

Error: {error}

+
+
+
+ ); + } + + const epochProgress = epochInfo + ? calculateEpochProgress(epochInfo.slotIndex, epochInfo.slotsInEpoch) + : 0; + + const renderValidatorTable = (validators: SolanaValidator[]) => ( +
+ + + + + + + + + + + + + {validators.map((v, idx) => ( + + + + + + + + + ))} + +
#{t("validators.identity")}{t("validators.voteAccount")}{t("validators.stake")}{t("validators.commission")}{t("validators.lastVote")}
{idx + 1} + + {shortenSolanaAddress(v.nodePubkey, 6, 6)} + + + + {shortenSolanaAddress(v.votePubkey, 6, 6)} + + {formatStake(v.activatedStake)}{v.commission}%{v.lastVote.toLocaleString()}
+
+ ); + + return ( +
+ +
+
+ {t("validators.title")} +
+ + {epochInfo && ( +
+
+ {t("validators.currentEpoch")}: + {epochInfo.epoch} +
+
+ {t("validators.epochProgress")}: + {epochProgress.toFixed(2)}% +
+
+ {t("validators.totalStake")}: + {formatStake(totalStake)} +
+
+ {t("validators.validatorCount")}: + {current.length.toLocaleString()} +
+
+ )} + +
+

+ {t("validators.currentValidators")} ({current.length}) +

+ {current.length > 0 ? ( + renderValidatorTable(current) + ) : ( +
+

{t("validators.noValidators")}

+
+ )} +
+ + {delinquent.length > 0 && ( +
+

+ {t("validators.delinquentValidators")} ({delinquent.length}) +

+ {renderValidatorTable(delinquent)} +
+ )} +
+
+ ); +} diff --git a/src/components/pages/solana/index.tsx b/src/components/pages/solana/index.tsx new file mode 100644 index 00000000..299a18c4 --- /dev/null +++ b/src/components/pages/solana/index.tsx @@ -0,0 +1,89 @@ +import { useLocation } from "react-router-dom"; +import { useSolanaDashboard } from "../../../hooks/useSolanaDashboard"; +import { resolveNetwork } from "../../../utils/networkResolver"; +import { getAllNetworks } from "../../../config/networks"; +import type { NetworkConfig } from "../../../types"; +import SearchBox from "../../common/SearchBox"; +import SolanaDashboardStats from "./SolanaDashboardStats"; +import SolanaBlocksTable from "./SolanaBlocksTable"; +import SolanaTransactionsTable from "./SolanaTransactionsTable"; + +// Default Solana network config for fallback +const DEFAULT_SOLANA_NETWORK: NetworkConfig = { + type: "solana", + networkId: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + slug: "sol", + name: "Solana", + shortName: "Solana", + currency: "SOL", + color: "#9945FF", +}; + +export default function SolanaNetwork() { + const location = useLocation(); + + // Extract network slug from path + const pathSlug = location.pathname.split("/")[1] || "sol"; + const network = resolveNetwork(pathSlug, getAllNetworks()) || DEFAULT_SOLANA_NETWORK; + const dashboard = useSolanaDashboard(network); + + const networkName = network.name.toUpperCase(); + const networkColor = network.color || "#9945FF"; + + return ( +
+
+

+ + {networkName} + +

+ {network.description &&

{network.description}

} + + + {dashboard.error &&

Error: {dashboard.error}

} + + + +
+ + +
+ + {network.links && network.links.length > 0 && ( +
+
+ {network.links.map((link) => ( + + {link.name} + + ))} +
+
+ )} +
+
+ ); +} diff --git a/src/config/networks.json b/src/config/networks.json index 248a8c19..eb08c278 100644 --- a/src/config/networks.json +++ b/src/config/networks.json @@ -285,6 +285,78 @@ } ] }, + { + "type": "solana", + "networkId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "slug": "sol", + "name": "Solana", + "shortName": "Solana", + "description": "High-performance blockchain with fast transactions and low fees", + "currency": "SOL", + "color": "#9945FF", + "isTestnet": false, + "logo": "assets/networks/solana.svg", + "links": [ + { + "name": "Website", + "url": "https://solana.com", + "description": "Official Solana website" + }, + { + "name": "Docs", + "url": "https://solana.com/docs", + "description": "Developer documentation" + }, + { + "name": "GitHub", + "url": "https://github.com/solana-labs", + "description": "Solana Labs GitHub" + } + ] + }, + { + "type": "solana", + "networkId": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "slug": "sol-devnet", + "name": "Solana Devnet", + "shortName": "SOL Devnet", + "description": "Solana development network for testing", + "currency": "SOL", + "color": "#9945FF", + "isTestnet": true, + "logo": "assets/networks/solana.svg", + "links": [ + { + "name": "Faucet", + "url": "https://faucet.solana.com", + "description": "Get devnet SOL" + }, + { + "name": "Docs", + "url": "https://solana.com/docs", + "description": "Developer documentation" + } + ] + }, + { + "type": "solana", + "networkId": "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z", + "slug": "sol-testnet", + "name": "Solana Testnet", + "shortName": "SOL Testnet", + "description": "Solana testnet for validators and developers", + "currency": "SOL", + "color": "#9945FF", + "isTestnet": true, + "logo": "assets/networks/solana.svg", + "links": [ + { + "name": "Faucet", + "url": "https://faucet.solana.com", + "description": "Get testnet SOL" + } + ] + }, { "type": "bitcoin", "networkId": "bip122:000000000019d6689c085ae165831e93", diff --git a/src/hooks/useSolanaDashboard.ts b/src/hooks/useSolanaDashboard.ts new file mode 100644 index 00000000..4cb76f72 --- /dev/null +++ b/src/hooks/useSolanaDashboard.ts @@ -0,0 +1,118 @@ +/** + * Hook for fetching Solana network dashboard data with auto-refresh + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { NetworkConfig, SolanaBlock, SolanaNetworkStats, SolanaTransaction } from "../types"; +import { useDataService } from "./useDataService"; + +const REFRESH_INTERVAL = 10000; // 10 seconds (Solana slots ~400ms) +const BLOCKS_TO_FETCH = 10; + +export interface SolanaDashboardData { + stats: SolanaNetworkStats | null; + latestBlocks: SolanaBlock[]; + latestTransactions: SolanaTransaction[]; + solPrice: number | null; + loading: boolean; + loadingTransactions: boolean; + error: string | null; + lastUpdated: number | null; +} + +const initialState: SolanaDashboardData = { + stats: null, + latestBlocks: [], + latestTransactions: [], + solPrice: null, + loading: true, + loadingTransactions: true, + error: null, + lastUpdated: null, +}; + +export function useSolanaDashboard(network: NetworkConfig): SolanaDashboardData { + const dataService = useDataService(network); + const [data, setData] = useState(initialState); + const isFetchingRef = useRef(false); + + const fetchDashboardData = useCallback(async () => { + if (!dataService || !dataService.isSolana() || isFetchingRef.current) { + return; + } + + isFetchingRef.current = true; + + try { + const adapter = dataService.getSolanaAdapter(); + + // Fetch stats and latest blocks in parallel + const [statsResult, blocksResult] = await Promise.all([ + adapter.getNetworkStats(), + adapter.getLatestBlocks(BLOCKS_TO_FETCH), + ]); + + setData((prev) => ({ + ...prev, + stats: statsResult.data, + latestBlocks: blocksResult, + loading: false, + error: null, + lastUpdated: Date.now(), + })); + + // Latest transactions: extract signatures from the most recent block + const recentSignatures: string[] = []; + for (const block of blocksResult) { + if (block.signatures) { + recentSignatures.push(...block.signatures.slice(0, 10)); + if (recentSignatures.length >= 20) break; + } + } + + // Fetch full details for the first few signatures + const txPromises = recentSignatures.slice(0, 10).map((sig) => + adapter + .getTransaction(sig) + .then((r) => r.data) + .catch(() => null), + ); + const txResults = await Promise.all(txPromises); + const transactions = txResults.filter((tx): tx is SolanaTransaction => tx !== null); + + setData((prev) => ({ + ...prev, + latestTransactions: transactions, + loadingTransactions: false, + })); + } catch (err) { + setData((prev) => ({ + ...prev, + loading: false, + loadingTransactions: false, + error: err instanceof Error ? err.message : "Failed to fetch Solana dashboard data", + })); + } finally { + isFetchingRef.current = false; + } + }, [dataService]); + + // Initial fetch + useEffect(() => { + setData(initialState); + fetchDashboardData(); + }, [fetchDashboardData]); + + // Polling + useEffect(() => { + const intervalId = setInterval(() => { + fetchDashboardData(); + }, REFRESH_INTERVAL); + + return () => { + clearInterval(intervalId); + }; + }, [fetchDashboardData]); + + return data; +} diff --git a/src/i18n.ts b/src/i18n.ts index 021b03b8..c04990cd 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -9,6 +9,7 @@ import enDevtools from "./locales/en/devtools.json"; import enHome from "./locales/en/home.json"; import enNetwork from "./locales/en/network.json"; import enSettings from "./locales/en/settings.json"; +import enSolana from "./locales/en/solana.json"; import enTransaction from "./locales/en/transaction.json"; import enTokenDetails from "./locales/en/tokenDetails.json"; import enRpcs from "./locales/en/rpcs.json"; @@ -21,6 +22,7 @@ import esDevtools from "./locales/es/devtools.json"; import esHome from "./locales/es/home.json"; import esNetwork from "./locales/es/network.json"; import esSettings from "./locales/es/settings.json"; +import esSolana from "./locales/es/solana.json"; import esTransaction from "./locales/es/transaction.json"; import esTokenDetails from "./locales/es/tokenDetails.json"; import esRpcs from "./locales/es/rpcs.json"; @@ -33,6 +35,7 @@ import zhDevtools from "./locales/zh/devtools.json"; import zhHome from "./locales/zh/home.json"; import zhNetwork from "./locales/zh/network.json"; import zhSettings from "./locales/zh/settings.json"; +import zhSolana from "./locales/zh/solana.json"; import zhTransaction from "./locales/zh/transaction.json"; import zhTokenDetails from "./locales/zh/tokenDetails.json"; import zhTooltips from "./locales/zh/tooltips.json"; @@ -44,6 +47,7 @@ import jaDevtools from "./locales/ja/devtools.json"; import jaHome from "./locales/ja/home.json"; import jaNetwork from "./locales/ja/network.json"; import jaSettings from "./locales/ja/settings.json"; +import jaSolana from "./locales/ja/solana.json"; import jaTransaction from "./locales/ja/transaction.json"; import jaTokenDetails from "./locales/ja/tokenDetails.json"; import jaTooltips from "./locales/ja/tooltips.json"; @@ -55,6 +59,7 @@ import ptBRDevtools from "./locales/pt-BR/devtools.json"; import ptBRHome from "./locales/pt-BR/home.json"; import ptBRNetwork from "./locales/pt-BR/network.json"; import ptBRSettings from "./locales/pt-BR/settings.json"; +import ptBRSolana from "./locales/pt-BR/solana.json"; import ptBRTransaction from "./locales/pt-BR/transaction.json"; import ptBRTokenDetails from "./locales/pt-BR/tokenDetails.json"; import ptBRTooltips from "./locales/pt-BR/tooltips.json"; @@ -84,6 +89,7 @@ i18n tokenDetails: enTokenDetails, rpcs: enRpcs, tooltips: enTooltips, + solana: enSolana, }, es: { common: esCommon, @@ -97,6 +103,7 @@ i18n tokenDetails: esTokenDetails, rpcs: esRpcs, tooltips: esTooltips, + solana: esSolana, }, zh: { common: zhCommon, @@ -109,6 +116,7 @@ i18n network: zhNetwork, tokenDetails: zhTokenDetails, tooltips: zhTooltips, + solana: zhSolana, }, ja: { common: jaCommon, @@ -121,6 +129,7 @@ i18n network: jaNetwork, tokenDetails: jaTokenDetails, tooltips: jaTooltips, + solana: jaSolana, }, "pt-BR": { common: ptBRCommon, @@ -133,6 +142,7 @@ i18n network: ptBRNetwork, tokenDetails: ptBRTokenDetails, tooltips: ptBRTooltips, + solana: ptBRSolana, }, }, fallbackLng: "en", @@ -148,6 +158,7 @@ i18n "network", "rpcs", "tooltips", + "solana", ], interpolation: { escapeValue: false, diff --git a/src/i18next.d.ts b/src/i18next.d.ts index d1122d44..834ba065 100644 --- a/src/i18next.d.ts +++ b/src/i18next.d.ts @@ -5,6 +5,7 @@ import type devtools from "./locales/en/devtools.json"; import type home from "./locales/en/home.json"; import type network from "./locales/en/network.json"; import type settings from "./locales/en/settings.json"; +import type solana from "./locales/en/solana.json"; import type transaction from "./locales/en/transaction.json"; import type tokenDetails from "./locales/en/tokenDetails.json"; import type rpcs from "./locales/en/rpcs.json"; @@ -24,6 +25,7 @@ declare module "i18next" { tokenDetails: typeof tokenDetails; rpcs: typeof rpcs; tooltips: typeof tooltips; + solana: typeof solana; }; } } diff --git a/src/locales/en/solana.json b/src/locales/en/solana.json new file mode 100644 index 00000000..671072c2 --- /dev/null +++ b/src/locales/en/solana.json @@ -0,0 +1,132 @@ +{ + "dashboard": { + "currentSlot": "Current Slot", + "blockHeight": "Block Height", + "epoch": "Epoch", + "epochProgress": "Epoch Progress", + "transactions": "Transactions", + "version": "Version", + "solPrice": "SOL Price", + "tps": "TPS" + }, + "blocks": { + "title": "Latest Blocks", + "viewAll": "View all", + "slot": "Slot", + "time": "Time", + "txCount": "Tx Count", + "leader": "Leader", + "rewards": "Rewards", + "blockHash": "Block Hash", + "noBlocks": "No blocks found", + "blocksTitle": "Blocks", + "loadMore": "Load more" + }, + "block": { + "title": "Block", + "slot": "Slot", + "blockHash": "Block Hash", + "previousBlockhash": "Previous Blockhash", + "parentSlot": "Parent Slot", + "blockHeight": "Block Height", + "blockTime": "Block Time", + "transactionCount": "Transaction Count", + "rewards": "Rewards", + "transactions": "Transactions", + "rewardType": "Reward Type", + "amount": "Amount", + "noTransactions": "No transactions in this block" + }, + "transactions": { + "title": "Latest Transactions", + "viewAll": "View all", + "signature": "Signature", + "status": "Status", + "slot": "Slot", + "fee": "Fee", + "time": "Time", + "noTransactions": "No transactions found", + "txsTitle": "Transactions", + "success": "Success", + "failed": "Failed" + }, + "transaction": { + "title": "Transaction", + "signature": "Signature", + "status": "Status", + "slot": "Slot", + "blockTime": "Block Time", + "fee": "Fee", + "computeUnits": "Compute Units Consumed", + "version": "Version", + "signers": "Signers", + "accountKeys": "Account Keys", + "instructions": "Instructions", + "innerInstructions": "Inner Instructions", + "logs": "Program Logs", + "tokenChanges": "Token Balance Changes", + "balanceChanges": "Balance Changes", + "programId": "Program", + "accounts": "Accounts", + "data": "Data", + "writable": "Writable", + "signer": "Signer", + "readonly": "Read-only", + "preBalance": "Before", + "postBalance": "After", + "noLogs": "No log messages" + }, + "account": { + "title": "Account", + "address": "Address", + "balance": "Balance", + "owner": "Owner", + "executable": "Executable", + "rentEpoch": "Rent Epoch", + "dataSize": "Data Size", + "tokenHoldings": "Token Holdings", + "recentTransactions": "Recent Transactions", + "noTokens": "No SPL token holdings", + "noTransactions": "No recent transactions", + "yes": "Yes", + "no": "No", + "program": "Program", + "wallet": "Wallet" + }, + "token": { + "title": "SPL Token", + "mint": "Mint", + "totalSupply": "Total Supply", + "decimals": "Decimals", + "topHolders": "Top Holders", + "holderRank": "Rank", + "holderAddress": "Address", + "amount": "Amount", + "percentage": "Percentage", + "noHolders": "No holders found" + }, + "validators": { + "title": "Validators", + "currentValidators": "Current Validators", + "delinquentValidators": "Delinquent Validators", + "totalStake": "Total Active Stake", + "validatorCount": "Validators", + "identity": "Identity", + "voteAccount": "Vote Account", + "stake": "Stake", + "commission": "Commission", + "lastVote": "Last Vote", + "epochCredits": "Epoch Credits", + "currentEpoch": "Current Epoch", + "epochProgress": "Epoch Progress", + "noValidators": "No validators found" + }, + "common": { + "loading": "Loading...", + "error": "Error", + "lamports": "lamports", + "sol": "SOL", + "slot": "Slot", + "copy": "Copy" + } +} diff --git a/src/locales/es/solana.json b/src/locales/es/solana.json new file mode 100644 index 00000000..07f9339b --- /dev/null +++ b/src/locales/es/solana.json @@ -0,0 +1,132 @@ +{ + "dashboard": { + "currentSlot": "Slot Actual", + "blockHeight": "Altura del Bloque", + "epoch": "Época", + "epochProgress": "Progreso de Época", + "transactions": "Transacciones", + "version": "Versión", + "solPrice": "Precio SOL", + "tps": "TPS" + }, + "blocks": { + "title": "Últimos Bloques", + "viewAll": "Ver todos", + "slot": "Slot", + "time": "Hora", + "txCount": "Núm. Tx", + "leader": "Líder", + "rewards": "Recompensas", + "blockHash": "Hash del Bloque", + "noBlocks": "No se encontraron bloques", + "blocksTitle": "Bloques", + "loadMore": "Cargar más" + }, + "block": { + "title": "Bloque", + "slot": "Slot", + "blockHash": "Hash del Bloque", + "previousBlockhash": "Hash del Bloque Anterior", + "parentSlot": "Slot Padre", + "blockHeight": "Altura del Bloque", + "blockTime": "Hora del Bloque", + "transactionCount": "Núm. de Transacciones", + "rewards": "Recompensas", + "transactions": "Transacciones", + "rewardType": "Tipo de Recompensa", + "amount": "Cantidad", + "noTransactions": "Sin transacciones en este bloque" + }, + "transactions": { + "title": "Últimas Transacciones", + "viewAll": "Ver todas", + "signature": "Firma", + "status": "Estado", + "slot": "Slot", + "fee": "Comisión", + "time": "Hora", + "noTransactions": "No se encontraron transacciones", + "txsTitle": "Transacciones", + "success": "Éxito", + "failed": "Fallida" + }, + "transaction": { + "title": "Transacción", + "signature": "Firma", + "status": "Estado", + "slot": "Slot", + "blockTime": "Hora del Bloque", + "fee": "Comisión", + "computeUnits": "Unidades de Cómputo Consumidas", + "version": "Versión", + "signers": "Firmantes", + "accountKeys": "Claves de Cuenta", + "instructions": "Instrucciones", + "innerInstructions": "Instrucciones Internas", + "logs": "Logs del Programa", + "tokenChanges": "Cambios de Saldo de Tokens", + "balanceChanges": "Cambios de Saldo", + "programId": "Programa", + "accounts": "Cuentas", + "data": "Datos", + "writable": "Escribible", + "signer": "Firmante", + "readonly": "Solo lectura", + "preBalance": "Antes", + "postBalance": "Después", + "noLogs": "Sin mensajes de log" + }, + "account": { + "title": "Cuenta", + "address": "Dirección", + "balance": "Saldo", + "owner": "Propietario", + "executable": "Ejecutable", + "rentEpoch": "Época de Renta", + "dataSize": "Tamaño de Datos", + "tokenHoldings": "Tokens en Posesión", + "recentTransactions": "Transacciones Recientes", + "noTokens": "Sin tokens SPL", + "noTransactions": "Sin transacciones recientes", + "yes": "Sí", + "no": "No", + "program": "Programa", + "wallet": "Billetera" + }, + "token": { + "title": "Token SPL", + "mint": "Mint", + "totalSupply": "Suministro Total", + "decimals": "Decimales", + "topHolders": "Mayores Holders", + "holderRank": "Posición", + "holderAddress": "Dirección", + "amount": "Cantidad", + "percentage": "Porcentaje", + "noHolders": "No se encontraron holders" + }, + "validators": { + "title": "Validadores", + "currentValidators": "Validadores Activos", + "delinquentValidators": "Validadores Delincuentes", + "totalStake": "Stake Total Activo", + "validatorCount": "Validadores", + "identity": "Identidad", + "voteAccount": "Cuenta de Voto", + "stake": "Stake", + "commission": "Comisión", + "lastVote": "Último Voto", + "epochCredits": "Créditos de Época", + "currentEpoch": "Época Actual", + "epochProgress": "Progreso de Época", + "noValidators": "No se encontraron validadores" + }, + "common": { + "loading": "Cargando...", + "error": "Error", + "lamports": "lamports", + "sol": "SOL", + "slot": "Slot", + "copy": "Copiar" + } +} diff --git a/src/locales/ja/solana.json b/src/locales/ja/solana.json new file mode 100644 index 00000000..671072c2 --- /dev/null +++ b/src/locales/ja/solana.json @@ -0,0 +1,132 @@ +{ + "dashboard": { + "currentSlot": "Current Slot", + "blockHeight": "Block Height", + "epoch": "Epoch", + "epochProgress": "Epoch Progress", + "transactions": "Transactions", + "version": "Version", + "solPrice": "SOL Price", + "tps": "TPS" + }, + "blocks": { + "title": "Latest Blocks", + "viewAll": "View all", + "slot": "Slot", + "time": "Time", + "txCount": "Tx Count", + "leader": "Leader", + "rewards": "Rewards", + "blockHash": "Block Hash", + "noBlocks": "No blocks found", + "blocksTitle": "Blocks", + "loadMore": "Load more" + }, + "block": { + "title": "Block", + "slot": "Slot", + "blockHash": "Block Hash", + "previousBlockhash": "Previous Blockhash", + "parentSlot": "Parent Slot", + "blockHeight": "Block Height", + "blockTime": "Block Time", + "transactionCount": "Transaction Count", + "rewards": "Rewards", + "transactions": "Transactions", + "rewardType": "Reward Type", + "amount": "Amount", + "noTransactions": "No transactions in this block" + }, + "transactions": { + "title": "Latest Transactions", + "viewAll": "View all", + "signature": "Signature", + "status": "Status", + "slot": "Slot", + "fee": "Fee", + "time": "Time", + "noTransactions": "No transactions found", + "txsTitle": "Transactions", + "success": "Success", + "failed": "Failed" + }, + "transaction": { + "title": "Transaction", + "signature": "Signature", + "status": "Status", + "slot": "Slot", + "blockTime": "Block Time", + "fee": "Fee", + "computeUnits": "Compute Units Consumed", + "version": "Version", + "signers": "Signers", + "accountKeys": "Account Keys", + "instructions": "Instructions", + "innerInstructions": "Inner Instructions", + "logs": "Program Logs", + "tokenChanges": "Token Balance Changes", + "balanceChanges": "Balance Changes", + "programId": "Program", + "accounts": "Accounts", + "data": "Data", + "writable": "Writable", + "signer": "Signer", + "readonly": "Read-only", + "preBalance": "Before", + "postBalance": "After", + "noLogs": "No log messages" + }, + "account": { + "title": "Account", + "address": "Address", + "balance": "Balance", + "owner": "Owner", + "executable": "Executable", + "rentEpoch": "Rent Epoch", + "dataSize": "Data Size", + "tokenHoldings": "Token Holdings", + "recentTransactions": "Recent Transactions", + "noTokens": "No SPL token holdings", + "noTransactions": "No recent transactions", + "yes": "Yes", + "no": "No", + "program": "Program", + "wallet": "Wallet" + }, + "token": { + "title": "SPL Token", + "mint": "Mint", + "totalSupply": "Total Supply", + "decimals": "Decimals", + "topHolders": "Top Holders", + "holderRank": "Rank", + "holderAddress": "Address", + "amount": "Amount", + "percentage": "Percentage", + "noHolders": "No holders found" + }, + "validators": { + "title": "Validators", + "currentValidators": "Current Validators", + "delinquentValidators": "Delinquent Validators", + "totalStake": "Total Active Stake", + "validatorCount": "Validators", + "identity": "Identity", + "voteAccount": "Vote Account", + "stake": "Stake", + "commission": "Commission", + "lastVote": "Last Vote", + "epochCredits": "Epoch Credits", + "currentEpoch": "Current Epoch", + "epochProgress": "Epoch Progress", + "noValidators": "No validators found" + }, + "common": { + "loading": "Loading...", + "error": "Error", + "lamports": "lamports", + "sol": "SOL", + "slot": "Slot", + "copy": "Copy" + } +} diff --git a/src/locales/pt-BR/solana.json b/src/locales/pt-BR/solana.json new file mode 100644 index 00000000..671072c2 --- /dev/null +++ b/src/locales/pt-BR/solana.json @@ -0,0 +1,132 @@ +{ + "dashboard": { + "currentSlot": "Current Slot", + "blockHeight": "Block Height", + "epoch": "Epoch", + "epochProgress": "Epoch Progress", + "transactions": "Transactions", + "version": "Version", + "solPrice": "SOL Price", + "tps": "TPS" + }, + "blocks": { + "title": "Latest Blocks", + "viewAll": "View all", + "slot": "Slot", + "time": "Time", + "txCount": "Tx Count", + "leader": "Leader", + "rewards": "Rewards", + "blockHash": "Block Hash", + "noBlocks": "No blocks found", + "blocksTitle": "Blocks", + "loadMore": "Load more" + }, + "block": { + "title": "Block", + "slot": "Slot", + "blockHash": "Block Hash", + "previousBlockhash": "Previous Blockhash", + "parentSlot": "Parent Slot", + "blockHeight": "Block Height", + "blockTime": "Block Time", + "transactionCount": "Transaction Count", + "rewards": "Rewards", + "transactions": "Transactions", + "rewardType": "Reward Type", + "amount": "Amount", + "noTransactions": "No transactions in this block" + }, + "transactions": { + "title": "Latest Transactions", + "viewAll": "View all", + "signature": "Signature", + "status": "Status", + "slot": "Slot", + "fee": "Fee", + "time": "Time", + "noTransactions": "No transactions found", + "txsTitle": "Transactions", + "success": "Success", + "failed": "Failed" + }, + "transaction": { + "title": "Transaction", + "signature": "Signature", + "status": "Status", + "slot": "Slot", + "blockTime": "Block Time", + "fee": "Fee", + "computeUnits": "Compute Units Consumed", + "version": "Version", + "signers": "Signers", + "accountKeys": "Account Keys", + "instructions": "Instructions", + "innerInstructions": "Inner Instructions", + "logs": "Program Logs", + "tokenChanges": "Token Balance Changes", + "balanceChanges": "Balance Changes", + "programId": "Program", + "accounts": "Accounts", + "data": "Data", + "writable": "Writable", + "signer": "Signer", + "readonly": "Read-only", + "preBalance": "Before", + "postBalance": "After", + "noLogs": "No log messages" + }, + "account": { + "title": "Account", + "address": "Address", + "balance": "Balance", + "owner": "Owner", + "executable": "Executable", + "rentEpoch": "Rent Epoch", + "dataSize": "Data Size", + "tokenHoldings": "Token Holdings", + "recentTransactions": "Recent Transactions", + "noTokens": "No SPL token holdings", + "noTransactions": "No recent transactions", + "yes": "Yes", + "no": "No", + "program": "Program", + "wallet": "Wallet" + }, + "token": { + "title": "SPL Token", + "mint": "Mint", + "totalSupply": "Total Supply", + "decimals": "Decimals", + "topHolders": "Top Holders", + "holderRank": "Rank", + "holderAddress": "Address", + "amount": "Amount", + "percentage": "Percentage", + "noHolders": "No holders found" + }, + "validators": { + "title": "Validators", + "currentValidators": "Current Validators", + "delinquentValidators": "Delinquent Validators", + "totalStake": "Total Active Stake", + "validatorCount": "Validators", + "identity": "Identity", + "voteAccount": "Vote Account", + "stake": "Stake", + "commission": "Commission", + "lastVote": "Last Vote", + "epochCredits": "Epoch Credits", + "currentEpoch": "Current Epoch", + "epochProgress": "Epoch Progress", + "noValidators": "No validators found" + }, + "common": { + "loading": "Loading...", + "error": "Error", + "lamports": "lamports", + "sol": "SOL", + "slot": "Slot", + "copy": "Copy" + } +} diff --git a/src/locales/zh/solana.json b/src/locales/zh/solana.json new file mode 100644 index 00000000..671072c2 --- /dev/null +++ b/src/locales/zh/solana.json @@ -0,0 +1,132 @@ +{ + "dashboard": { + "currentSlot": "Current Slot", + "blockHeight": "Block Height", + "epoch": "Epoch", + "epochProgress": "Epoch Progress", + "transactions": "Transactions", + "version": "Version", + "solPrice": "SOL Price", + "tps": "TPS" + }, + "blocks": { + "title": "Latest Blocks", + "viewAll": "View all", + "slot": "Slot", + "time": "Time", + "txCount": "Tx Count", + "leader": "Leader", + "rewards": "Rewards", + "blockHash": "Block Hash", + "noBlocks": "No blocks found", + "blocksTitle": "Blocks", + "loadMore": "Load more" + }, + "block": { + "title": "Block", + "slot": "Slot", + "blockHash": "Block Hash", + "previousBlockhash": "Previous Blockhash", + "parentSlot": "Parent Slot", + "blockHeight": "Block Height", + "blockTime": "Block Time", + "transactionCount": "Transaction Count", + "rewards": "Rewards", + "transactions": "Transactions", + "rewardType": "Reward Type", + "amount": "Amount", + "noTransactions": "No transactions in this block" + }, + "transactions": { + "title": "Latest Transactions", + "viewAll": "View all", + "signature": "Signature", + "status": "Status", + "slot": "Slot", + "fee": "Fee", + "time": "Time", + "noTransactions": "No transactions found", + "txsTitle": "Transactions", + "success": "Success", + "failed": "Failed" + }, + "transaction": { + "title": "Transaction", + "signature": "Signature", + "status": "Status", + "slot": "Slot", + "blockTime": "Block Time", + "fee": "Fee", + "computeUnits": "Compute Units Consumed", + "version": "Version", + "signers": "Signers", + "accountKeys": "Account Keys", + "instructions": "Instructions", + "innerInstructions": "Inner Instructions", + "logs": "Program Logs", + "tokenChanges": "Token Balance Changes", + "balanceChanges": "Balance Changes", + "programId": "Program", + "accounts": "Accounts", + "data": "Data", + "writable": "Writable", + "signer": "Signer", + "readonly": "Read-only", + "preBalance": "Before", + "postBalance": "After", + "noLogs": "No log messages" + }, + "account": { + "title": "Account", + "address": "Address", + "balance": "Balance", + "owner": "Owner", + "executable": "Executable", + "rentEpoch": "Rent Epoch", + "dataSize": "Data Size", + "tokenHoldings": "Token Holdings", + "recentTransactions": "Recent Transactions", + "noTokens": "No SPL token holdings", + "noTransactions": "No recent transactions", + "yes": "Yes", + "no": "No", + "program": "Program", + "wallet": "Wallet" + }, + "token": { + "title": "SPL Token", + "mint": "Mint", + "totalSupply": "Total Supply", + "decimals": "Decimals", + "topHolders": "Top Holders", + "holderRank": "Rank", + "holderAddress": "Address", + "amount": "Amount", + "percentage": "Percentage", + "noHolders": "No holders found" + }, + "validators": { + "title": "Validators", + "currentValidators": "Current Validators", + "delinquentValidators": "Delinquent Validators", + "totalStake": "Total Active Stake", + "validatorCount": "Validators", + "identity": "Identity", + "voteAccount": "Vote Account", + "stake": "Stake", + "commission": "Commission", + "lastVote": "Last Vote", + "epochCredits": "Epoch Credits", + "currentEpoch": "Current Epoch", + "epochProgress": "Epoch Progress", + "noValidators": "No validators found" + }, + "common": { + "loading": "Loading...", + "error": "Error", + "lamports": "lamports", + "sol": "SOL", + "slot": "Slot", + "copy": "Copy" + } +} diff --git a/src/services/AIPromptTemplates.ts b/src/services/AIPromptTemplates.ts index 66129d0f..0d189f23 100644 --- a/src/services/AIPromptTemplates.ts +++ b/src/services/AIPromptTemplates.ts @@ -57,9 +57,24 @@ export function buildPrompt( return buildBitcoinBlockPrompt(config, context, promptContext); case "bitcoin_address": return buildBitcoinAddressPrompt(config, context, promptContext); + case "solana_transaction": + case "solana_block": + case "solana_account": + // Solana uses a generic prompt builder (no specialized builder yet) + return buildGenericSolanaPrompt(config, context, promptContext); } } +function buildGenericSolanaPrompt( + config: PromptConfig, + context: Record, + promptContext: PromptContext, +): PromptPair { + const system = buildSystemPrompt(config, promptContext, config.customRules); + const user = `${config.task}. Analyze the following Solana data:\n\n${JSON.stringify(context, null, 2)}`; + return { system, user }; +} + function languageInstruction(language?: string): string { if (!language || language === "en") return ""; const found = SUPPORTED_LANGUAGES.find((l) => l.code === language); @@ -173,6 +188,38 @@ const POWER_STABLE_CONFIGS: Record = { sections: ["Address Analysis", "Balance and UTXOs", "Activity", "Notable Aspects"], customRules: "Express amounts in BTC. Never use gas, wei, Gwei, or EVM terminology.", }, + solana_transaction: { + role: "Solana blockchain analyst", + conciseness: "6-8 sentences", + focusAreas: + "instructions, programs invoked, token transfers, fee and compute units, success/failure, and notable aspects", + audience: "senior Solana developer", + task: "Analyze this Solana transaction", + sections: ["Transaction Analysis", "Instructions", "Token Changes", "Notable Aspects"], + customRules: + "Express amounts in SOL (not lamports). Never use gas, wei, Gwei, or EVM terminology. Use lamports only for fee display alongside SOL.", + }, + solana_block: { + role: "Solana blockchain analyst", + conciseness: "3-5 sentences", + focusAreas: + "transaction count, slot number, block rewards, leader identity, and block utilization", + audience: "senior Solana developer", + task: "Analyze this Solana block (slot)", + sections: ["Block Analysis", "Rewards", "Notable Aspects"], + customRules: "Express amounts in SOL. Never use gas, wei, Gwei, or EVM terminology.", + }, + solana_account: { + role: "Solana blockchain analyst", + conciseness: "4-6 sentences", + focusAreas: + "account type (wallet/program/token), SOL balance, owner program, token holdings, and executable status", + audience: "senior Solana developer", + task: "Analyze this Solana account", + sections: ["Account Analysis", "Balance and Holdings", "Activity", "Notable Aspects"], + customRules: + "Express amounts in SOL. Identify if account is a program, system account, or token account. Never use gas, wei, Gwei, or EVM terminology.", + }, }; // --- Regular User Stable Configs (simpler prompts for non-super-users) --- @@ -240,6 +287,34 @@ const REGULAR_STABLE_CONFIGS: Record = { sections: ["Overview", "Balance"], customRules: "Express amounts in BTC. No EVM terminology.", }, + solana_transaction: { + role: "Solana educator", + conciseness: "4-6 sentences", + focusAreas: "what happened, who signed, programs called, and the fee paid", + audience: "general user", + task: "Explain this Solana transaction in simple, easy-to-understand language", + sections: ["What Happened", "Programs Used", "Fee Details"], + customRules: + "Use simple language. Avoid jargon. Express amounts in SOL. Never use gas, wei, or EVM terminology.", + }, + solana_block: { + role: "Solana educator", + conciseness: "2-3 sentences", + focusAreas: "what happened in this slot, how many transactions it included", + audience: "general user", + task: "Summarize this Solana block in simple terms", + sections: ["Block Summary", "Activity"], + customRules: "Express amounts in SOL. No EVM terminology.", + }, + solana_account: { + role: "Solana educator", + conciseness: "3-4 sentences", + focusAreas: "what this account is, its current SOL balance, and any token holdings", + audience: "general user", + task: "Provide a simple overview of this Solana account", + sections: ["Overview", "Balance"], + customRules: "Express amounts in SOL. No EVM terminology.", + }, }; // --- Latest Configs (initially copies of stable; experiment here) --- diff --git a/src/services/DataService.ts b/src/services/DataService.ts index 2d8ad997..29c26b5a 100644 --- a/src/services/DataService.ts +++ b/src/services/DataService.ts @@ -1,24 +1,32 @@ -import { type SupportedChainId, ClientFactory, BitcoinClient } from "@openscan/network-connectors"; +import { + type SupportedChainId, + type SupportedSolanaChainId, + ClientFactory, + BitcoinClient, +} from "@openscan/network-connectors"; import { AdapterFactory } from "./adapters/adaptersFactory"; import type { NetworkAdapter } from "./adapters/NetworkAdapter"; import type { BitcoinAdapter } from "./adapters/BitcoinAdapter/BitcoinAdapter"; +import type { SolanaAdapter } from "./adapters/SolanaAdapter/SolanaAdapter"; import type { NetworkConfig, RpcUrlsContextType } from "../types"; import { getRPCUrls } from "../config/rpcConfig"; import { getNetworkRpcKey, getChainIdFromNetwork } from "../utils/networkResolver"; /** - * DataService supports both EVM and Bitcoin networks + * DataService supports EVM, Bitcoin, and Solana networks * The adapter type varies based on network type */ export class DataService { /** * The network adapter - use this directly for EVM networks * For Bitcoin networks, use getBitcoinAdapter() instead + * For Solana networks, use getSolanaAdapter() instead */ networkAdapter: NetworkAdapter; private bitcoinAdapter?: BitcoinAdapter; - readonly networkType: "evm" | "bitcoin"; + private solanaAdapter?: SolanaAdapter; + readonly networkType: "evm" | "bitcoin" | "solana"; constructor( network: NetworkConfig, @@ -37,7 +45,15 @@ export class DataService { }); this.bitcoinAdapter = AdapterFactory.createBitcoinAdapter(network.networkId, bitcoinClient); // Create a placeholder adapter that throws for EVM methods - // This maintains type compatibility while ensuring Bitcoin networks use the right methods + this.networkAdapter = null as unknown as NetworkAdapter; + } else if (network.type === "solana") { + // Create Solana client and adapter via ClientFactory + const solanaChainId = network.networkId as SupportedSolanaChainId; + const solanaClient = ClientFactory.createTypedClient(solanaChainId, { + rpcUrls, + type: strategy, + }); + this.solanaAdapter = AdapterFactory.createSolanaAdapter(network.networkId, solanaClient); this.networkAdapter = null as unknown as NetworkAdapter; } else { // Create EVM client and adapter @@ -64,6 +80,13 @@ export class DataService { return this.networkType === "bitcoin"; } + /** + * Check if this is a Solana network service + */ + isSolana(): boolean { + return this.networkType === "solana"; + } + /** * Get the adapter as an EVM adapter (throws if not EVM) */ @@ -83,4 +106,14 @@ export class DataService { } return this.bitcoinAdapter; } + + /** + * Get the adapter as a Solana adapter (throws if not Solana) + */ + getSolanaAdapter(): SolanaAdapter { + if (!this.isSolana() || !this.solanaAdapter) { + throw new Error("Cannot get Solana adapter for non-Solana network"); + } + return this.solanaAdapter; + } } diff --git a/src/services/MetadataService.ts b/src/services/MetadataService.ts index a4439fdc..ecd6e8d0 100644 --- a/src/services/MetadataService.ts +++ b/src/services/MetadataService.ts @@ -7,7 +7,7 @@ import networksData from "../config/networks.json"; import { logger } from "../utils/logger"; import { extractChainIdFromNetworkId } from "../utils/networkResolver"; -export const METADATA_VERSION = "1.1.2-alpha.0"; +export const METADATA_VERSION = "1.2.0-alpha.0"; const METADATA_BASE_URL = `https://cdn.jsdelivr.net/npm/@openscan/metadata@${METADATA_VERSION}/dist`; export interface NetworkLink { @@ -81,10 +81,18 @@ const BTC_NETWORK_SLUGS: Record = { "00000000da84f2bafbbc53dee25a72ae": "testnet4", }; +// Solana CAIP-2 chain IDs (first 32 chars of genesis hash) → friendly file names +const SOLANA_NETWORK_SLUGS: Record = { + "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": "mainnet", + EtWTRABZaYq6iMfeYKouRu166VU2xqa1: "devnet", + "4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": "testnet", +}; + /** * Parse a CAIP-2 networkId to determine the RPC file path * "eip155:{chainId}" → rpcs/evm/{chainId}.json * "bip122:{hash}" → rpcs/btc/{slug}.json (using genesis hash → slug mapping) + * "solana:{hash}" → rpcs/solana/{slug}.json (using genesis hash → slug mapping) */ function getRpcPathFromNetworkId(networkId: string): string | null { if (networkId.startsWith("eip155:")) { @@ -97,6 +105,12 @@ function getRpcPathFromNetworkId(networkId: string): string | null { if (!slug) return null; return `rpcs/btc/${slug}.json`; } + if (networkId.startsWith("solana:")) { + const hash = networkId.slice(7); + const slug = SOLANA_NETWORK_SLUGS[hash]; + if (!slug) return null; + return `rpcs/solana/${slug}.json`; + } return null; } diff --git a/src/services/adapters/SolanaAdapter/SolanaAdapter.ts b/src/services/adapters/SolanaAdapter/SolanaAdapter.ts new file mode 100644 index 00000000..cf4d36eb --- /dev/null +++ b/src/services/adapters/SolanaAdapter/SolanaAdapter.ts @@ -0,0 +1,496 @@ +import type { + DataWithMetadata, + SolanaAccount, + SolanaBlock, + SolanaEpochInfo, + SolanaInnerInstruction, + SolanaInstruction, + SolanaLeaderSchedule, + SolanaNetworkStats, + SolanaReward, + SolanaSignatureInfo, + SolanaTokenAmount, + SolanaTokenHolding, + SolanaTokenLargestAccount, + SolanaTransaction, + SolanaValidator, +} from "../../../types"; +import type { SolanaClient, SolBlock, SolTransaction } from "@openscan/network-connectors"; + +// Not exported from the package — mirror the shape +interface SolParsedAccountKey { + pubkey: string; + writable: boolean; + signer: boolean; + source?: "transaction" | "lookupTable"; +} + +// SPL Token Program IDs +const TOKEN_PROGRAM_ID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; +const TOKEN_2022_PROGRAM_ID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"; + +/** + * Solana blockchain adapter + * + * Follows the same pattern as BitcoinAdapter — standalone class, not extending NetworkAdapter. + */ +export class SolanaAdapter { + readonly networkId: string; + private client: SolanaClient; + + constructor(networkId: string, client: SolanaClient) { + this.networkId = networkId; + this.client = client; + } + + // ==================== CORE METHODS ==================== + + /** + * Get the current slot number + */ + async getLatestSlot(): Promise { + const result = await this.client.getSlot("finalized"); + return result.data ?? 0; + } + + /** + * Get network statistics + */ + async getNetworkStats(): Promise> { + const [slotResult, epochResult, versionResult, txCountResult] = await Promise.all([ + this.client.getSlot("finalized"), + this.client.getEpochInfo("finalized"), + this.client.getVersion(), + this.client.getTransactionCount("finalized"), + ]); + + const epochInfo = epochResult.data; + + const stats: SolanaNetworkStats = { + currentSlot: slotResult.data ?? 0, + blockHeight: epochInfo?.blockHeight ?? 0, + epoch: epochInfo?.epoch ?? 0, + epochSlotIndex: epochInfo?.slotIndex ?? 0, + epochSlotsTotal: epochInfo?.slotsInEpoch ?? 0, + transactionCount: txCountResult.data ?? 0, + version: versionResult.data?.["solana-core"] ?? "unknown", + }; + + return { + data: stats, + metadata: slotResult.metadata as DataWithMetadata["metadata"], + }; + } + + /** + * Get epoch info + */ + async getEpochInfo(): Promise { + const result = await this.client.getEpochInfo("finalized"); + const data = result.data; + if (!data) { + throw new Error("Failed to fetch epoch info"); + } + return { + epoch: data.epoch, + slotIndex: data.slotIndex, + slotsInEpoch: data.slotsInEpoch, + absoluteSlot: data.absoluteSlot, + blockHeight: data.blockHeight, + transactionCount: data.transactionCount, + }; + } + + /** + * Get the latest N blocks (slots with confirmed blocks) + */ + async getLatestBlocks(count = 10): Promise { + const currentSlot = await this.getLatestSlot(); + + // Get confirmed block slots in a range + const startSlot = Math.max(0, currentSlot - 100); + const slotsResult = await this.client.getBlocks(startSlot, currentSlot, "finalized"); + const slots = (slotsResult.data ?? []).slice(-count).reverse(); + + // Fetch block details in parallel + const blockResults = await Promise.all( + slots.map((slot) => + this.client + .getBlock(slot, { + encoding: "jsonParsed", + transactionDetails: "signatures", + rewards: true, + commitment: "finalized", + }) + .catch(() => null), + ), + ); + + const blocks: SolanaBlock[] = []; + for (let i = 0; i < slots.length; i++) { + const result = blockResults[i]; + if (!result?.data) continue; + + const blockData = result.data; + blocks.push(this.transformBlock(slots[i] ?? 0, blockData)); + } + + return blocks; + } + + /** + * Get a single block by slot number + */ + async getBlock(slot: number): Promise> { + const result = await this.client.getBlock(slot, { + encoding: "jsonParsed", + transactionDetails: "signatures", + rewards: true, + commitment: "finalized", + }); + + if (!result.data) { + throw new Error(`Block at slot ${slot} not found`); + } + + return { + data: this.transformBlock(slot, result.data), + metadata: result.metadata as DataWithMetadata["metadata"], + }; + } + + /** + * Get a transaction by signature + */ + async getTransaction(signature: string): Promise> { + const result = await this.client.getTransaction(signature, { + encoding: "jsonParsed", + commitment: "finalized", + maxSupportedTransactionVersion: 0, + }); + + if (!result.data) { + throw new Error(`Transaction ${signature} not found`); + } + + return { + data: this.transformTransaction(signature, result.data), + metadata: result.metadata as DataWithMetadata["metadata"], + }; + } + + /** + * Get account information + */ + async getAccount(pubkey: string): Promise> { + const [accountResult, tokenAccountsResult] = await Promise.all([ + this.client.getAccountInfo(pubkey, { commitment: "finalized", encoding: "jsonParsed" }), + this.getTokenAccountsByOwner(pubkey).catch(() => []), + ]); + + const accountInfo = accountResult.data?.value; + + const account: SolanaAccount = { + address: pubkey, + lamports: accountInfo?.lamports ?? 0, + owner: accountInfo?.owner ?? "11111111111111111111111111111111", + executable: accountInfo?.executable ?? false, + rentEpoch: accountInfo?.rentEpoch ?? 0, + space: accountInfo?.space ?? 0, + tokenAccounts: tokenAccountsResult, + }; + + return { + data: account, + metadata: accountResult.metadata as DataWithMetadata["metadata"], + }; + } + + // ==================== TOKEN METHODS ==================== + + /** + * Get SPL token accounts owned by an address + */ + async getTokenAccountsByOwner(owner: string): Promise { + // Fetch from both Token Program and Token-2022 in parallel + const [tokenResult, token2022Result] = await Promise.all([ + this.client + .getTokenAccountsByOwner( + owner, + { programId: TOKEN_PROGRAM_ID }, + { encoding: "jsonParsed", commitment: "finalized" }, + ) + .catch(() => null), + this.client + .getTokenAccountsByOwner( + owner, + { programId: TOKEN_2022_PROGRAM_ID }, + { encoding: "jsonParsed", commitment: "finalized" }, + ) + .catch(() => null), + ]); + + const holdings: SolanaTokenHolding[] = []; + + // biome-ignore lint/suspicious/noExplicitAny: RPC response varies + const processAccounts = (result: { data?: { value?: any[] } } | null) => { + if (!result?.data?.value) return; + for (const tokenAccount of result.data.value) { + // biome-ignore lint/suspicious/noExplicitAny: parsed data varies + const parsed = (tokenAccount.account?.data as any)?.parsed?.info; + if (!parsed) continue; + + holdings.push({ + mint: parsed.mint, + tokenAccount: tokenAccount.pubkey, + amount: { + amount: parsed.tokenAmount?.amount ?? "0", + decimals: parsed.tokenAmount?.decimals ?? 0, + uiAmount: parsed.tokenAmount?.uiAmount ?? null, + uiAmountString: parsed.tokenAmount?.uiAmountString ?? "0", + }, + }); + } + }; + + processAccounts(tokenResult); + processAccounts(token2022Result); + + return holdings; + } + + /** + * Get total supply for an SPL token + */ + async getTokenSupply(mint: string): Promise { + const result = await this.client.getTokenSupply(mint, "finalized"); + const value = result.data?.value; + return { + amount: value?.amount ?? "0", + decimals: value?.decimals ?? 0, + uiAmount: value?.uiAmount ?? null, + uiAmountString: value?.uiAmountString ?? "0", + }; + } + + /** + * Get the largest holders of an SPL token + */ + async getTokenLargestAccounts(mint: string): Promise { + const result = await this.client.getTokenLargestAccounts(mint, "finalized"); + const accounts = result.data?.value ?? []; + return accounts.map((a) => ({ + address: a.address, + amount: a.amount, + decimals: a.decimals, + uiAmount: a.uiAmount, + uiAmountString: a.uiAmountString, + })); + } + + // ==================== VALIDATOR METHODS ==================== + + /** + * Get vote accounts (validators) + */ + async getVoteAccounts(): Promise<{ + current: SolanaValidator[]; + delinquent: SolanaValidator[]; + }> { + const result = await this.client.getVoteAccounts({ commitment: "finalized" }); + const data = result.data; + if (!data) { + return { current: [], delinquent: [] }; + } + + const mapValidator = (v: { + votePubkey: string; + nodePubkey: string; + activatedStake: number; + epochVoteAccount: boolean; + commission: number; + lastVote: number; + epochCredits: [number, number, number][]; + rootSlot?: number; + }): SolanaValidator => ({ + votePubkey: v.votePubkey, + nodePubkey: v.nodePubkey, + activatedStake: v.activatedStake, + commission: v.commission, + lastVote: v.lastVote, + epochVoteAccount: v.epochVoteAccount, + epochCredits: v.epochCredits, + rootSlot: v.rootSlot, + }); + + return { + current: (data.current ?? []).map(mapValidator), + delinquent: (data.delinquent ?? []).map(mapValidator), + }; + } + + /** + * Get leader schedule for current epoch + */ + async getLeaderSchedule(): Promise { + const result = await this.client.getLeaderSchedule(null, { commitment: "finalized" }); + return result.data ?? {}; + } + + // ==================== ACCOUNT HISTORY ==================== + + /** + * Get confirmed signatures for transactions involving an address + */ + async getSignaturesForAddress( + address: string, + config?: { limit?: number; before?: string }, + ): Promise { + const result = await this.client.getSignaturesForAddress(address, { + limit: config?.limit ?? 20, + before: config?.before, + commitment: "finalized", + }); + + return (result.data ?? []).map((sig) => ({ + signature: sig.signature, + slot: sig.slot, + blockTime: sig.blockTime, + err: sig.err, + memo: sig.memo, + confirmationStatus: sig.confirmationStatus, + })); + } + + // ==================== UTILITY METHODS ==================== + + isSolana(): boolean { + return true; + } + + getNetworkId(): string { + return this.networkId; + } + + // ==================== PRIVATE TRANSFORM METHODS ==================== + + private transformBlock(slot: number, blockData: SolBlock): SolanaBlock { + return { + slot, + blockhash: blockData.blockhash, + previousBlockhash: blockData.previousBlockhash, + parentSlot: blockData.parentSlot, + blockHeight: blockData.blockHeight, + blockTime: blockData.blockTime, + transactionCount: blockData.signatures?.length ?? blockData.transactions?.length ?? 0, + rewards: (blockData.rewards ?? []).map( + (r): SolanaReward => ({ + pubkey: r.pubkey, + lamports: r.lamports, + postBalance: r.postBalance, + rewardType: r.rewardType, + commission: r.commission, + }), + ), + signatures: blockData.signatures, + }; + } + + private transformTransaction(signature: string, txData: SolTransaction): SolanaTransaction { + const meta = txData.meta; + const message = txData.transaction.message; + + // Parse account keys + const accountKeys = Array.isArray(message.accountKeys) + ? message.accountKeys.map((key) => { + if (typeof key === "string") { + return { pubkey: key, writable: false, signer: false }; + } + const parsed = key as SolParsedAccountKey; + return { + pubkey: parsed.pubkey, + writable: parsed.writable, + signer: parsed.signer, + }; + }) + : []; + + // Extract signers + const signers = accountKeys.filter((k) => k.signer).map((k) => k.pubkey); + if (signers.length === 0 && txData.transaction.signatures.length > 0) { + // First account key is always the fee payer / signer + const firstKey = accountKeys[0]; + if (firstKey) { + signers.push(firstKey.pubkey); + } + } + + // Transform instructions + const instructions: SolanaInstruction[] = message.instructions.map( + // biome-ignore lint/suspicious/noExplicitAny: instruction format varies + (ix: any): SolanaInstruction => ({ + programId: ix.programId ?? ix.program ?? "", + accounts: ix.accounts ?? [], + data: ix.data ?? "", + parsed: ix.parsed, + }), + ); + + // Transform inner instructions + const innerInstructions: SolanaInnerInstruction[] = (meta?.innerInstructions ?? []).map( + // biome-ignore lint/suspicious/noExplicitAny: inner instruction format varies + (group: any): SolanaInnerInstruction => ({ + index: group.index, + instructions: (group.instructions ?? []).map( + // biome-ignore lint/suspicious/noExplicitAny: instruction format varies + (ix: any): SolanaInstruction => ({ + programId: ix.programId ?? ix.program ?? "", + accounts: ix.accounts ?? [], + data: ix.data ?? "", + parsed: ix.parsed, + }), + ), + }), + ); + + return { + signature, + slot: txData.slot, + blockTime: txData.blockTime, + fee: meta?.fee ?? 0, + status: meta?.err ? "failed" : "success", + err: meta?.err ?? null, + signers, + accountKeys, + instructions, + innerInstructions, + logMessages: meta?.logMessages ?? [], + preBalances: meta?.preBalances ?? [], + postBalances: meta?.postBalances ?? [], + preTokenBalances: (meta?.preTokenBalances ?? []).map((tb) => ({ + accountIndex: tb.accountIndex, + mint: tb.mint, + owner: tb.owner, + uiTokenAmount: { + amount: tb.uiTokenAmount.amount, + decimals: tb.uiTokenAmount.decimals, + uiAmount: tb.uiTokenAmount.uiAmount, + uiAmountString: tb.uiTokenAmount.uiAmountString, + }, + })), + postTokenBalances: (meta?.postTokenBalances ?? []).map((tb) => ({ + accountIndex: tb.accountIndex, + mint: tb.mint, + owner: tb.owner, + uiTokenAmount: { + amount: tb.uiTokenAmount.amount, + decimals: tb.uiTokenAmount.decimals, + uiAmount: tb.uiTokenAmount.uiAmount, + uiAmountString: tb.uiTokenAmount.uiAmountString, + }, + })), + computeUnitsConsumed: meta?.computeUnitsConsumed, + version: txData.version, + }; + } +} diff --git a/src/services/adapters/adaptersFactory.ts b/src/services/adapters/adaptersFactory.ts index 956d85a1..d59f5737 100644 --- a/src/services/adapters/adaptersFactory.ts +++ b/src/services/adapters/adaptersFactory.ts @@ -7,6 +7,7 @@ import { PolygonAdapter } from "./PolygonAdapter/PolygonAdapter"; import { ArbitrumAdapter } from "./ArbitrumAdapter/ArbitrumAdapter"; import { HardhatAdapter } from "./HardhatAdapter/HardhatAdapter"; import { BitcoinAdapter } from "./BitcoinAdapter/BitcoinAdapter"; +import { SolanaAdapter } from "./SolanaAdapter/SolanaAdapter"; import type { ArbitrumClient, AvalancheClient, @@ -18,6 +19,7 @@ import type { HardhatClient, OptimismClient, PolygonClient, + SolanaClient, SupportedChainId, } from "@openscan/network-connectors"; @@ -68,4 +70,11 @@ export class AdapterFactory { static createBitcoinAdapter(networkId: string, client: BitcoinClient): BitcoinAdapter { return new BitcoinAdapter(networkId, client); } + + /** + * Create a Solana network adapter + */ + static createSolanaAdapter(networkId: string, client: SolanaClient): SolanaAdapter { + return new SolanaAdapter(networkId, client); + } } diff --git a/src/styles/styles.css b/src/styles/styles.css index 1af9f89c..74704a7b 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -4392,6 +4392,11 @@ code { color: var(--color-warning); } +.block-status-failed { + background: var(--color-error-alpha-15); + color: var(--color-error); +} + .block-display-grid { display: flex; flex-direction: column; diff --git a/src/types/index.ts b/src/types/index.ts index 14025cf1..ee904ab4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,9 +4,9 @@ import type React from "react"; // ==================== NETWORK TYPES ==================== /** - * Network type - EVM or Bitcoin + * Network type - EVM, Bitcoin, or Solana */ -export type NetworkType = "evm" | "bitcoin"; +export type NetworkType = "evm" | "bitcoin" | "solana"; /** * All EVM chain IDs supported by the app. @@ -273,6 +273,198 @@ export interface BitcoinAddress { txids?: string[]; } +// ==================== SOLANA TYPES ==================== + +/** + * Solana network statistics + */ +export interface SolanaNetworkStats { + currentSlot: number; + blockHeight: number; + epoch: number; + epochSlotIndex: number; + epochSlotsTotal: number; + transactionCount: number; + version: string; +} + +/** + * Solana epoch info + */ +export interface SolanaEpochInfo { + epoch: number; + slotIndex: number; + slotsInEpoch: number; + absoluteSlot: number; + blockHeight: number; + transactionCount?: number; +} + +/** + * Solana block/slot data + */ +export interface SolanaBlock { + slot: number; + blockhash: string; + previousBlockhash: string; + parentSlot: number; + blockHeight: number | null; + blockTime: number | null; + transactionCount: number; + rewards: SolanaReward[]; + // Transaction signatures (for block list views) + signatures?: string[]; +} + +/** + * Solana block reward entry + */ +export interface SolanaReward { + pubkey: string; + lamports: number; + postBalance: number; + rewardType: "fee" | "rent" | "staking" | "voting" | null; + commission?: number | null; +} + +/** + * Solana transaction data + */ +export interface SolanaTransaction { + signature: string; + slot: number; + blockTime: number | null; + fee: number; + status: "success" | "failed"; + // biome-ignore lint/suspicious/noExplicitAny: error format varies + err: any; + signers: string[]; + accountKeys: SolanaAccountKey[]; + instructions: SolanaInstruction[]; + innerInstructions: SolanaInnerInstruction[]; + logMessages: string[]; + preBalances: number[]; + postBalances: number[]; + preTokenBalances: SolanaTokenBalance[]; + postTokenBalances: SolanaTokenBalance[]; + computeUnitsConsumed?: number; + version?: "legacy" | 0; +} + +/** + * Solana parsed account key with permissions + */ +export interface SolanaAccountKey { + pubkey: string; + writable: boolean; + signer: boolean; +} + +/** + * Solana instruction + */ +export interface SolanaInstruction { + programId: string; + accounts: string[]; + data: string; + // biome-ignore lint/suspicious/noExplicitAny: parsed instruction formats vary + parsed?: any; +} + +/** + * Solana inner instruction group + */ +export interface SolanaInnerInstruction { + index: number; + instructions: SolanaInstruction[]; +} + +/** + * Solana token balance (pre/post transaction) + */ +export interface SolanaTokenBalance { + accountIndex: number; + mint: string; + owner?: string; + uiTokenAmount: SolanaTokenAmount; +} + +/** + * Solana token amount + */ +export interface SolanaTokenAmount { + amount: string; + decimals: number; + uiAmount: number | null; + uiAmountString: string; +} + +/** + * Solana account data + */ +export interface SolanaAccount { + address: string; + lamports: number; + owner: string; + executable: boolean; + rentEpoch: number; + space: number; + // Token holdings (fetched separately) + tokenAccounts?: SolanaTokenHolding[]; +} + +/** + * Solana SPL token holding for an account + */ +export interface SolanaTokenHolding { + mint: string; + tokenAccount: string; + amount: SolanaTokenAmount; +} + +/** + * Solana token largest account holder + */ +export interface SolanaTokenLargestAccount { + address: string; + amount: string; + decimals: number; + uiAmount: number | null; + uiAmountString: string; +} + +/** + * Solana validator (vote account) + */ +export interface SolanaValidator { + votePubkey: string; + nodePubkey: string; + activatedStake: number; + commission: number; + lastVote: number; + epochVoteAccount: boolean; + epochCredits: [number, number, number][]; + rootSlot?: number; +} + +/** + * Solana signature info (for address transaction history) + */ +export interface SolanaSignatureInfo { + signature: string; + slot: number; + blockTime: number | null; + // biome-ignore lint/suspicious/noExplicitAny: error format varies + err: any; + memo: string | null; + confirmationStatus: "processed" | "confirmed" | "finalized" | null; +} + +/** + * Solana leader schedule + */ +export type SolanaLeaderSchedule = Record; + export interface Address { address: string; balance: string; @@ -490,7 +682,10 @@ export type AIAnalysisType = | "block" | "bitcoin_transaction" | "bitcoin_block" - | "bitcoin_address"; + | "bitcoin_address" + | "solana_transaction" + | "solana_block" + | "solana_account"; /** * Prompt version for AI analysis diff --git a/src/utils/networkResolver.ts b/src/utils/networkResolver.ts index 12135599..041361ae 100644 --- a/src/utils/networkResolver.ts +++ b/src/utils/networkResolver.ts @@ -71,6 +71,13 @@ export function isBitcoinNetwork(network: NetworkConfig): boolean { return network.type === "bitcoin"; } +/** + * Check if a network is a Solana network + */ +export function isSolanaNetwork(network: NetworkConfig): boolean { + return network.type === "solana"; +} + /** * Get the URL path segment for a network * Uses slug if available, otherwise chainId for EVM or networkId diff --git a/src/utils/rpcStorage.ts b/src/utils/rpcStorage.ts index 647498ff..5a66c614 100644 --- a/src/utils/rpcStorage.ts +++ b/src/utils/rpcStorage.ts @@ -64,6 +64,24 @@ const BUILTIN_RPC_DEFAULTS: RpcUrlsContextType = { `${OPENSCAN_WORKER_URL}/evm/drpc/eip155:43114`, `${OPENSCAN_WORKER_URL}/evm/ankr/eip155:43114`, ], + // Solana — public RPC endpoints (rate-limited; users should add their own for production use) + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": [ + "https://api.mainnet-beta.solana.com", + "https://solana-rpc.publicnode.com", + "https://solana.drpc.org", + "https://rpc.ankr.com/solana", + "https://solana.api.pocket.network", + ], + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": [ + "https://api.devnet.solana.com", + "https://solana-devnet-rpc.publicnode.com", + "https://solana-devnet.drpc.org", + "https://rpc.ankr.com/solana_devnet", + ], + "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": [ + "https://api.testnet.solana.com", + "https://solana-testnet-rpc.publicnode.com", + ], }; interface MetadataRpcCache { diff --git a/src/utils/solanaUtils.ts b/src/utils/solanaUtils.ts new file mode 100644 index 00000000..f041b021 --- /dev/null +++ b/src/utils/solanaUtils.ts @@ -0,0 +1,88 @@ +/** + * Solana-specific utility functions + */ + +const LAMPORTS_PER_SOL = 1_000_000_000; + +/** + * Convert lamports to SOL string with appropriate decimals + */ +export function lamportsToSol(lamports: number): string { + if (lamports === 0) return "0"; + const sol = lamports / LAMPORTS_PER_SOL; + // Use up to 9 decimals, but trim trailing zeros + return sol.toFixed(9).replace(/\.?0+$/, ""); +} + +/** + * Format lamports as a human-readable SOL amount + */ +export function formatSol(lamports: number): string { + return `${lamportsToSol(lamports)} SOL`; +} + +/** + * Shorten a Solana address (base58 pubkey) for display + */ +export function shortenSolanaAddress(address: string, prefixLen = 4, suffixLen = 4): string { + if (!address || address.length <= prefixLen + suffixLen) return address; + return `${address.slice(0, prefixLen)}...${address.slice(-suffixLen)}`; +} + +/** + * Validate that a string looks like a Solana address (base58, 32-44 chars) + */ +export function isSolanaAddress(input: string): boolean { + if (!input) return false; + // Base58 alphabet (no 0, O, I, l) and length 32-44 + return /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(input); +} + +/** + * Validate that a string looks like a Solana transaction signature (base58, 87-88 chars) + */ +export function isSolanaSignature(input: string): boolean { + if (!input) return false; + return /^[1-9A-HJ-NP-Za-km-z]{86,90}$/.test(input); +} + +/** + * Format slot number with commas + */ +export function formatSlotNumber(slot: number): string { + return slot.toLocaleString(); +} + +/** + * Get a transaction status string from the Solana err field + */ +export function getTransactionStatus(err: unknown): "success" | "failed" { + return err == null ? "success" : "failed"; +} + +/** + * Format a Solana block time (Unix seconds) to a relative time + */ +export function formatBlockTime(blockTime: number | null): string { + if (blockTime === null) return "Unknown"; + const date = new Date(blockTime * 1000); + return date.toLocaleString(); +} + +/** + * Calculate epoch progress percentage + */ +export function calculateEpochProgress(slotIndex: number, slotsInEpoch: number): number { + if (slotsInEpoch === 0) return 0; + return (slotIndex / slotsInEpoch) * 100; +} + +/** + * Format a stake amount (lamports) as SOL with M/B suffix for large amounts + */ +export function formatStake(lamports: number): string { + const sol = lamports / LAMPORTS_PER_SOL; + if (sol >= 1_000_000) return `${(sol / 1_000_000).toFixed(2)}M SOL`; + if (sol >= 1_000) return `${(sol / 1_000).toFixed(2)}K SOL`; + return `${sol.toFixed(2)} SOL`; +}