From 2087334dad641f140f7c59710fbc019b1ead6a8c Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 6 Apr 2026 17:50:54 -0300 Subject: [PATCH 01/15] feat(solana): add Solana network support (Mainnet, Devnet, Testnet) Adds Solana as a third network type alongside EVM and Bitcoin, with dashboard, slots/blocks, transactions, accounts, SPL tokens, and validators views. --- src/App.tsx | 35 ++ src/components/LazyComponents.tsx | 27 + .../pages/solana/SolanaAccountPage.tsx | 169 ++++++ .../pages/solana/SolanaBlocksTable.tsx | 78 +++ .../pages/solana/SolanaDashboardStats.tsx | 73 +++ .../pages/solana/SolanaSlotPage.tsx | 151 ++++++ .../pages/solana/SolanaSlotsPage.tsx | 90 ++++ .../pages/solana/SolanaTokenPage.tsx | 131 +++++ .../pages/solana/SolanaTransactionPage.tsx | 188 +++++++ .../pages/solana/SolanaTransactionsPage.tsx | 106 ++++ .../pages/solana/SolanaTransactionsTable.tsx | 73 +++ .../pages/solana/SolanaValidatorsPage.tsx | 164 ++++++ src/components/pages/solana/index.tsx | 89 ++++ src/config/networks.json | 72 +++ src/hooks/useSolanaDashboard.ts | 118 +++++ src/i18n.ts | 11 + src/i18next.d.ts | 2 + src/locales/en/solana.json | 132 +++++ src/locales/es/solana.json | 132 +++++ src/locales/ja/solana.json | 132 +++++ src/locales/pt-BR/solana.json | 132 +++++ src/locales/zh/solana.json | 132 +++++ src/services/AIPromptTemplates.ts | 75 +++ src/services/DataService.ts | 115 ++++- .../adapters/SolanaAdapter/SolanaAdapter.ts | 488 ++++++++++++++++++ .../SolanaAdapter/SolanaClientTypes.ts | 260 ++++++++++ src/services/adapters/adaptersFactory.ts | 9 + src/types/index.ts | 201 +++++++- src/utils/networkResolver.ts | 7 + src/utils/solanaUtils.ts | 88 ++++ 30 files changed, 3475 insertions(+), 5 deletions(-) create mode 100644 src/components/pages/solana/SolanaAccountPage.tsx create mode 100644 src/components/pages/solana/SolanaBlocksTable.tsx create mode 100644 src/components/pages/solana/SolanaDashboardStats.tsx create mode 100644 src/components/pages/solana/SolanaSlotPage.tsx create mode 100644 src/components/pages/solana/SolanaSlotsPage.tsx create mode 100644 src/components/pages/solana/SolanaTokenPage.tsx create mode 100644 src/components/pages/solana/SolanaTransactionPage.tsx create mode 100644 src/components/pages/solana/SolanaTransactionsPage.tsx create mode 100644 src/components/pages/solana/SolanaTransactionsTable.tsx create mode 100644 src/components/pages/solana/SolanaValidatorsPage.tsx create mode 100644 src/components/pages/solana/index.tsx create mode 100644 src/hooks/useSolanaDashboard.ts create mode 100644 src/locales/en/solana.json create mode 100644 src/locales/es/solana.json create mode 100644 src/locales/ja/solana.json create mode 100644 src/locales/pt-BR/solana.json create mode 100644 src/locales/zh/solana.json create mode 100644 src/services/adapters/SolanaAdapter/SolanaAdapter.ts create mode 100644 src/services/adapters/SolanaAdapter/SolanaClientTypes.ts create mode 100644 src/utils/solanaUtils.ts 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/pages/solana/SolanaAccountPage.tsx b/src/components/pages/solana/SolanaAccountPage.tsx new file mode 100644 index 00000000..f5e3bf4b --- /dev/null +++ b/src/components/pages/solana/SolanaAccountPage.tsx @@ -0,0 +1,169 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useLocation, useParams } from "react-router-dom"; +import { useDataService } from "../../../hooks/useDataService"; +import { resolveNetwork } from "../../../utils/networkResolver"; +import { getAllNetworks } from "../../../config/networks"; +import type { SolanaAccount, SolanaSignatureInfo } from "../../../types"; +import { formatSol, shortenSolanaAddress } from "../../../utils/solanaUtils"; + +export default function SolanaAccountPage() { + const { address } = useParams<{ address: string }>(); + const location = useLocation(); + const { t } = useTranslation("solana"); + + const pathSlug = location.pathname.split("/")[1] || "sol"; + const network = resolveNetwork(pathSlug, getAllNetworks()); + const dataService = useDataService(network ?? pathSlug); + + const [account, setAccount] = useState(null); + const [signatures, setSignatures] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function fetchAccount() { + if (!dataService || !dataService.isSolana() || !address) return; + setLoading(true); + 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); + setError(null); + } + } 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]); + + if (loading) + return ( +
+

{t("common.loading")}

+
+ ); + if (error) + return ( +
+

{error}

+
+ ); + if (!account) + return ( +
+

{t("account.title")}

+
+ ); + + return ( +
+
+

{account.executable ? t("account.program") : t("account.wallet")}

+ +
+
+ {t("account.address")}: + {account.address} +
+
+ {t("account.balance")}: + {formatSol(account.lamports)} +
+
+ {t("account.owner")}: + + {account.owner} + +
+
+ {t("account.executable")}: + + {account.executable ? t("account.yes") : t("account.no")} + +
+
+ {t("account.dataSize")}: + {account.space} bytes +
+
+ {t("account.rentEpoch")}: + {account.rentEpoch} +
+
+ + {account.tokenAccounts && account.tokenAccounts.length > 0 ? ( +
+

{t("account.tokenHoldings")}

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

{t("account.noTokens")}

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

{t("account.recentTransactions")}

+ + + + + + + + + + {signatures.map((sig) => ( + + + + + + ))} + +
{t("transaction.signature")}{t("transaction.status")}{t("transaction.slot")}
+ + {shortenSolanaAddress(sig.signature, 8, 6)} + + {sig.err ? t("transactions.failed") : t("transactions.success")}{sig.slot.toLocaleString()}
+
+ )} +
+
+ ); +} 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..f9b3929e --- /dev/null +++ b/src/components/pages/solana/SolanaDashboardStats.tsx @@ -0,0 +1,73 @@ +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` : ""} +
+
+ +
+
{t("dashboard.transactions")}
+
+ {loading && !stats ? skeleton("100px") : formatSlotNumber(stats?.transactionCount ?? 0)} +
+
+ {stats ? `${t("dashboard.version")}: ${stats.version}` : ""} +
+
+
+ ); +}; + +export default SolanaDashboardStats; diff --git a/src/components/pages/solana/SolanaSlotPage.tsx b/src/components/pages/solana/SolanaSlotPage.tsx new file mode 100644 index 00000000..53925f9b --- /dev/null +++ b/src/components/pages/solana/SolanaSlotPage.tsx @@ -0,0 +1,151 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useLocation, useParams } from "react-router-dom"; +import { useDataService } from "../../../hooks/useDataService"; +import { resolveNetwork } from "../../../utils/networkResolver"; +import { getAllNetworks } from "../../../config/networks"; +import type { SolanaBlock } from "../../../types"; +import { formatBlockTime, formatSol, formatSlotNumber } from "../../../utils/solanaUtils"; + +export default function SolanaSlotPage() { + const { filter } = useParams<{ filter: string }>(); + const location = useLocation(); + const { t } = useTranslation("solana"); + + const pathSlug = location.pathname.split("/")[1] || "sol"; + const network = resolveNetwork(pathSlug, getAllNetworks()); + const dataService = useDataService(network ?? pathSlug); + + const [block, setBlock] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function fetchBlock() { + if (!dataService || !dataService.isSolana() || !filter) return; + setLoading(true); + 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) { + setBlock(result.data); + setError(null); + } + } 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]); + + if (loading) + return ( +
+

{t("common.loading")}

+
+ ); + if (error) + return ( +
+

{error}

+
+ ); + if (!block) + return ( +
+

{t("blocks.noBlocks")}

+
+ ); + + return ( +
+
+

+ {t("block.title")} #{formatSlotNumber(block.slot)} +

+ +
+
+ {t("block.blockHash")}: + {block.blockhash} +
+
+ {t("block.previousBlockhash")}: + {block.previousBlockhash} +
+
+ {t("block.parentSlot")}: + + + #{formatSlotNumber(block.parentSlot)} + + +
+
+ {t("block.blockHeight")}: + + {block.blockHeight !== null ? formatSlotNumber(block.blockHeight) : "—"} + +
+
+ {t("block.blockTime")}: + {formatBlockTime(block.blockTime)} +
+
+ {t("block.transactionCount")}: + {block.transactionCount} +
+
+ + {block.rewards.length > 0 && ( +
+

{t("block.rewards")}

+ + + + + + + + + {block.rewards.map((reward) => ( + + + + + ))} + +
{t("block.rewardType")}{t("block.amount")}
{reward.rewardType ?? "—"}{formatSol(reward.lamports)}
+
+ )} + + {block.signatures && block.signatures.length > 0 && ( +
+

{t("block.transactions")}

+
    + {block.signatures.slice(0, 50).map((sig) => ( +
  • + {sig} +
  • + ))} +
+
+ )} +
+
+ ); +} diff --git a/src/components/pages/solana/SolanaSlotsPage.tsx b/src/components/pages/solana/SolanaSlotsPage.tsx new file mode 100644 index 00000000..dcfc5555 --- /dev/null +++ b/src/components/pages/solana/SolanaSlotsPage.tsx @@ -0,0 +1,90 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useLocation } from "react-router-dom"; +import { useDataService } from "../../../hooks/useDataService"; +import { resolveNetwork } from "../../../utils/networkResolver"; +import { getAllNetworks } from "../../../config/networks"; +import type { SolanaBlock } from "../../../types"; +import { formatSlotNumber, shortenSolanaAddress } from "../../../utils/solanaUtils"; + +export default function SolanaSlotsPage() { + const location = useLocation(); + const { t } = useTranslation("solana"); + + const pathSlug = location.pathname.split("/")[1] || "sol"; + const network = resolveNetwork(pathSlug, getAllNetworks()); + const dataService = useDataService(network ?? pathSlug); + + const [blocks, setBlocks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function fetchBlocks() { + if (!dataService || !dataService.isSolana()) return; + setLoading(true); + try { + const adapter = dataService.getSolanaAdapter(); + const result = await adapter.getLatestBlocks(25); + if (!cancelled) { + setBlocks(result); + setError(null); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to fetch blocks"); + } + } finally { + if (!cancelled) setLoading(false); + } + } + + fetchBlocks(); + return () => { + cancelled = true; + }; + }, [dataService]); + + return ( +
+
+

{t("blocks.blocksTitle")}

+ {error &&

{error}

} + {loading && blocks.length === 0 ? ( +

{t("common.loading")}

+ ) : blocks.length === 0 ? ( +

{t("blocks.noBlocks")}

+ ) : ( + + + + + + + + + + + {blocks.map((block) => ( + + + + + + + ))} + +
{t("blocks.slot")}{t("blocks.blockHash")}{t("blocks.txCount")}{t("blocks.time")}
+ + #{formatSlotNumber(block.slot)} + + {shortenSolanaAddress(block.blockhash, 8, 8)}{block.transactionCount} + {block.blockTime ? new Date(block.blockTime * 1000).toLocaleString() : "—"} +
+ )} +
+
+ ); +} diff --git a/src/components/pages/solana/SolanaTokenPage.tsx b/src/components/pages/solana/SolanaTokenPage.tsx new file mode 100644 index 00000000..c3af04e2 --- /dev/null +++ b/src/components/pages/solana/SolanaTokenPage.tsx @@ -0,0 +1,131 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useLocation, useParams } from "react-router-dom"; +import { useDataService } from "../../../hooks/useDataService"; +import { resolveNetwork } from "../../../utils/networkResolver"; +import { getAllNetworks } from "../../../config/networks"; +import type { SolanaTokenAmount, SolanaTokenLargestAccount } from "../../../types"; +import { shortenSolanaAddress } from "../../../utils/solanaUtils"; + +export default function SolanaTokenPage() { + const { mint } = useParams<{ mint: string }>(); + const location = useLocation(); + const { t } = useTranslation("solana"); + + const pathSlug = location.pathname.split("/")[1] || "sol"; + const network = resolveNetwork(pathSlug, getAllNetworks()); + const dataService = useDataService(network ?? pathSlug); + + const [supply, setSupply] = useState(null); + const [holders, setHolders] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function fetchToken() { + if (!dataService || !dataService.isSolana() || !mint) return; + setLoading(true); + try { + const adapter = dataService.getSolanaAdapter(); + const [supplyResult, holdersResult] = await Promise.all([ + adapter.getTokenSupply(mint), + adapter.getTokenLargestAccounts(mint), + ]); + if (!cancelled) { + setSupply(supplyResult); + setHolders(holdersResult); + setError(null); + } + } 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]); + + if (loading) + return ( +
+

{t("common.loading")}

+
+ ); + if (error) + return ( +
+

{error}

+
+ ); + + const totalSupplyNum = supply ? Number(supply.amount) : 0; + + return ( +
+
+

{t("token.title")}

+ +
+
+ {t("token.mint")}: + {mint} +
+ {supply && ( + <> +
+ {t("token.totalSupply")}: + {supply.uiAmountString} +
+
+ {t("token.decimals")}: + {supply.decimals} +
+ + )} +
+ + {holders.length > 0 ? ( +
+

{t("token.topHolders")}

+ + + + + + + + + + + {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/SolanaTransactionPage.tsx b/src/components/pages/solana/SolanaTransactionPage.tsx new file mode 100644 index 00000000..aa58c689 --- /dev/null +++ b/src/components/pages/solana/SolanaTransactionPage.tsx @@ -0,0 +1,188 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useLocation, useParams } from "react-router-dom"; +import { useDataService } from "../../../hooks/useDataService"; +import { resolveNetwork } from "../../../utils/networkResolver"; +import { getAllNetworks } from "../../../config/networks"; +import type { SolanaTransaction } from "../../../types"; +import { formatBlockTime, formatSol, formatSlotNumber } from "../../../utils/solanaUtils"; + +export default function SolanaTransactionPage() { + const { filter: signature } = useParams<{ filter: string }>(); + const location = useLocation(); + const { t } = useTranslation("solana"); + + const pathSlug = location.pathname.split("/")[1] || "sol"; + const network = resolveNetwork(pathSlug, getAllNetworks()); + const dataService = useDataService(network ?? pathSlug); + + const [tx, setTx] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function fetchTx() { + if (!dataService || !dataService.isSolana() || !signature) return; + setLoading(true); + try { + const adapter = dataService.getSolanaAdapter(); + const result = await adapter.getTransaction(signature); + if (!cancelled) { + setTx(result.data); + setError(null); + } + } 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]); + + if (loading) + return ( +
+

{t("common.loading")}

+
+ ); + if (error) + return ( +
+

{error}

+
+ ); + if (!tx) + return ( +
+

{t("transactions.noTransactions")}

+
+ ); + + return ( +
+
+

{t("transaction.title")}

+ +
+
+ {t("transaction.signature")}: + {tx.signature} +
+
+ {t("transaction.status")}: + + {tx.status === "success" ? t("transactions.success") : t("transactions.failed")} + +
+
+ {t("transaction.slot")}: + + #{formatSlotNumber(tx.slot)} + +
+
+ {t("transaction.blockTime")}: + {formatBlockTime(tx.blockTime)} +
+
+ {t("transaction.fee")}: + {formatSol(tx.fee)} +
+ {tx.computeUnitsConsumed !== undefined && ( +
+ {t("transaction.computeUnits")}: + {tx.computeUnitsConsumed.toLocaleString()} +
+ )} + {tx.version !== undefined && ( +
+ {t("transaction.version")}: + {String(tx.version)} +
+ )} +
+ + {tx.signers.length > 0 && ( +
+

{t("transaction.signers")}

+
    + {tx.signers.map((s) => ( +
  • + {s} +
  • + ))} +
+
+ )} + + {tx.accountKeys.length > 0 && ( +
+

{t("transaction.accountKeys")}

+ + + + + + + + + + {tx.accountKeys.map((key) => ( + + + + + + ))} + +
{t("transaction.programId")}{t("transaction.signer")}{t("transaction.writable")}
+ {key.pubkey} + {key.signer ? "✓" : ""}{key.writable ? "✓" : t("transaction.readonly")}
+
+ )} + + {tx.instructions.length > 0 && ( +
+

{t("transaction.instructions")}

+ {tx.instructions.map((ix, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: instructions are ordered +
+
+ {t("transaction.programId")}: + {ix.programId} +
+ {ix.accounts.length > 0 && ( +
+ {t("transaction.accounts")}: + {ix.accounts.join(", ")} +
+ )} + {ix.data && ( +
+ {t("transaction.data")}: + {ix.data} +
+ )} +
+ ))} +
+ )} + + {tx.logMessages.length > 0 && ( +
+

{t("transaction.logs")}

+
{tx.logMessages.join("\n")}
+
+ )} +
+
+ ); +} diff --git a/src/components/pages/solana/SolanaTransactionsPage.tsx b/src/components/pages/solana/SolanaTransactionsPage.tsx new file mode 100644 index 00000000..fda86d60 --- /dev/null +++ b/src/components/pages/solana/SolanaTransactionsPage.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useLocation } from "react-router-dom"; +import { useDataService } from "../../../hooks/useDataService"; +import { resolveNetwork } from "../../../utils/networkResolver"; +import { getAllNetworks } from "../../../config/networks"; +import type { SolanaTransaction } from "../../../types"; +import { formatSol, formatSlotNumber, shortenSolanaAddress } from "../../../utils/solanaUtils"; + +export default function SolanaTransactionsPage() { + const location = useLocation(); + const { t } = useTranslation("solana"); + + const pathSlug = location.pathname.split("/")[1] || "sol"; + const network = resolveNetwork(pathSlug, getAllNetworks()); + const dataService = useDataService(network ?? pathSlug); + + const [transactions, setTransactions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function fetchTxs() { + if (!dataService || !dataService.isSolana()) return; + setLoading(true); + try { + const adapter = dataService.getSolanaAdapter(); + // Get latest blocks then fetch some transactions from them + 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 >= 30) break; + } + const txResults = await Promise.all( + sigs.slice(0, 30).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); + setError(null); + } + } catch (err) { + if (!cancelled) + setError(err instanceof Error ? err.message : "Failed to fetch transactions"); + } finally { + if (!cancelled) setLoading(false); + } + } + + fetchTxs(); + return () => { + cancelled = true; + }; + }, [dataService]); + + return ( +
+
+

{t("transactions.txsTitle")}

+ {error &&

{error}

} + {loading && transactions.length === 0 ? ( +

{t("common.loading")}

+ ) : transactions.length === 0 ? ( +

{t("transactions.noTransactions")}

+ ) : ( + + + + + + + + + + + {transactions.map((tx) => ( + + + + + + + ))} + +
{t("transactions.signature")}{t("transactions.status")}{t("transactions.slot")}{t("transactions.fee")}
+ + {shortenSolanaAddress(tx.signature, 10, 8)} + + + {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..1ebbb9bc --- /dev/null +++ b/src/components/pages/solana/SolanaValidatorsPage.tsx @@ -0,0 +1,164 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useLocation } from "react-router-dom"; +import { useDataService } from "../../../hooks/useDataService"; +import { resolveNetwork } from "../../../utils/networkResolver"; +import { getAllNetworks } from "../../../config/networks"; +import type { SolanaEpochInfo, SolanaValidator } from "../../../types"; +import { + calculateEpochProgress, + formatStake, + shortenSolanaAddress, +} from "../../../utils/solanaUtils"; + +export default function SolanaValidatorsPage() { + const location = useLocation(); + const { t } = useTranslation("solana"); + + const pathSlug = location.pathname.split("/")[1] || "sol"; + const network = resolveNetwork(pathSlug, getAllNetworks()); + const dataService = useDataService(network ?? pathSlug); + + const [current, setCurrent] = useState([]); + const [delinquent, setDelinquent] = useState([]); + const [epochInfo, setEpochInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function fetchValidators() { + if (!dataService || !dataService.isSolana()) return; + setLoading(true); + try { + const adapter = dataService.getSolanaAdapter(); + const [voteAccounts, epoch] = await Promise.all([ + adapter.getVoteAccounts(), + adapter.getEpochInfo(), + ]); + if (!cancelled) { + // Sort by activated stake descending + const sortedCurrent = [...voteAccounts.current].sort( + (a, b) => b.activatedStake - a.activatedStake, + ); + setCurrent(sortedCurrent); + setDelinquent(voteAccounts.delinquent); + setEpochInfo(epoch); + setError(null); + } + } 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], + ); + + if (loading) + return ( +
+

{t("common.loading")}

+
+ ); + if (error) + return ( +
+

{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} +
+
+ )} + +
+

{t("validators.currentValidators")}

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

{t("validators.noValidators")}

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

{t("validators.delinquentValidators")}

+ {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..1c2c9b9b 100644 --- a/src/services/DataService.ts +++ b/src/services/DataService.ts @@ -3,22 +3,26 @@ import { type SupportedChainId, ClientFactory, BitcoinClient } from "@openscan/n 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 { ISolanaClient } from "./adapters/SolanaAdapter/SolanaClientTypes"; 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, @@ -39,6 +43,14 @@ export class DataService { // 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 + // TODO: Once @openscan/network-connectors publishes Solana support, use ClientFactory: + // const solanaClient = ClientFactory.createTypedClient(network.networkId, { rpcUrls, type: strategy }); + // For now, create a minimal JSON-RPC client that implements ISolanaClient + const solanaClient = createSolanaJsonRpcClient(rpcUrls); + this.solanaAdapter = AdapterFactory.createSolanaAdapter(network.networkId, solanaClient); + this.networkAdapter = null as unknown as NetworkAdapter; } else { // Create EVM client and adapter const chainId = getChainIdFromNetwork(network) as SupportedChainId; @@ -64,6 +76,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 +102,96 @@ 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; + } +} + +/** + * Temporary Solana client factory until @openscan/network-connectors publishes Solana support. + * Creates a minimal JSON-RPC client that implements ISolanaClient. + */ +function createSolanaJsonRpcClient(rpcUrls: string[]): ISolanaClient { + const rpcUrl = rpcUrls[0] ?? ""; + + async function rpcCall( + method: string, + params: unknown[] = [], + ): Promise<{ data: T; metadata?: undefined }> { + const response = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method, + params, + }), + }); + const json = await response.json(); + if (json.error) { + throw new Error(json.error.message || `RPC error: ${method}`); + } + return { data: json.result as T }; + } + + const client: ISolanaClient = { + getAccountInfo: (pubkey, config) => + rpcCall("getAccountInfo", config ? [pubkey, config] : [pubkey]), + getBalance: (pubkey, config) => rpcCall("getBalance", config ? [pubkey, config] : [pubkey]), + getBlock: (slot, config) => rpcCall("getBlock", config ? [slot, config] : [slot]), + getBlockHeight: (commitment) => rpcCall("getBlockHeight", commitment ? [{ commitment }] : []), + getBlocks: (startSlot, endSlot, commitment) => { + // biome-ignore lint/suspicious/noExplicitAny: params built conditionally + const params: any[] = [startSlot]; + if (endSlot !== undefined) params.push(endSlot); + if (commitment) params.push({ commitment }); + return rpcCall("getBlocks", params); + }, + getBlocksWithLimit: (startSlot, limit, commitment) => { + // biome-ignore lint/suspicious/noExplicitAny: params built conditionally + const params: any[] = [startSlot, limit]; + if (commitment) params.push({ commitment }); + return rpcCall("getBlocksWithLimit", params); + }, + getBlockTime: (slot) => rpcCall("getBlockTime", [slot]), + getSlot: (commitment) => rpcCall("getSlot", commitment ? [{ commitment }] : []), + getTransaction: (signature, config) => + rpcCall("getTransaction", config ? [signature, config] : [signature]), + getSignaturesForAddress: (address, config) => + rpcCall("getSignaturesForAddress", config ? [address, config] : [address]), + getTokenAccountsByOwner: (owner, filter, config) => + rpcCall("getTokenAccountsByOwner", config ? [owner, filter, config] : [owner, filter]), + getTokenSupply: (mint, commitment) => + rpcCall("getTokenSupply", commitment ? [mint, { commitment }] : [mint]), + getTokenLargestAccounts: (mint, commitment) => + rpcCall("getTokenLargestAccounts", commitment ? [mint, { commitment }] : [mint]), + getEpochInfo: (commitment) => rpcCall("getEpochInfo", commitment ? [{ commitment }] : []), + getVoteAccounts: (config) => rpcCall("getVoteAccounts", config ? [config] : []), + getVersion: () => rpcCall("getVersion"), + getSlotLeader: (commitment) => rpcCall("getSlotLeader", commitment ? [{ commitment }] : []), + getLeaderSchedule: (slot, config) => { + // biome-ignore lint/suspicious/noExplicitAny: params built conditionally + const params: any[] = []; + if (slot !== undefined && slot !== null) params.push(slot); + else params.push(null); + if (config) params.push(config); + return rpcCall("getLeaderSchedule", params); + }, + getTransactionCount: (commitment) => + rpcCall("getTransactionCount", commitment ? [{ commitment }] : []), + getRecentPerformanceSamples: (limit) => + rpcCall("getRecentPerformanceSamples", limit !== undefined ? [limit] : []), + getRecentPrioritizationFees: (addresses) => + rpcCall("getRecentPrioritizationFees", addresses ? [addresses] : []), + }; + + return client; } diff --git a/src/services/adapters/SolanaAdapter/SolanaAdapter.ts b/src/services/adapters/SolanaAdapter/SolanaAdapter.ts new file mode 100644 index 00000000..6e8c40d1 --- /dev/null +++ b/src/services/adapters/SolanaAdapter/SolanaAdapter.ts @@ -0,0 +1,488 @@ +import type { + DataWithMetadata, + SolanaAccount, + SolanaBlock, + SolanaEpochInfo, + SolanaInnerInstruction, + SolanaInstruction, + SolanaLeaderSchedule, + SolanaNetworkStats, + SolanaReward, + SolanaSignatureInfo, + SolanaTokenAmount, + SolanaTokenHolding, + SolanaTokenLargestAccount, + SolanaTransaction, + SolanaValidator, +} from "../../../types"; +import type { + ISolanaClient, + SolBlock, + SolParsedAccountKey, + SolTransaction, +} from "./SolanaClientTypes"; + +// 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: ISolanaClient; + + constructor(networkId: string, client: ISolanaClient) { + 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; + 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[] = []; + + const processAccounts = ( + // biome-ignore lint/suspicious/noExplicitAny: RPC response varies + result: { data: { value: any[] } } | null, + ) => { + if (!result?.data?.value) return; + for (const tokenAccount of result.data.value) { + const parsed = tokenAccount.account?.data?.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; + + 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/SolanaAdapter/SolanaClientTypes.ts b/src/services/adapters/SolanaAdapter/SolanaClientTypes.ts new file mode 100644 index 00000000..874965d7 --- /dev/null +++ b/src/services/adapters/SolanaAdapter/SolanaClientTypes.ts @@ -0,0 +1,260 @@ +/** + * Temporary type stubs for SolanaClient until @openscan/network-connectors publishes Solana support. + * These types mirror the SolanaClient API from network-connectors PR #25. + * Once the package is published, delete this file and import from @openscan/network-connectors. + */ + +// biome-ignore lint/suspicious/noExplicitAny: stub types for unpublished package +type StrategyResult = { data: T; metadata?: any }; + +export type Commitment = "processed" | "confirmed" | "finalized"; + +export interface SolRpcResponse { + context: { slot: number; apiVersion?: string }; + value: T; +} + +export interface SolAccountInfo { + lamports: number; + owner: string; + // biome-ignore lint/suspicious/noExplicitAny: account data varies + data: string | [string, string] | { program: string; parsed: any; space: number }; + executable: boolean; + rentEpoch: number; + space?: number; +} + +export interface SolBlock { + blockhash: string; + previousBlockhash: string; + parentSlot: number; + // biome-ignore lint/suspicious/noExplicitAny: transaction format varies + transactions?: any[]; + signatures?: string[]; + rewards?: SolReward[]; + blockTime: number | null; + blockHeight: number | null; +} + +export interface SolReward { + pubkey: string; + lamports: number; + postBalance: number; + rewardType: "fee" | "rent" | "staking" | "voting" | null; + commission?: number | null; +} + +export interface SolTransaction { + slot: number; + transaction: { + signatures: string[]; + message: { + accountKeys: string[] | SolParsedAccountKey[]; + recentBlockhash: string; + // biome-ignore lint/suspicious/noExplicitAny: instruction formats vary + instructions: any[]; + addressTableLookups?: { + accountKey: string; + writableIndexes: number[]; + readonlyIndexes: number[]; + }[]; + }; + }; + meta: SolTransactionMeta | null; + blockTime: number | null; + version?: "legacy" | 0; +} + +export interface SolParsedAccountKey { + pubkey: string; + writable: boolean; + signer: boolean; + source?: "transaction" | "lookupTable"; +} + +export interface SolTransactionMeta { + // biome-ignore lint/suspicious/noExplicitAny: error format varies + err: any; + fee: number; + preBalances: number[]; + postBalances: number[]; + // biome-ignore lint/suspicious/noExplicitAny: instruction formats vary + innerInstructions: { index: number; instructions: any[] }[] | null; + logMessages: string[] | null; + preTokenBalances?: SolTokenBalance[]; + postTokenBalances?: SolTokenBalance[]; + rewards?: SolReward[] | null; + loadedAddresses?: { writable: string[]; readonly: string[] }; + returnData?: { programId: string; data: [string, string] } | null; + computeUnitsConsumed?: number; +} + +export interface SolTokenBalance { + accountIndex: number; + mint: string; + uiTokenAmount: SolTokenAmount; + owner?: string; + programId?: string; +} + +export interface SolTokenAmount { + amount: string; + decimals: number; + uiAmount: number | null; + uiAmountString: string; +} + +export interface SolTokenAccount { + account: SolAccountInfo; + pubkey: string; +} + +export interface SolTokenLargestAccount { + address: string; + amount: string; + decimals: number; + uiAmount: number | null; + uiAmountString: string; +} + +export interface SolEpochInfo { + absoluteSlot: number; + blockHeight: number; + epoch: number; + slotIndex: number; + slotsInEpoch: number; + transactionCount?: number; +} + +export interface SolVoteAccount { + votePubkey: string; + nodePubkey: string; + activatedStake: number; + epochVoteAccount: boolean; + commission: number; + lastVote: number; + epochCredits: [number, number, number][]; + rootSlot?: number; +} + +export interface SolSignatureInfo { + signature: string; + slot: number; + // biome-ignore lint/suspicious/noExplicitAny: error format varies + err: any; + memo: string | null; + blockTime: number | null; + confirmationStatus: Commitment | null; +} + +export interface SolVersion { + "solana-core": string; + "feature-set": number; +} + +export interface SolPerfSample { + slot: number; + numTransactions: number; + numSlots: number; + samplePeriodSecs: number; + numNonVoteTransactions?: number; +} + +export type SolLeaderSchedule = Record; + +/** + * Minimal SolanaClient interface matching the API from network-connectors PR #25. + * Replace with import from @openscan/network-connectors once published. + */ +export interface ISolanaClient { + getAccountInfo( + pubkey: string, + config?: { commitment?: Commitment; encoding?: string }, + ): Promise>>; + + getBalance( + pubkey: string, + config?: { commitment?: Commitment }, + ): Promise>>; + + getBlock( + slot: number, + config?: { + encoding?: string; + transactionDetails?: string; + rewards?: boolean; + commitment?: Commitment; + }, + ): Promise>; + + getBlockHeight(commitment?: Commitment): Promise>; + + getBlocks( + startSlot: number, + endSlot?: number, + commitment?: Commitment, + ): Promise>; + + getBlocksWithLimit( + startSlot: number, + limit: number, + commitment?: Commitment, + ): Promise>; + + getBlockTime(slot: number): Promise>; + + getSlot(commitment?: Commitment): Promise>; + + getTransaction( + signature: string, + config?: { + encoding?: string; + commitment?: Commitment; + maxSupportedTransactionVersion?: number; + }, + ): Promise>; + + getSignaturesForAddress( + address: string, + config?: { limit?: number; before?: string; until?: string; commitment?: Commitment }, + ): Promise>; + + getTokenAccountsByOwner( + owner: string, + filter: { mint?: string; programId?: string }, + config?: { encoding?: string; commitment?: Commitment }, + ): Promise>>; + + getTokenSupply( + mint: string, + commitment?: Commitment, + ): Promise>>; + + getTokenLargestAccounts( + mint: string, + commitment?: Commitment, + ): Promise>>; + + getEpochInfo(commitment?: Commitment): Promise>; + + getVoteAccounts(config?: { + commitment?: Commitment; + }): Promise>; + + getVersion(): Promise>; + + getSlotLeader(commitment?: Commitment): Promise>; + + getLeaderSchedule( + slot?: number | null, + config?: { commitment?: Commitment; identity?: string }, + ): Promise>; + + getTransactionCount(commitment?: Commitment): Promise>; + + getRecentPerformanceSamples(limit?: number): Promise>; + + getRecentPrioritizationFees( + addresses?: string[], + ): Promise>; +} diff --git a/src/services/adapters/adaptersFactory.ts b/src/services/adapters/adaptersFactory.ts index 956d85a1..dbbd37ca 100644 --- a/src/services/adapters/adaptersFactory.ts +++ b/src/services/adapters/adaptersFactory.ts @@ -7,6 +7,8 @@ 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 { ISolanaClient } from "./SolanaAdapter/SolanaClientTypes"; import type { ArbitrumClient, AvalancheClient, @@ -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: ISolanaClient): SolanaAdapter { + return new SolanaAdapter(networkId, client); + } } 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/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`; +} From 89f272178defc35ef03d89e0d0fddcb47de34ed6 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 6 Apr 2026 17:56:53 -0300 Subject: [PATCH 02/15] chore(solana): wire real SolanaClient from network-connectors 1.7 Bumps @openscan/network-connectors to 1.7 (which now ships SolanaClient), deletes the local SolanaClientTypes stub, replaces the inline JSON-RPC client in DataService with ClientFactory.createTypedClient, and updates SolanaAdapter and adaptersFactory to use the real types. --- package.json | 2 +- src/services/DataService.ts | 102 +------ .../adapters/SolanaAdapter/SolanaAdapter.ts | 34 ++- .../SolanaAdapter/SolanaClientTypes.ts | 260 ------------------ src/services/adapters/adaptersFactory.ts | 4 +- 5 files changed, 36 insertions(+), 366 deletions(-) delete mode 100644 src/services/adapters/SolanaAdapter/SolanaClientTypes.ts diff --git a/package.json b/package.json index 75eb1cc1..f8a2ccce 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "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/services/DataService.ts b/src/services/DataService.ts index 1c2c9b9b..29c26b5a 100644 --- a/src/services/DataService.ts +++ b/src/services/DataService.ts @@ -1,10 +1,14 @@ -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 { ISolanaClient } from "./adapters/SolanaAdapter/SolanaClientTypes"; import type { NetworkConfig, RpcUrlsContextType } from "../types"; import { getRPCUrls } from "../config/rpcConfig"; import { getNetworkRpcKey, getChainIdFromNetwork } from "../utils/networkResolver"; @@ -41,14 +45,14 @@ 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 - // TODO: Once @openscan/network-connectors publishes Solana support, use ClientFactory: - // const solanaClient = ClientFactory.createTypedClient(network.networkId, { rpcUrls, type: strategy }); - // For now, create a minimal JSON-RPC client that implements ISolanaClient - const solanaClient = createSolanaJsonRpcClient(rpcUrls); + // 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 { @@ -113,85 +117,3 @@ export class DataService { return this.solanaAdapter; } } - -/** - * Temporary Solana client factory until @openscan/network-connectors publishes Solana support. - * Creates a minimal JSON-RPC client that implements ISolanaClient. - */ -function createSolanaJsonRpcClient(rpcUrls: string[]): ISolanaClient { - const rpcUrl = rpcUrls[0] ?? ""; - - async function rpcCall( - method: string, - params: unknown[] = [], - ): Promise<{ data: T; metadata?: undefined }> { - const response = await fetch(rpcUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method, - params, - }), - }); - const json = await response.json(); - if (json.error) { - throw new Error(json.error.message || `RPC error: ${method}`); - } - return { data: json.result as T }; - } - - const client: ISolanaClient = { - getAccountInfo: (pubkey, config) => - rpcCall("getAccountInfo", config ? [pubkey, config] : [pubkey]), - getBalance: (pubkey, config) => rpcCall("getBalance", config ? [pubkey, config] : [pubkey]), - getBlock: (slot, config) => rpcCall("getBlock", config ? [slot, config] : [slot]), - getBlockHeight: (commitment) => rpcCall("getBlockHeight", commitment ? [{ commitment }] : []), - getBlocks: (startSlot, endSlot, commitment) => { - // biome-ignore lint/suspicious/noExplicitAny: params built conditionally - const params: any[] = [startSlot]; - if (endSlot !== undefined) params.push(endSlot); - if (commitment) params.push({ commitment }); - return rpcCall("getBlocks", params); - }, - getBlocksWithLimit: (startSlot, limit, commitment) => { - // biome-ignore lint/suspicious/noExplicitAny: params built conditionally - const params: any[] = [startSlot, limit]; - if (commitment) params.push({ commitment }); - return rpcCall("getBlocksWithLimit", params); - }, - getBlockTime: (slot) => rpcCall("getBlockTime", [slot]), - getSlot: (commitment) => rpcCall("getSlot", commitment ? [{ commitment }] : []), - getTransaction: (signature, config) => - rpcCall("getTransaction", config ? [signature, config] : [signature]), - getSignaturesForAddress: (address, config) => - rpcCall("getSignaturesForAddress", config ? [address, config] : [address]), - getTokenAccountsByOwner: (owner, filter, config) => - rpcCall("getTokenAccountsByOwner", config ? [owner, filter, config] : [owner, filter]), - getTokenSupply: (mint, commitment) => - rpcCall("getTokenSupply", commitment ? [mint, { commitment }] : [mint]), - getTokenLargestAccounts: (mint, commitment) => - rpcCall("getTokenLargestAccounts", commitment ? [mint, { commitment }] : [mint]), - getEpochInfo: (commitment) => rpcCall("getEpochInfo", commitment ? [{ commitment }] : []), - getVoteAccounts: (config) => rpcCall("getVoteAccounts", config ? [config] : []), - getVersion: () => rpcCall("getVersion"), - getSlotLeader: (commitment) => rpcCall("getSlotLeader", commitment ? [{ commitment }] : []), - getLeaderSchedule: (slot, config) => { - // biome-ignore lint/suspicious/noExplicitAny: params built conditionally - const params: any[] = []; - if (slot !== undefined && slot !== null) params.push(slot); - else params.push(null); - if (config) params.push(config); - return rpcCall("getLeaderSchedule", params); - }, - getTransactionCount: (commitment) => - rpcCall("getTransactionCount", commitment ? [{ commitment }] : []), - getRecentPerformanceSamples: (limit) => - rpcCall("getRecentPerformanceSamples", limit !== undefined ? [limit] : []), - getRecentPrioritizationFees: (addresses) => - rpcCall("getRecentPrioritizationFees", addresses ? [addresses] : []), - }; - - return client; -} diff --git a/src/services/adapters/SolanaAdapter/SolanaAdapter.ts b/src/services/adapters/SolanaAdapter/SolanaAdapter.ts index 6e8c40d1..cf4d36eb 100644 --- a/src/services/adapters/SolanaAdapter/SolanaAdapter.ts +++ b/src/services/adapters/SolanaAdapter/SolanaAdapter.ts @@ -15,12 +15,15 @@ import type { SolanaTransaction, SolanaValidator, } from "../../../types"; -import type { - ISolanaClient, - SolBlock, - SolParsedAccountKey, - SolTransaction, -} from "./SolanaClientTypes"; +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"; @@ -33,9 +36,9 @@ const TOKEN_2022_PROGRAM_ID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"; */ export class SolanaAdapter { readonly networkId: string; - private client: ISolanaClient; + private client: SolanaClient; - constructor(networkId: string, client: ISolanaClient) { + constructor(networkId: string, client: SolanaClient) { this.networkId = networkId; this.client = client; } @@ -85,6 +88,9 @@ export class SolanaAdapter { 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, @@ -226,13 +232,12 @@ export class SolanaAdapter { const holdings: SolanaTokenHolding[] = []; - const processAccounts = ( - // biome-ignore lint/suspicious/noExplicitAny: RPC response varies - result: { data: { value: any[] } } | null, - ) => { + // 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) { - const parsed = tokenAccount.account?.data?.parsed?.info; + // biome-ignore lint/suspicious/noExplicitAny: parsed data varies + const parsed = (tokenAccount.account?.data as any)?.parsed?.info; if (!parsed) continue; holdings.push({ @@ -294,6 +299,9 @@ export class SolanaAdapter { }> { const result = await this.client.getVoteAccounts({ commitment: "finalized" }); const data = result.data; + if (!data) { + return { current: [], delinquent: [] }; + } const mapValidator = (v: { votePubkey: string; diff --git a/src/services/adapters/SolanaAdapter/SolanaClientTypes.ts b/src/services/adapters/SolanaAdapter/SolanaClientTypes.ts deleted file mode 100644 index 874965d7..00000000 --- a/src/services/adapters/SolanaAdapter/SolanaClientTypes.ts +++ /dev/null @@ -1,260 +0,0 @@ -/** - * Temporary type stubs for SolanaClient until @openscan/network-connectors publishes Solana support. - * These types mirror the SolanaClient API from network-connectors PR #25. - * Once the package is published, delete this file and import from @openscan/network-connectors. - */ - -// biome-ignore lint/suspicious/noExplicitAny: stub types for unpublished package -type StrategyResult = { data: T; metadata?: any }; - -export type Commitment = "processed" | "confirmed" | "finalized"; - -export interface SolRpcResponse { - context: { slot: number; apiVersion?: string }; - value: T; -} - -export interface SolAccountInfo { - lamports: number; - owner: string; - // biome-ignore lint/suspicious/noExplicitAny: account data varies - data: string | [string, string] | { program: string; parsed: any; space: number }; - executable: boolean; - rentEpoch: number; - space?: number; -} - -export interface SolBlock { - blockhash: string; - previousBlockhash: string; - parentSlot: number; - // biome-ignore lint/suspicious/noExplicitAny: transaction format varies - transactions?: any[]; - signatures?: string[]; - rewards?: SolReward[]; - blockTime: number | null; - blockHeight: number | null; -} - -export interface SolReward { - pubkey: string; - lamports: number; - postBalance: number; - rewardType: "fee" | "rent" | "staking" | "voting" | null; - commission?: number | null; -} - -export interface SolTransaction { - slot: number; - transaction: { - signatures: string[]; - message: { - accountKeys: string[] | SolParsedAccountKey[]; - recentBlockhash: string; - // biome-ignore lint/suspicious/noExplicitAny: instruction formats vary - instructions: any[]; - addressTableLookups?: { - accountKey: string; - writableIndexes: number[]; - readonlyIndexes: number[]; - }[]; - }; - }; - meta: SolTransactionMeta | null; - blockTime: number | null; - version?: "legacy" | 0; -} - -export interface SolParsedAccountKey { - pubkey: string; - writable: boolean; - signer: boolean; - source?: "transaction" | "lookupTable"; -} - -export interface SolTransactionMeta { - // biome-ignore lint/suspicious/noExplicitAny: error format varies - err: any; - fee: number; - preBalances: number[]; - postBalances: number[]; - // biome-ignore lint/suspicious/noExplicitAny: instruction formats vary - innerInstructions: { index: number; instructions: any[] }[] | null; - logMessages: string[] | null; - preTokenBalances?: SolTokenBalance[]; - postTokenBalances?: SolTokenBalance[]; - rewards?: SolReward[] | null; - loadedAddresses?: { writable: string[]; readonly: string[] }; - returnData?: { programId: string; data: [string, string] } | null; - computeUnitsConsumed?: number; -} - -export interface SolTokenBalance { - accountIndex: number; - mint: string; - uiTokenAmount: SolTokenAmount; - owner?: string; - programId?: string; -} - -export interface SolTokenAmount { - amount: string; - decimals: number; - uiAmount: number | null; - uiAmountString: string; -} - -export interface SolTokenAccount { - account: SolAccountInfo; - pubkey: string; -} - -export interface SolTokenLargestAccount { - address: string; - amount: string; - decimals: number; - uiAmount: number | null; - uiAmountString: string; -} - -export interface SolEpochInfo { - absoluteSlot: number; - blockHeight: number; - epoch: number; - slotIndex: number; - slotsInEpoch: number; - transactionCount?: number; -} - -export interface SolVoteAccount { - votePubkey: string; - nodePubkey: string; - activatedStake: number; - epochVoteAccount: boolean; - commission: number; - lastVote: number; - epochCredits: [number, number, number][]; - rootSlot?: number; -} - -export interface SolSignatureInfo { - signature: string; - slot: number; - // biome-ignore lint/suspicious/noExplicitAny: error format varies - err: any; - memo: string | null; - blockTime: number | null; - confirmationStatus: Commitment | null; -} - -export interface SolVersion { - "solana-core": string; - "feature-set": number; -} - -export interface SolPerfSample { - slot: number; - numTransactions: number; - numSlots: number; - samplePeriodSecs: number; - numNonVoteTransactions?: number; -} - -export type SolLeaderSchedule = Record; - -/** - * Minimal SolanaClient interface matching the API from network-connectors PR #25. - * Replace with import from @openscan/network-connectors once published. - */ -export interface ISolanaClient { - getAccountInfo( - pubkey: string, - config?: { commitment?: Commitment; encoding?: string }, - ): Promise>>; - - getBalance( - pubkey: string, - config?: { commitment?: Commitment }, - ): Promise>>; - - getBlock( - slot: number, - config?: { - encoding?: string; - transactionDetails?: string; - rewards?: boolean; - commitment?: Commitment; - }, - ): Promise>; - - getBlockHeight(commitment?: Commitment): Promise>; - - getBlocks( - startSlot: number, - endSlot?: number, - commitment?: Commitment, - ): Promise>; - - getBlocksWithLimit( - startSlot: number, - limit: number, - commitment?: Commitment, - ): Promise>; - - getBlockTime(slot: number): Promise>; - - getSlot(commitment?: Commitment): Promise>; - - getTransaction( - signature: string, - config?: { - encoding?: string; - commitment?: Commitment; - maxSupportedTransactionVersion?: number; - }, - ): Promise>; - - getSignaturesForAddress( - address: string, - config?: { limit?: number; before?: string; until?: string; commitment?: Commitment }, - ): Promise>; - - getTokenAccountsByOwner( - owner: string, - filter: { mint?: string; programId?: string }, - config?: { encoding?: string; commitment?: Commitment }, - ): Promise>>; - - getTokenSupply( - mint: string, - commitment?: Commitment, - ): Promise>>; - - getTokenLargestAccounts( - mint: string, - commitment?: Commitment, - ): Promise>>; - - getEpochInfo(commitment?: Commitment): Promise>; - - getVoteAccounts(config?: { - commitment?: Commitment; - }): Promise>; - - getVersion(): Promise>; - - getSlotLeader(commitment?: Commitment): Promise>; - - getLeaderSchedule( - slot?: number | null, - config?: { commitment?: Commitment; identity?: string }, - ): Promise>; - - getTransactionCount(commitment?: Commitment): Promise>; - - getRecentPerformanceSamples(limit?: number): Promise>; - - getRecentPrioritizationFees( - addresses?: string[], - ): Promise>; -} diff --git a/src/services/adapters/adaptersFactory.ts b/src/services/adapters/adaptersFactory.ts index dbbd37ca..d59f5737 100644 --- a/src/services/adapters/adaptersFactory.ts +++ b/src/services/adapters/adaptersFactory.ts @@ -8,7 +8,6 @@ import { ArbitrumAdapter } from "./ArbitrumAdapter/ArbitrumAdapter"; import { HardhatAdapter } from "./HardhatAdapter/HardhatAdapter"; import { BitcoinAdapter } from "./BitcoinAdapter/BitcoinAdapter"; import { SolanaAdapter } from "./SolanaAdapter/SolanaAdapter"; -import type { ISolanaClient } from "./SolanaAdapter/SolanaClientTypes"; import type { ArbitrumClient, AvalancheClient, @@ -20,6 +19,7 @@ import type { HardhatClient, OptimismClient, PolygonClient, + SolanaClient, SupportedChainId, } from "@openscan/network-connectors"; @@ -74,7 +74,7 @@ export class AdapterFactory { /** * Create a Solana network adapter */ - static createSolanaAdapter(networkId: string, client: ISolanaClient): SolanaAdapter { + static createSolanaAdapter(networkId: string, client: SolanaClient): SolanaAdapter { return new SolanaAdapter(networkId, client); } } From 87f5fbe7dd293acc32cbbc35c53d6a27b90feb06 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 6 Apr 2026 17:58:59 -0300 Subject: [PATCH 03/15] fix(solana): add default public RPCs and handle Solana in NetworkBlockIndicator - Add public Solana RPC endpoints (mainnet-beta, devnet, testnet) to BUILTIN_RPC_DEFAULTS so the app can connect out of the box - Add Solana branch to NetworkBlockIndicator so the navbar shows the current slot for Solana networks instead of crashing with "At least one RPC URL must be provided" --- src/components/navbar/NetworkBlockIndicator.tsx | 9 +++++++++ src/utils/rpcStorage.ts | 4 ++++ worker/.vercel/README.txt | 11 +++++++++++ worker/.vercel/project.json | 1 + 4 files changed, 25 insertions(+) create mode 100644 worker/.vercel/README.txt create mode 100644 worker/.vercel/project.json 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/utils/rpcStorage.ts b/src/utils/rpcStorage.ts index 647498ff..593eefd3 100644 --- a/src/utils/rpcStorage.ts +++ b/src/utils/rpcStorage.ts @@ -64,6 +64,10 @@ 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"], + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": ["https://api.devnet.solana.com"], + "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": ["https://api.testnet.solana.com"], }; interface MetadataRpcCache { diff --git a/worker/.vercel/README.txt b/worker/.vercel/README.txt new file mode 100644 index 00000000..525d8ce8 --- /dev/null +++ b/worker/.vercel/README.txt @@ -0,0 +1,11 @@ +> Why do I have a folder named ".vercel" in my project? +The ".vercel" folder is created when you link a directory to a Vercel project. + +> What does the "project.json" file contain? +The "project.json" file contains: +- The ID of the Vercel project that you linked ("projectId") +- The ID of the user or team your Vercel project is owned by ("orgId") + +> Should I commit the ".vercel" folder? +No, you should not share the ".vercel" folder with anyone. +Upon creation, it will be automatically added to your ".gitignore" file. diff --git a/worker/.vercel/project.json b/worker/.vercel/project.json new file mode 100644 index 00000000..ff04ed2f --- /dev/null +++ b/worker/.vercel/project.json @@ -0,0 +1 @@ +{"projectId":"prj_OLG5jjTbODJilSdl7eOHdlkfeGlk","orgId":"team_zwHZHbp0QW677pPJzbWJ16xr","projectName":"worker"} \ No newline at end of file From 98f9b8892a2da26366150469a6678adcedfcd523 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 6 Apr 2026 18:00:06 -0300 Subject: [PATCH 04/15] chore: remove accidentally committed worker/.vercel files --- worker/.vercel/README.txt | 11 ----------- worker/.vercel/project.json | 1 - 2 files changed, 12 deletions(-) delete mode 100644 worker/.vercel/README.txt delete mode 100644 worker/.vercel/project.json diff --git a/worker/.vercel/README.txt b/worker/.vercel/README.txt deleted file mode 100644 index 525d8ce8..00000000 --- a/worker/.vercel/README.txt +++ /dev/null @@ -1,11 +0,0 @@ -> Why do I have a folder named ".vercel" in my project? -The ".vercel" folder is created when you link a directory to a Vercel project. - -> What does the "project.json" file contain? -The "project.json" file contains: -- The ID of the Vercel project that you linked ("projectId") -- The ID of the user or team your Vercel project is owned by ("orgId") - -> Should I commit the ".vercel" folder? -No, you should not share the ".vercel" folder with anyone. -Upon creation, it will be automatically added to your ".gitignore" file. diff --git a/worker/.vercel/project.json b/worker/.vercel/project.json deleted file mode 100644 index ff04ed2f..00000000 --- a/worker/.vercel/project.json +++ /dev/null @@ -1 +0,0 @@ -{"projectId":"prj_OLG5jjTbODJilSdl7eOHdlkfeGlk","orgId":"team_zwHZHbp0QW677pPJzbWJ16xr","projectName":"worker"} \ No newline at end of file From 4a69e4d89a5fa4542bb9279cef45d17a7712c8f6 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 6 Apr 2026 18:02:42 -0300 Subject: [PATCH 05/15] feat(solana): add more public RPC endpoints for fallback Adds PublicNode, dRPC, Ankr, and Pocket Network as additional fallback RPCs for Solana mainnet, devnet, and testnet so the fallback strategy has more options when the official endpoints are rate-limited. --- src/utils/rpcStorage.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/utils/rpcStorage.ts b/src/utils/rpcStorage.ts index 593eefd3..5a66c614 100644 --- a/src/utils/rpcStorage.ts +++ b/src/utils/rpcStorage.ts @@ -65,9 +65,23 @@ const BUILTIN_RPC_DEFAULTS: RpcUrlsContextType = { `${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"], - "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": ["https://api.devnet.solana.com"], - "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": ["https://api.testnet.solana.com"], + "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 { From 8e414998b74d077989296a9cf25f81b4a6978e5e Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 6 Apr 2026 18:11:28 -0300 Subject: [PATCH 06/15] style(solana): rewrite page components to match BTC/EVM design system Refactors all Solana pages to use the same shared style classes and patterns established by the Bitcoin/EVM pages: - Wrap pages in container-wide / page-container-padded - Use Breadcrumb on every detail and list page - Use block-display-card, blocks-header, block-display-header, block-label, tx-details, tx-row, tx-value, tx-mono, tx-link - Use dash-table / table-wrapper / table-cell-* classes for tables - Use block-status-badge for status indicators - Use LoaderWithTimeout for loading states - Add CopyButton to addresses, signatures, mints, blockhashes - Skeleton placeholders match the Bitcoin loading pattern New display components extracted (mirroring BitcoinBlockDisplay pattern): - SolanaSlotDisplay - SolanaTransactionDisplay - SolanaAccountDisplay --- .../pages/solana/SolanaAccountDisplay.tsx | 181 ++++++++++++++ .../pages/solana/SolanaAccountPage.tsx | 184 +++++--------- .../pages/solana/SolanaSlotDisplay.tsx | 196 +++++++++++++++ .../pages/solana/SolanaSlotPage.tsx | 166 +++++------- .../pages/solana/SolanaSlotsPage.tsx | 150 ++++++++--- .../pages/solana/SolanaTokenPage.tsx | 199 +++++++++------ .../pages/solana/SolanaTransactionDisplay.tsx | 236 ++++++++++++++++++ .../pages/solana/SolanaTransactionPage.tsx | 214 ++++++---------- .../pages/solana/SolanaTransactionsPage.tsx | 156 +++++++++--- .../pages/solana/SolanaValidatorsPage.tsx | 191 +++++++++----- 10 files changed, 1310 insertions(+), 563 deletions(-) create mode 100644 src/components/pages/solana/SolanaAccountDisplay.tsx create mode 100644 src/components/pages/solana/SolanaSlotDisplay.tsx create mode 100644 src/components/pages/solana/SolanaTransactionDisplay.tsx diff --git a/src/components/pages/solana/SolanaAccountDisplay.tsx b/src/components/pages/solana/SolanaAccountDisplay.tsx new file mode 100644 index 00000000..e9761d67 --- /dev/null +++ b/src/components/pages/solana/SolanaAccountDisplay.tsx @@ -0,0 +1,181 @@ +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 */} +
+ {t("account.address")}: + + {account.address} + + +
+ + {/* Balance */} +
+ {t("account.balance")}: + {formatSol(account.lamports)} +
+ + {/* Owner */} +
+ {t("account.owner")}: + + + {shortenSolanaAddress(account.owner, 10, 10)} + + +
+ + {/* Executable */} +
+ {t("account.executable")}: + + {account.executable ? t("account.yes") : t("account.no")} + +
+ + {/* Data size */} +
+ {t("account.dataSize")}: + {account.space.toLocaleString()} bytes +
+ + {/* Rent epoch */} +
+ {t("account.rentEpoch")}: + {account.rentEpoch} +
+
+ + {/* 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 index f5e3bf4b..724b097d 100644 --- a/src/components/pages/solana/SolanaAccountPage.tsx +++ b/src/components/pages/solana/SolanaAccountPage.tsx @@ -1,20 +1,23 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Link, useLocation, useParams } from "react-router-dom"; +import { useLocation, useParams } from "react-router-dom"; +import { getNetworkBySlug } from "../../../config/networks"; import { useDataService } from "../../../hooks/useDataService"; -import { resolveNetwork } from "../../../utils/networkResolver"; -import { getAllNetworks } from "../../../config/networks"; import type { SolanaAccount, SolanaSignatureInfo } from "../../../types"; -import { formatSol, shortenSolanaAddress } from "../../../utils/solanaUtils"; +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 pathSlug = location.pathname.split("/")[1] || "sol"; - const network = resolveNetwork(pathSlug, getAllNetworks()); - const dataService = useDataService(network ?? pathSlug); + 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([]); @@ -22,11 +25,15 @@ export default function SolanaAccountPage() { const [error, setError] = useState(null); useEffect(() => { - let cancelled = false; + if (!dataService || !dataService.isSolana() || !address) { + setLoading(false); + return; + } - async function fetchAccount() { - if (!dataService || !dataService.isSolana() || !address) return; + let cancelled = false; + const fetchAccount = async () => { setLoading(true); + setError(null); try { const adapter = dataService.getSolanaAdapter(); const [accountResult, sigsResult] = await Promise.all([ @@ -36,14 +43,15 @@ export default function SolanaAccountPage() { if (!cancelled) { setAccount(accountResult.data); setSignatures(sigsResult); - setError(null); } } catch (err) { - if (!cancelled) setError(err instanceof Error ? err.message : "Failed to fetch account"); + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to fetch account"); + } } finally { if (!cancelled) setLoading(false); } - } + }; fetchAccount(); return () => { @@ -51,119 +59,63 @@ export default function SolanaAccountPage() { }; }, [dataService, address]); - if (loading) - return ( -
-

{t("common.loading")}

-
- ); - if (error) - return ( -
-

{error}

-
- ); - if (!account) - return ( -
-

{t("account.title")}

-
- ); + const breadcrumbItems = [ + { label: "Home", to: "/" }, + { label: networkLabel, to: `/${networkSlug}` }, + { label: t("account.title") }, + { label: address ? shortenSolanaAddress(address, 6, 6) : "" }, + ]; - return ( -
-
-

{account.executable ? t("account.program") : t("account.wallet")}

- -
-
- {t("account.address")}: - {account.address} -
-
- {t("account.balance")}: - {formatSol(account.lamports)} -
-
- {t("account.owner")}: - - {account.owner} - -
-
- {t("account.executable")}: - - {account.executable ? t("account.yes") : t("account.no")} + if (loading) { + return ( +
+ +
+
+ {t("account.title")} + + {address ? shortenSolanaAddress(address, 8, 8) : ""}
-
- {t("account.dataSize")}: - {account.space} bytes -
-
- {t("account.rentEpoch")}: - {account.rentEpoch} +
+ window.location.reload()} + />
+
+ ); + } - {account.tokenAccounts && account.tokenAccounts.length > 0 ? ( -
-

{t("account.tokenHoldings")}

- - - - - - - - - {account.tokenAccounts.map((holding) => ( - - - - - ))} - -
{t("token.mint")}{t("token.amount")}
- - {shortenSolanaAddress(holding.mint, 8, 8)} - - {holding.amount.uiAmountString}
+ if (error) { + return ( +
+ +
+
+ {t("account.title")}
- ) : ( -
-

{t("account.noTokens")}

+
+

Error: {error}

- )} +
+
+ ); + } - {signatures.length > 0 && ( -
-

{t("account.recentTransactions")}

- - - - - - - - - - {signatures.map((sig) => ( - - - - - - ))} - -
{t("transaction.signature")}{t("transaction.status")}{t("transaction.slot")}
- - {shortenSolanaAddress(sig.signature, 8, 6)} - - {sig.err ? t("transactions.failed") : t("transactions.success")}{sig.slot.toLocaleString()}
+ return ( +
+ + {account ? ( + + ) : ( +
+
+

Account not found

- )} -
+
+ )}
); } diff --git a/src/components/pages/solana/SolanaSlotDisplay.tsx b/src/components/pages/solana/SolanaSlotDisplay.tsx new file mode 100644 index 00000000..82910a18 --- /dev/null +++ b/src/components/pages/solana/SolanaSlotDisplay.tsx @@ -0,0 +1,196 @@ +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.slice(0, 100).map((sig) => ( + + + + ))} + +
{t("transaction.signature")}
+ + {shortenSolanaAddress(sig, 12, 12)} + +
+
+ )} +
+ )} +
+
+ ); +}); + +SolanaSlotDisplay.displayName = "SolanaSlotDisplay"; + +export default SolanaSlotDisplay; diff --git a/src/components/pages/solana/SolanaSlotPage.tsx b/src/components/pages/solana/SolanaSlotPage.tsx index 53925f9b..06951ac6 100644 --- a/src/components/pages/solana/SolanaSlotPage.tsx +++ b/src/components/pages/solana/SolanaSlotPage.tsx @@ -1,31 +1,37 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Link, useLocation, useParams } from "react-router-dom"; +import { useLocation, useParams } from "react-router-dom"; +import { getNetworkBySlug } from "../../../config/networks"; import { useDataService } from "../../../hooks/useDataService"; -import { resolveNetwork } from "../../../utils/networkResolver"; -import { getAllNetworks } from "../../../config/networks"; -import type { SolanaBlock } from "../../../types"; -import { formatBlockTime, formatSol, formatSlotNumber } from "../../../utils/solanaUtils"; +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 pathSlug = location.pathname.split("/")[1] || "sol"; - const network = resolveNetwork(pathSlug, getAllNetworks()); - const dataService = useDataService(network ?? pathSlug); + const networkSlug = location.pathname.split("/")[1] || "sol"; + const dataService = useDataService(networkSlug); + const networkConfig = getNetworkBySlug(networkSlug); + const networkLabel = networkConfig?.shortName || networkConfig?.name || networkSlug.toUpperCase(); - const [block, setBlock] = useState(null); + const [blockResult, setBlockResult] = useState | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { - let cancelled = false; + if (!dataService || !dataService.isSolana() || !filter) { + setLoading(false); + return; + } - async function fetchBlock() { - if (!dataService || !dataService.isSolana() || !filter) return; + let cancelled = false; + const fetchBlock = async () => { setLoading(true); + setError(null); try { const adapter = dataService.getSolanaAdapter(); const slot = Number(filter); @@ -33,10 +39,7 @@ export default function SolanaSlotPage() { throw new Error(`Invalid slot: ${filter}`); } const result = await adapter.getBlock(slot); - if (!cancelled) { - setBlock(result.data); - setError(null); - } + if (!cancelled) setBlockResult(result); } catch (err) { if (!cancelled) { setError(err instanceof Error ? err.message : "Failed to fetch block"); @@ -44,7 +47,7 @@ export default function SolanaSlotPage() { } finally { if (!cancelled) setLoading(false); } - } + }; fetchBlock(); return () => { @@ -52,100 +55,61 @@ export default function SolanaSlotPage() { }; }, [dataService, filter]); - if (loading) - return ( -
-

{t("common.loading")}

-
- ); - if (error) + const breadcrumbItems = [ + { label: "Home", to: "/" }, + { label: networkLabel, to: `/${networkSlug}` }, + { label: t("blocks.blocksTitle"), to: `/${networkSlug}/slots` }, + { label: `${t("block.title")} #${filter}` }, + ]; + + if (loading) { return ( -
-

{error}

+
+ +
+
+ {t("block.title")} + #{filter} +
+
+ window.location.reload()} + /> +
+
); - if (!block) + } + + if (error) { return ( -
-

{t("blocks.noBlocks")}

+
+ +
+
+ {t("block.title")} +
+
+

Error: {error}

+
+
); + } return ( -
-
-

- {t("block.title")} #{formatSlotNumber(block.slot)} -

- -
-
- {t("block.blockHash")}: - {block.blockhash} -
-
- {t("block.previousBlockhash")}: - {block.previousBlockhash} -
-
- {t("block.parentSlot")}: - - - #{formatSlotNumber(block.parentSlot)} - - -
-
- {t("block.blockHeight")}: - - {block.blockHeight !== null ? formatSlotNumber(block.blockHeight) : "—"} - -
-
- {t("block.blockTime")}: - {formatBlockTime(block.blockTime)} -
-
- {t("block.transactionCount")}: - {block.transactionCount} +
+ + {blockResult?.data ? ( + + ) : ( +
+
+

{t("blocks.noBlocks")}

- - {block.rewards.length > 0 && ( -
-

{t("block.rewards")}

- - - - - - - - - {block.rewards.map((reward) => ( - - - - - ))} - -
{t("block.rewardType")}{t("block.amount")}
{reward.rewardType ?? "—"}{formatSol(reward.lamports)}
-
- )} - - {block.signatures && block.signatures.length > 0 && ( -
-

{t("block.transactions")}

-
    - {block.signatures.slice(0, 50).map((sig) => ( -
  • - {sig} -
  • - ))} -
-
- )} -
+ )}
); } diff --git a/src/components/pages/solana/SolanaSlotsPage.tsx b/src/components/pages/solana/SolanaSlotsPage.tsx index dcfc5555..e338a9ff 100644 --- a/src/components/pages/solana/SolanaSlotsPage.tsx +++ b/src/components/pages/solana/SolanaSlotsPage.tsx @@ -1,45 +1,60 @@ 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 { resolveNetwork } from "../../../utils/networkResolver"; -import { getAllNetworks } from "../../../config/networks"; 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 pathSlug = location.pathname.split("/")[1] || "sol"; - const network = resolveNetwork(pathSlug, getAllNetworks()); - const dataService = useDataService(network ?? pathSlug); + 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(() => { - let cancelled = false; + if (!dataService || !dataService.isSolana()) { + setLoading(false); + return; + } - async function fetchBlocks() { - if (!dataService || !dataService.isSolana()) return; + let cancelled = false; + const fetchBlocks = async () => { setLoading(true); + setError(null); try { const adapter = dataService.getSolanaAdapter(); - const result = await adapter.getLatestBlocks(25); - if (!cancelled) { - setBlocks(result); - setError(null); - } + 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 () => { @@ -47,43 +62,118 @@ export default function SolanaSlotsPage() { }; }, [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.blocksTitle")}

- {error &&

{error}

} - {loading && blocks.length === 0 ? ( -

{t("common.loading")}

- ) : blocks.length === 0 ? ( -

{t("blocks.noBlocks")}

- ) : ( - +
+
+ {t("blocks.title")} + + Showing {blocks.length} most recent blocks +
+
+ +
+
- + {blocks.map((block) => ( - - - + + ))}
{t("blocks.slot")} {t("blocks.blockHash")}{t("blocks.txCount")} {t("blocks.time")}{t("blocks.txCount")}
- - #{formatSlotNumber(block.slot)} + + {formatSlotNumber(block.slot)} {shortenSolanaAddress(block.blockhash, 8, 8)}{block.transactionCount} - {block.blockTime ? new Date(block.blockTime * 1000).toLocaleString() : "—"} + + + {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 index c3af04e2..9dbf8d4e 100644 --- a/src/components/pages/solana/SolanaTokenPage.tsx +++ b/src/components/pages/solana/SolanaTokenPage.tsx @@ -1,20 +1,23 @@ 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 { resolveNetwork } from "../../../utils/networkResolver"; -import { getAllNetworks } from "../../../config/networks"; 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 pathSlug = location.pathname.split("/")[1] || "sol"; - const network = resolveNetwork(pathSlug, getAllNetworks()); - const dataService = useDataService(network ?? pathSlug); + 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([]); @@ -22,11 +25,15 @@ export default function SolanaTokenPage() { const [error, setError] = useState(null); useEffect(() => { - let cancelled = false; + if (!dataService || !dataService.isSolana() || !mint) { + setLoading(false); + return; + } - async function fetchToken() { - if (!dataService || !dataService.isSolana() || !mint) return; + let cancelled = false; + const fetchToken = async () => { setLoading(true); + setError(null); try { const adapter = dataService.getSolanaAdapter(); const [supplyResult, holdersResult] = await Promise.all([ @@ -36,14 +43,15 @@ export default function SolanaTokenPage() { if (!cancelled) { setSupply(supplyResult); setHolders(holdersResult); - setError(null); } } catch (err) { - if (!cancelled) setError(err instanceof Error ? err.message : "Failed to fetch token"); + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to fetch token"); + } } finally { if (!cancelled) setLoading(false); } - } + }; fetchToken(); return () => { @@ -51,80 +59,131 @@ export default function SolanaTokenPage() { }; }, [dataService, mint]); - if (loading) + const breadcrumbItems = [ + { label: "Home", to: "/" }, + { label: networkLabel, to: `/${networkSlug}` }, + { label: t("token.title") }, + { label: mint ? shortenSolanaAddress(mint, 6, 6) : "" }, + ]; + + if (loading) { return ( -
-

{t("common.loading")}

+
+ +
+
+ {t("token.title")} +
+
+ window.location.reload()} + /> +
+
); - if (error) + } + + if (error) { return ( -
-

{error}

+
+ +
+
+ {t("token.title")} +
+
+

Error: {error}

+
+
); + } const totalSupplyNum = supply ? Number(supply.amount) : 0; return ( -
-
-

{t("token.title")}

+
+ +
+
+
+ {t("token.title")} + SPL +
-
-
- {t("token.mint")}: - {mint} +
+
+ {t("token.mint")}: + + {mint} + {mint && } + +
+ {supply && ( + <> +
+ {t("token.totalSupply")}: + {supply.uiAmountString} +
+
+ {t("token.decimals")}: + {supply.decimals} +
+ + )}
- {supply && ( - <> -
- {t("token.totalSupply")}: - {supply.uiAmountString} -
-
- {t("token.decimals")}: - {supply.decimals} -
- - )} -
- {holders.length > 0 ? ( -
-

{t("token.topHolders")}

- - - - - - - - - - - {holders.map((holder, idx) => { - const pct = - totalSupplyNum > 0 ? (Number(holder.amount) / totalSupplyNum) * 100 : 0; - return ( - - - - - +
+

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

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

{t("token.noHolders")}

+
+ )}
- ) : ( -

{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..32c51092 --- /dev/null +++ b/src/components/pages/solana/SolanaTransactionDisplay.tsx @@ -0,0 +1,236 @@ +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.join("\n")}
} +
+ )} +
+
+ ); + }, +); + +SolanaTransactionDisplay.displayName = "SolanaTransactionDisplay"; + +export default SolanaTransactionDisplay; diff --git a/src/components/pages/solana/SolanaTransactionPage.tsx b/src/components/pages/solana/SolanaTransactionPage.tsx index aa58c689..9213f1fc 100644 --- a/src/components/pages/solana/SolanaTransactionPage.tsx +++ b/src/components/pages/solana/SolanaTransactionPage.tsx @@ -1,45 +1,50 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Link, useLocation, useParams } from "react-router-dom"; +import { useLocation, useParams } from "react-router-dom"; +import { getNetworkBySlug } from "../../../config/networks"; import { useDataService } from "../../../hooks/useDataService"; -import { resolveNetwork } from "../../../utils/networkResolver"; -import { getAllNetworks } from "../../../config/networks"; -import type { SolanaTransaction } from "../../../types"; -import { formatBlockTime, formatSol, formatSlotNumber } from "../../../utils/solanaUtils"; +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 pathSlug = location.pathname.split("/")[1] || "sol"; - const network = resolveNetwork(pathSlug, getAllNetworks()); - const dataService = useDataService(network ?? pathSlug); + const networkSlug = location.pathname.split("/")[1] || "sol"; + const dataService = useDataService(networkSlug); + const networkConfig = getNetworkBySlug(networkSlug); + const networkLabel = networkConfig?.shortName || networkConfig?.name || networkSlug.toUpperCase(); - const [tx, setTx] = useState(null); + const [txResult, setTxResult] = useState | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { - let cancelled = false; + if (!dataService || !dataService.isSolana() || !signature) { + setLoading(false); + return; + } - async function fetchTx() { - if (!dataService || !dataService.isSolana() || !signature) return; + let cancelled = false; + const fetchTx = async () => { setLoading(true); + setError(null); try { const adapter = dataService.getSolanaAdapter(); const result = await adapter.getTransaction(signature); - if (!cancelled) { - setTx(result.data); - setError(null); - } + if (!cancelled) setTxResult(result); } catch (err) { - if (!cancelled) + if (!cancelled) { setError(err instanceof Error ? err.message : "Failed to fetch transaction"); + } } finally { if (!cancelled) setLoading(false); } - } + }; fetchTx(); return () => { @@ -47,142 +52,63 @@ export default function SolanaTransactionPage() { }; }, [dataService, signature]); - if (loading) - return ( -
-

{t("common.loading")}

-
- ); - if (error) - return ( -
-

{error}

-
- ); - if (!tx) - return ( -
-

{t("transactions.noTransactions")}

-
- ); + 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") }, + ]; - return ( -
-
-

{t("transaction.title")}

- -
-
- {t("transaction.signature")}: - {tx.signature} -
-
- {t("transaction.status")}: - - {tx.status === "success" ? t("transactions.success") : t("transactions.failed")} - -
-
- {t("transaction.slot")}: - - #{formatSlotNumber(tx.slot)} + if (loading) { + return ( +
+ +
+
+ {t("transaction.title")} + + {signature ? shortenSolanaAddress(signature, 10, 10) : ""}
-
- {t("transaction.blockTime")}: - {formatBlockTime(tx.blockTime)} +
+ window.location.reload()} + />
-
- {t("transaction.fee")}: - {formatSol(tx.fee)} -
- {tx.computeUnitsConsumed !== undefined && ( -
- {t("transaction.computeUnits")}: - {tx.computeUnitsConsumed.toLocaleString()} -
- )} - {tx.version !== undefined && ( -
- {t("transaction.version")}: - {String(tx.version)} -
- )}
+
+ ); + } - {tx.signers.length > 0 && ( -
-

{t("transaction.signers")}

-
    - {tx.signers.map((s) => ( -
  • - {s} -
  • - ))} -
-
- )} - - {tx.accountKeys.length > 0 && ( -
-

{t("transaction.accountKeys")}

- - - - - - - - - - {tx.accountKeys.map((key) => ( - - - - - - ))} - -
{t("transaction.programId")}{t("transaction.signer")}{t("transaction.writable")}
- {key.pubkey} - {key.signer ? "✓" : ""}{key.writable ? "✓" : t("transaction.readonly")}
+ if (error) { + return ( +
+ +
+
+ {t("transaction.title")}
- )} - - {tx.instructions.length > 0 && ( -
-

{t("transaction.instructions")}

- {tx.instructions.map((ix, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: instructions are ordered -
-
- {t("transaction.programId")}: - {ix.programId} -
- {ix.accounts.length > 0 && ( -
- {t("transaction.accounts")}: - {ix.accounts.join(", ")} -
- )} - {ix.data && ( -
- {t("transaction.data")}: - {ix.data} -
- )} -
- ))} +
+

Error: {error}

- )} +
+
+ ); + } - {tx.logMessages.length > 0 && ( -
-

{t("transaction.logs")}

-
{tx.logMessages.join("\n")}
+ return ( +
+ + {txResult?.data ? ( + + ) : ( +
+
+

{t("transactions.noTransactions")}

- )} -
+
+ )}
); } diff --git a/src/components/pages/solana/SolanaTransactionsPage.tsx b/src/components/pages/solana/SolanaTransactionsPage.tsx index fda86d60..d8a04840 100644 --- a/src/components/pages/solana/SolanaTransactionsPage.tsx +++ b/src/components/pages/solana/SolanaTransactionsPage.tsx @@ -1,41 +1,49 @@ 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 { resolveNetwork } from "../../../utils/networkResolver"; -import { getAllNetworks } from "../../../config/networks"; import type { SolanaTransaction } from "../../../types"; -import { formatSol, formatSlotNumber, shortenSolanaAddress } from "../../../utils/solanaUtils"; +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 pathSlug = location.pathname.split("/")[1] || "sol"; - const network = resolveNetwork(pathSlug, getAllNetworks()); - const dataService = useDataService(network ?? pathSlug); + 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(() => { - let cancelled = false; + if (!dataService || !dataService.isSolana()) { + setLoading(false); + return; + } - async function fetchTxs() { - if (!dataService || !dataService.isSolana()) return; + let cancelled = false; + const fetchTxs = async () => { setLoading(true); + setError(null); try { const adapter = dataService.getSolanaAdapter(); - // Get latest blocks then fetch some transactions from them 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 >= 30) break; + if (sigs.length >= TXS_PER_PAGE) break; } const txResults = await Promise.all( - sigs.slice(0, 30).map((s) => + sigs.slice(0, TXS_PER_PAGE).map((s) => adapter .getTransaction(s) .then((r) => r.data) @@ -43,17 +51,16 @@ export default function SolanaTransactionsPage() { ), ); const txs = txResults.filter((tx): tx is SolanaTransaction => tx !== null); - if (!cancelled) { - setTransactions(txs); - setError(null); - } + if (!cancelled) setTransactions(txs); } catch (err) { - if (!cancelled) + 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 () => { @@ -61,17 +68,88 @@ export default function SolanaTransactionsPage() { }; }, [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.txsTitle")}

- {error &&

{error}

} - {loading && transactions.length === 0 ? ( -

{t("common.loading")}

- ) : transactions.length === 0 ? ( -

{t("transactions.noTransactions")}

- ) : ( - +
+
+ {t("transactions.title")} + + + Showing {transactions.length} most recent transactions + +
+
+ +
+
@@ -83,23 +161,35 @@ export default function SolanaTransactionsPage() { {transactions.map((tx) => ( - - + ))}
{t("transactions.signature")}
- - {shortenSolanaAddress(tx.signature, 10, 8)} + + + {shortenSolanaAddress(tx.signature, 12, 12)} - {tx.status === "success" ? t("transactions.success") : t("transactions.failed")} + + {tx.status === "success" + ? t("transactions.success") + : t("transactions.failed")} + - #{formatSlotNumber(tx.slot)} + + {formatSlotNumber(tx.slot)} + {formatSol(tx.fee)}{formatSol(tx.fee)}
- )} +
); diff --git a/src/components/pages/solana/SolanaValidatorsPage.tsx b/src/components/pages/solana/SolanaValidatorsPage.tsx index 1ebbb9bc..e0613e3a 100644 --- a/src/components/pages/solana/SolanaValidatorsPage.tsx +++ b/src/components/pages/solana/SolanaValidatorsPage.tsx @@ -1,23 +1,25 @@ 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 { resolveNetwork } from "../../../utils/networkResolver"; -import { getAllNetworks } from "../../../config/networks"; 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 pathSlug = location.pathname.split("/")[1] || "sol"; - const network = resolveNetwork(pathSlug, getAllNetworks()); - const dataService = useDataService(network ?? pathSlug); + 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([]); @@ -26,11 +28,15 @@ export default function SolanaValidatorsPage() { const [error, setError] = useState(null); useEffect(() => { - let cancelled = false; + if (!dataService || !dataService.isSolana()) { + setLoading(false); + return; + } - async function fetchValidators() { - if (!dataService || !dataService.isSolana()) return; + let cancelled = false; + const fetchValidators = async () => { setLoading(true); + setError(null); try { const adapter = dataService.getSolanaAdapter(); const [voteAccounts, epoch] = await Promise.all([ @@ -38,21 +44,21 @@ export default function SolanaValidatorsPage() { adapter.getEpochInfo(), ]); if (!cancelled) { - // Sort by activated stake descending const sortedCurrent = [...voteAccounts.current].sort( (a, b) => b.activatedStake - a.activatedStake, ); setCurrent(sortedCurrent); setDelinquent(voteAccounts.delinquent); setEpochInfo(epoch); - setError(null); } } catch (err) { - if (!cancelled) setError(err instanceof Error ? err.message : "Failed to fetch validators"); + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to fetch validators"); + } } finally { if (!cancelled) setLoading(false); } - } + }; fetchValidators(); return () => { @@ -65,96 +71,143 @@ export default function SolanaValidatorsPage() { [current], ); - if (loading) + const breadcrumbItems = [ + { label: "Home", to: "/" }, + { label: networkLabel, to: `/${networkSlug}` }, + { label: t("validators.title") }, + ]; + + if (loading) { return ( -
-

{t("common.loading")}

+
+ +
+
+ {t("validators.title")} +
+
+ window.location.reload()} + /> +
+
); - if (error) + } + + if (error) { return ( -
-

{error}

+
+ +
+
+ {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()}
+ + + + + + + + - ))} - -
#{t("validators.identity")}{t("validators.voteAccount")}{t("validators.stake")}{t("validators.commission")}{t("validators.lastVote")}
+ + + {validators.map((v, idx) => ( + + {idx + 1} + + + {shortenSolanaAddress(v.nodePubkey, 6, 6)} + + + + + {shortenSolanaAddress(v.votePubkey, 6, 6)} + + + {formatStake(v.activatedStake)} + {v.commission}% + {v.lastVote.toLocaleString()} + + ))} + + +
); return ( -
+
+
-

{t("validators.title")}

+
+ {t("validators.title")} +
{epochInfo && ( -
-
- {t("validators.currentEpoch")}: - {epochInfo.epoch} +
+
+ {t("validators.currentEpoch")}: + {epochInfo.epoch}
-
- {t("validators.epochProgress")}: - {epochProgress.toFixed(2)}% +
+ {t("validators.epochProgress")}: + {epochProgress.toFixed(2)}%
-
- {t("validators.totalStake")}: - {formatStake(totalStake)} +
+ {t("validators.totalStake")}: + {formatStake(totalStake)}
-
- {t("validators.validatorCount")}: - {current.length} +
+ {t("validators.validatorCount")}: + {current.length.toLocaleString()}
)} -
-

{t("validators.currentValidators")}

+
+

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

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

{t("validators.noValidators")}

+
+

{t("validators.noValidators")}

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

{t("validators.delinquentValidators")}

+
+

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

{renderValidatorTable(delinquent)}
)} From 716bab693f213baf22882a7d370b005b7db389ff Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 6 Apr 2026 18:13:18 -0300 Subject: [PATCH 07/15] fix(solana): use rounded badge classes for transaction status - SolanaTransactionsPage and SolanaAccountDisplay now use the shared table-status-badge / table-status-success / table-status-failed classes (matching EVM TransactionHistory) so the success/failed pills render with rounded corners. - Add a block-status-failed CSS rule next to block-status-finalized and block-status-pending so the SolanaTransactionDisplay header badge has the correct red rounded style. --- .../pages/solana/SolanaAccountDisplay.tsx | 12 +++++++++--- .../pages/solana/SolanaTransactionsPage.tsx | 16 +++++++++------- src/styles/styles.css | 5 +++++ 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/components/pages/solana/SolanaAccountDisplay.tsx b/src/components/pages/solana/SolanaAccountDisplay.tsx index e9761d67..0dbac9f6 100644 --- a/src/components/pages/solana/SolanaAccountDisplay.tsx +++ b/src/components/pages/solana/SolanaAccountDisplay.tsx @@ -150,9 +150,15 @@ const SolanaAccountDisplay: React.FC = React.memo( - - {sig.err ? t("transactions.failed") : t("transactions.success")} - + {sig.err ? ( + + ✗ {t("transactions.failed")} + + ) : ( + + ✓ {t("transactions.success")} + + )} diff --git a/src/components/pages/solana/SolanaTransactionsPage.tsx b/src/components/pages/solana/SolanaTransactionsPage.tsx index d8a04840..d08eae1a 100644 --- a/src/components/pages/solana/SolanaTransactionsPage.tsx +++ b/src/components/pages/solana/SolanaTransactionsPage.tsx @@ -171,13 +171,15 @@ export default function SolanaTransactionsPage() { - - {tx.status === "success" - ? t("transactions.success") - : t("transactions.failed")} - + {tx.status === "success" ? ( + + ✓ {t("transactions.success")} + + ) : ( + + ✗ {t("transactions.failed")} + + )} 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; From 5677826f5f37c57bf5f2203bc0487826bc97f068 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 6 Apr 2026 18:26:23 -0300 Subject: [PATCH 08/15] style(solana): use two-column layout for account details and token holdings Wrap the account details and token holdings inside btc-tx-details-grid so they render side-by-side, mirroring the BTC/EVM address page layout. The address row stays full-width on top, then the left column holds the account metadata (balance, owner, executable, data size, rent) and the right column holds the SPL token holdings table. --- .../pages/solana/SolanaAccountDisplay.tsx | 157 +++++++++--------- 1 file changed, 81 insertions(+), 76 deletions(-) diff --git a/src/components/pages/solana/SolanaAccountDisplay.tsx b/src/components/pages/solana/SolanaAccountDisplay.tsx index 0dbac9f6..16f0207a 100644 --- a/src/components/pages/solana/SolanaAccountDisplay.tsx +++ b/src/components/pages/solana/SolanaAccountDisplay.tsx @@ -26,7 +26,7 @@ const SolanaAccountDisplay: React.FC = React.memo(
- {/* Address */} + {/* Address — full width on top */}
{t("account.address")}: = React.memo(
- {/* Balance */} -
- {t("account.balance")}: - {formatSol(account.lamports)} -
- - {/* Owner */} -
- {t("account.owner")}: - - - {shortenSolanaAddress(account.owner, 10, 10)} - - -
+
+ {/* Left column — account details */} +
+
+ {t("account.balance")}: + + {formatSol(account.lamports)} + +
- {/* Executable */} -
- {t("account.executable")}: - - {account.executable ? t("account.yes") : t("account.no")} - -
+
+ {t("account.owner")}: + + + {shortenSolanaAddress(account.owner, 10, 10)} + + +
- {/* Data size */} -
- {t("account.dataSize")}: - {account.space.toLocaleString()} bytes -
+
+ {t("account.executable")}: + + {account.executable ? t("account.yes") : t("account.no")} + +
- {/* Rent epoch */} -
- {t("account.rentEpoch")}: - {account.rentEpoch} -
-
+
+ {t("account.dataSize")}: + {account.space.toLocaleString()} bytes +
- {/* 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.rentEpoch")}: + {account.rentEpoch} +
- ) : ( -
-

{t("account.noTokens")}

+ + {/* 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 */} From 2ecd24806af3bab3eff2370cdae3476160d33e5f Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 6 Apr 2026 20:00:06 -0300 Subject: [PATCH 09/15] style(solana): fix account layout - address full width, token holdings as section header - Move the address row into its own tx-details block outside the two-column grid so it spans the full card width. - Replace the 'Token Holdings:' tx-row label with a proper block-display-section-title header inside the right column, so the token table has its own section heading like the rest of the app. --- .../pages/solana/SolanaAccountDisplay.tsx | 98 +++++++++---------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/src/components/pages/solana/SolanaAccountDisplay.tsx b/src/components/pages/solana/SolanaAccountDisplay.tsx index 16f0207a..9465e7e8 100644 --- a/src/components/pages/solana/SolanaAccountDisplay.tsx +++ b/src/components/pages/solana/SolanaAccountDisplay.tsx @@ -25,8 +25,8 @@ const SolanaAccountDisplay: React.FC = React.memo( {accountTypeLabel}
+ {/* Address — full width on top */}
- {/* Address — full width on top */}
{t("account.address")}: = React.memo(
+
-
- {/* Left column — account details */} -
-
- {t("account.balance")}: - - {formatSol(account.lamports)} - -
+ {/* 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.owner")}: + + + {shortenSolanaAddress(account.owner, 10, 10)} + + +
-
- {t("account.executable")}: - - {account.executable ? t("account.yes") : t("account.no")} - -
+
+ {t("account.executable")}: + + {account.executable ? t("account.yes") : t("account.no")} + +
-
- {t("account.dataSize")}: - {account.space.toLocaleString()} bytes -
+
+ {t("account.dataSize")}: + {account.space.toLocaleString()} bytes +
-
- {t("account.rentEpoch")}: - {account.rentEpoch} -
+
+ {t("account.rentEpoch")}: + {account.rentEpoch}
+
- {/* Right column — token holdings */} -
-
- - {t("account.tokenHoldings")} - {account.tokenAccounts && account.tokenAccounts.length > 0 - ? ` (${account.tokenAccounts.length})` - : ""} - : - -
+ {/* Right column — token holdings */} +
+
+

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

{account.tokenAccounts && account.tokenAccounts.length > 0 ? (
@@ -118,8 +118,8 @@ const SolanaAccountDisplay: React.FC = React.memo(
) : ( -
- {t("account.noTokens")} +
+

{t("account.noTokens")}

)}
From 5c51c919ef86aa02c2ddb7131d5fbb797f75b9cb Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 6 Apr 2026 20:03:55 -0300 Subject: [PATCH 10/15] fix(solana): use link-accent class for in-app links on detail views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom tx-link class doesn't exist — replace all occurrences in SolanaTransactionDisplay, SolanaSlotDisplay, and SolanaAccountDisplay with link-accent tx-mono, matching how BTC/EVM detail pages render their block/slot/account/tx links. --- src/components/pages/solana/SolanaAccountDisplay.tsx | 2 +- src/components/pages/solana/SolanaSlotDisplay.tsx | 2 +- src/components/pages/solana/SolanaTransactionDisplay.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/pages/solana/SolanaAccountDisplay.tsx b/src/components/pages/solana/SolanaAccountDisplay.tsx index 9465e7e8..83451a4d 100644 --- a/src/components/pages/solana/SolanaAccountDisplay.tsx +++ b/src/components/pages/solana/SolanaAccountDisplay.tsx @@ -55,7 +55,7 @@ const SolanaAccountDisplay: React.FC = React.memo( {shortenSolanaAddress(account.owner, 10, 10)} diff --git a/src/components/pages/solana/SolanaSlotDisplay.tsx b/src/components/pages/solana/SolanaSlotDisplay.tsx index 82910a18..6eefe24e 100644 --- a/src/components/pages/solana/SolanaSlotDisplay.tsx +++ b/src/components/pages/solana/SolanaSlotDisplay.tsx @@ -76,7 +76,7 @@ const SolanaSlotDisplay: React.FC = React.memo(({ block,
{t("block.parentSlot")}: - + #{formatSlotNumber(block.parentSlot)} diff --git a/src/components/pages/solana/SolanaTransactionDisplay.tsx b/src/components/pages/solana/SolanaTransactionDisplay.tsx index 32c51092..3c72274e 100644 --- a/src/components/pages/solana/SolanaTransactionDisplay.tsx +++ b/src/components/pages/solana/SolanaTransactionDisplay.tsx @@ -60,7 +60,7 @@ const SolanaTransactionDisplay: React.FC = React.
{t("transaction.slot")}: - + #{formatSlotNumber(tx.slot)} @@ -143,7 +143,7 @@ const SolanaTransactionDisplay: React.FC = React. {shortenSolanaAddress(ix.programId, 10, 10)} From 89dd23c3cbc23c72422e1605508b9f702203ab7b Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 6 Apr 2026 20:08:05 -0300 Subject: [PATCH 11/15] style(solana): remove transaction count from dashboard stats Replace the Transactions stat card (which showed the cumulative transaction count) with a Version stat card. The cumulative count isn't useful at a glance and was taking up a prominent dashboard slot. --- src/components/pages/solana/SolanaDashboardStats.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/components/pages/solana/SolanaDashboardStats.tsx b/src/components/pages/solana/SolanaDashboardStats.tsx index f9b3929e..76b6c6fd 100644 --- a/src/components/pages/solana/SolanaDashboardStats.tsx +++ b/src/components/pages/solana/SolanaDashboardStats.tsx @@ -58,12 +58,9 @@ const SolanaDashboardStats: React.FC = ({
-
{t("dashboard.transactions")}
+
{t("dashboard.version")}
- {loading && !stats ? skeleton("100px") : formatSlotNumber(stats?.transactionCount ?? 0)} -
-
- {stats ? `${t("dashboard.version")}: ${stats.version}` : ""} + {loading && !stats ? skeleton("80px") : (stats?.version ?? "—")}
From 039b999f747fd80ceb7afef12d23ebb4f9302419 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 6 Apr 2026 20:08:52 -0300 Subject: [PATCH 12/15] style(solana): show only 3 dashboard stats (price, slot, epoch) Remove the Version stat card so the Solana dashboard header only shows SOL Price, Current Slot, and Epoch. --- src/components/pages/solana/SolanaDashboardStats.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/components/pages/solana/SolanaDashboardStats.tsx b/src/components/pages/solana/SolanaDashboardStats.tsx index 76b6c6fd..02010c9c 100644 --- a/src/components/pages/solana/SolanaDashboardStats.tsx +++ b/src/components/pages/solana/SolanaDashboardStats.tsx @@ -57,12 +57,6 @@ const SolanaDashboardStats: React.FC = ({
-
-
{t("dashboard.version")}
-
- {loading && !stats ? skeleton("80px") : (stats?.version ?? "—")} -
-
); }; From 98af388d76ca44c505d0a56700d59f6b8b3eadd8 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 6 Apr 2026 20:14:27 -0300 Subject: [PATCH 13/15] style(solana): show block transactions list using shared tx-list pattern Replace the table-based collapsible transactions section in SolanaSlotDisplay with the tx-list / tx-list-item / tx-list-index / tx-list-hash pattern used by EVM BlockAnalyser and BTC BitcoinBlockDisplay, toggled via a more-details-toggle button. The signatures now render as a numbered list of full hashes with link-accent styling, matching how EVM and BTC blocks display their transaction lists. --- .../pages/solana/SolanaSlotDisplay.tsx | 49 +++++++------------ 1 file changed, 18 insertions(+), 31 deletions(-) diff --git a/src/components/pages/solana/SolanaSlotDisplay.tsx b/src/components/pages/solana/SolanaSlotDisplay.tsx index 6eefe24e..005d656c 100644 --- a/src/components/pages/solana/SolanaSlotDisplay.tsx +++ b/src/components/pages/solana/SolanaSlotDisplay.tsx @@ -145,43 +145,30 @@ const SolanaSlotDisplay: React.FC = React.memo(({ block, {/* Transactions */} {block.signatures && block.signatures.length > 0 && ( -
+
+ {showTransactions && ( -
- - - - - - - - {block.signatures.slice(0, 100).map((sig) => ( - - - - ))} - -
{t("transaction.signature")}
- - {shortenSolanaAddress(sig, 12, 12)} - -
+
+
+ {block.signatures.map((sig, index) => ( +
+ {index} + + + {sig} + + +
+ ))} +
)}
From fd7656cfa3c30090fcd85e7b0a64eeb308326191 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Sat, 11 Apr 2026 09:13:40 -0300 Subject: [PATCH 14/15] fix(solana): restyle program logs section on transaction view Replace the bare
 with nonexistent log-output class with the
shared more-details-toggle / more-details-content / detail-row pattern.
Logs now render as a numbered list inside the styled container, matching
how EVM raw traces and block details display expandable content.
---
 .../pages/solana/SolanaAccountDisplay.tsx     |  4 +---
 .../pages/solana/SolanaDashboardStats.tsx     |  1 -
 .../pages/solana/SolanaTransactionDisplay.tsx | 23 +++++++++++++------
 src/services/MetadataService.ts               | 16 ++++++++++++-
 4 files changed, 32 insertions(+), 12 deletions(-)

diff --git a/src/components/pages/solana/SolanaAccountDisplay.tsx b/src/components/pages/solana/SolanaAccountDisplay.tsx
index 83451a4d..f8c44d18 100644
--- a/src/components/pages/solana/SolanaAccountDisplay.tsx
+++ b/src/components/pages/solana/SolanaAccountDisplay.tsx
@@ -45,9 +45,7 @@ const SolanaAccountDisplay: React.FC = React.memo(
             
{t("account.balance")}: - - {formatSol(account.lamports)} - + {formatSol(account.lamports)}
diff --git a/src/components/pages/solana/SolanaDashboardStats.tsx b/src/components/pages/solana/SolanaDashboardStats.tsx index 02010c9c..9aa1b6b0 100644 --- a/src/components/pages/solana/SolanaDashboardStats.tsx +++ b/src/components/pages/solana/SolanaDashboardStats.tsx @@ -56,7 +56,6 @@ const SolanaDashboardStats: React.FC = ({ {stats ? `${epochProgress.toFixed(1)}% complete` : ""}
-
); }; diff --git a/src/components/pages/solana/SolanaTransactionDisplay.tsx b/src/components/pages/solana/SolanaTransactionDisplay.tsx index 3c72274e..de6127a5 100644 --- a/src/components/pages/solana/SolanaTransactionDisplay.tsx +++ b/src/components/pages/solana/SolanaTransactionDisplay.tsx @@ -211,18 +211,27 @@ const SolanaTransactionDisplay: React.FC = React. {/* Logs */} {tx.logMessages.length > 0 && ( -
+
- {showLogs &&
{tx.logMessages.join("\n")}
} + {showLogs && ( +
+ {tx.logMessages.map((msg, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: log messages are ordered +
+ + {i} + + {msg} +
+ ))} +
+ )}
)}
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; } From a0dee339e5786760742749709fc21b16b6ed8ec0 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Tue, 14 Apr 2026 09:44:19 -0300 Subject: [PATCH 15/15] fix: update lockfile and override axios to fix CI failures Regenerate bun.lock to pass --frozen-lockfile check in CI. Add axios >=1.15.0 override to resolve critical SSRF/header injection vulnerabilities in transitive dependency from @coinbase/cdp-sdk. --- bun.lock | 9 +++++---- package.json | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) 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 f8a2ccce..26637827 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ }, "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",