diff --git a/bun.lock b/bun.lock index 1a313c4..a83386e 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "openscan", "dependencies": { + "@erc7730/sdk": "^0.1.3", "@openscan/network-connectors": "^1.3.0", "@rainbow-me/rainbowkit": "^2.2.8", "@react-native-async-storage/async-storage": "^1.24.0", @@ -146,6 +147,8 @@ "@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="], + "@erc7730/sdk": ["@erc7730/sdk@0.1.3", "", { "dependencies": { "abitype": "^1.0.0" }, "peerDependencies": { "viem": "^2.0.0" }, "optionalPeers": ["viem"] }, "sha512-DO7as1TlVMWxY2AmpSirGLudkm9HJmelyyBHUvvLKO97FJRamh7xF2EvbyMBCdlQQOKwrVMQUK5l+KD63WpJQQ=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], diff --git a/package.json b/package.json index 7d42111..d3293f0 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@noble/curves": "^1.8.0" }, "dependencies": { + "@erc7730/sdk": "^0.1.3", "@openscan/network-connectors": "^1.3.0", "@rainbow-me/rainbowkit": "^2.2.8", "@react-native-async-storage/async-storage": "^1.24.0", diff --git a/src/App.tsx b/src/App.tsx index d3b4417..92ee595 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import "./styles/tables.css"; import "./styles/forms.css"; import "./styles/rainbowkit.css"; import "./styles/responsive.css"; +import "./styles/ai-analysis.css"; import Loading from "./components/common/Loading"; import { diff --git a/src/components/common/AIAnalysis/AIAnalysisPanel.tsx b/src/components/common/AIAnalysis/AIAnalysisPanel.tsx new file mode 100644 index 0000000..50819af --- /dev/null +++ b/src/components/common/AIAnalysis/AIAnalysisPanel.tsx @@ -0,0 +1,162 @@ +import type React from "react"; +import { useEffect, useId, useState } from "react"; +import { useTranslation } from "react-i18next"; +import Markdown from "react-markdown"; +import { Link } from "react-router-dom"; +import { useAIAnalysis } from "../../../hooks/useAIAnalysis"; +import type { AIAnalysisType } from "../../../types"; + +interface AIAnalysisProps { + analysisType: AIAnalysisType; + context: Record; + networkName: string; + networkCurrency: string; + cacheKey: string; +} + +const AIAnalysisPanel: React.FC = ({ + analysisType, + context, + networkName, + networkCurrency, + cacheKey, +}) => { + const { t, i18n } = useTranslation("common"); + const [isOpen, setIsOpen] = useState(false); + const panelId = useId(); + const { result, loading, error, errorType, analyze, refresh } = useAIAnalysis( + analysisType, + context, + networkName, + networkCurrency, + cacheKey, + i18n.language, + ); + + useEffect(() => { + if (result || error) { + setIsOpen(true); + } + }, [result, error]); + + const handleAnalyze = () => { + setIsOpen(true); + void analyze(); + }; + + return ( +
+
+
+ + {result && ( + + )} +
+
+ + {result && !isOpen && ( + + )} + +
+ {error && } + + {result && ( + <> +
+ {result.summary} +
+
+
+ + {t("aiAnalysis.generatedBy", { model: result.model })} + {result.cached && ( + {t("aiAnalysis.cachedResult")} + )} + + +
+
{t("aiAnalysis.disclaimer")}
+
+ + )} +
+
+ ); +}; + +const ERROR_MESSAGE_KEYS = { + rate_limited: "aiAnalysis.errors.rateLimited", + invalid_key: "aiAnalysis.errors.invalidKey", + no_api_key: "aiAnalysis.errors.no_api_key", + network_error: "aiAnalysis.errors.networkError", + service_unavailable: "aiAnalysis.errors.serviceUnavailable", + parse_error: "aiAnalysis.errors.parseError", + generic: "aiAnalysis.errors.generic", +} as const; + +interface AIAnalysisErrorProps { + errorType: string | null; + onRetry: () => void; +} + +const AIAnalysisError: React.FC = ({ errorType, onRetry }) => { + const { t } = useTranslation("common"); + + const messageKey = + errorType && errorType in ERROR_MESSAGE_KEYS + ? ERROR_MESSAGE_KEYS[errorType as keyof typeof ERROR_MESSAGE_KEYS] + : ERROR_MESSAGE_KEYS.generic; + const showSettingsLink = errorType === "no_api_key" || errorType === "invalid_key"; + + return ( +
+
{t(messageKey)}
+
+ + {showSettingsLink && ( + + {t("aiAnalysis.errors.goToSettings")} + + )} +
+
+ ); +}; + +export default AIAnalysisPanel; diff --git a/src/components/common/AIAnalysis/aiCache.ts b/src/components/common/AIAnalysis/aiCache.ts new file mode 100644 index 0000000..a915168 --- /dev/null +++ b/src/components/common/AIAnalysis/aiCache.ts @@ -0,0 +1,153 @@ +import type { AIAnalysisResult } from "../../../types"; +import { logger } from "../../../utils/logger"; + +const CACHE_PREFIX = "openscan_ai_"; +const CACHE_VERSION = 1; +const MAX_CACHE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB + +interface CachedAnalysis { + result: AIAnalysisResult; + contextHash: string; + version: number; + storedAt: number; +} + +/** + * Fast string hash (djb2 algorithm). + * Used to hash serialized context objects for cache invalidation. + */ +export function hashContext(context: Record): string { + const str = JSON.stringify(context, (_key, value) => + typeof value === "bigint" ? value.toString() : value, + ); + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0; + } + return (hash >>> 0).toString(36); +} + +/** + * Build a cache key from analysis type, network ID, and identifier. + */ +export function buildCacheKey(type: string, networkId: string, identifier: string): string { + return `${CACHE_PREFIX}${type}_${networkId}_${identifier}`; +} + +/** + * Get a cached analysis result if it exists and the context hash matches. + * Returns null if cache miss, hash mismatch, or version mismatch. + */ +export function getCachedAnalysis(key: string, contextHash: string): AIAnalysisResult | null { + try { + const raw = localStorage.getItem(key); + if (!raw) return null; + + const cached: CachedAnalysis = JSON.parse(raw); + if (cached.version !== CACHE_VERSION) { + localStorage.removeItem(key); + return null; + } + + if (cached.contextHash !== contextHash) { + return null; + } + + return { ...cached.result, cached: true }; + } catch { + logger.warn("Failed to read AI cache entry:", key); + return null; + } +} + +/** + * Store an analysis result in the cache. + * Evicts oldest entries if total AI cache exceeds size limit. + */ +export function setCachedAnalysis( + key: string, + contextHash: string, + result: AIAnalysisResult, +): void { + try { + const entry: CachedAnalysis = { + result, + contextHash, + version: CACHE_VERSION, + storedAt: Date.now(), + }; + + evictIfNeeded(); + localStorage.setItem(key, JSON.stringify(entry)); + } catch { + logger.warn("Failed to write AI cache entry:", key); + } +} + +/** + * Clear all AI analysis cache entries from localStorage. + */ +export function clearAICache(): void { + const keysToRemove: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key?.startsWith(CACHE_PREFIX)) { + keysToRemove.push(key); + } + } + for (const key of keysToRemove) { + localStorage.removeItem(key); + } + logger.info(`Cleared ${keysToRemove.length} AI cache entries`); +} + +/** + * Get the total size of AI cache entries in bytes. + */ +export function getAICacheSize(): number { + let totalSize = 0; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key?.startsWith(CACHE_PREFIX)) { + const value = localStorage.getItem(key); + if (value) { + totalSize += key.length + value.length; + } + } + } + return totalSize; +} + +/** + * Evict oldest AI cache entries if total cache size exceeds limit. + */ +function evictIfNeeded(): void { + if (getAICacheSize() <= MAX_CACHE_SIZE_BYTES) return; + + const entries: Array<{ key: string; storedAt: number }> = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key?.startsWith(CACHE_PREFIX)) continue; + + try { + const raw = localStorage.getItem(key); + if (raw) { + const cached: CachedAnalysis = JSON.parse(raw); + entries.push({ key, storedAt: cached.storedAt }); + } + } catch { + // Remove invalid entries + if (key) localStorage.removeItem(key); + } + } + + // Sort oldest first + entries.sort((a, b) => a.storedAt - b.storedAt); + + // Remove oldest entries until under the size limit + for (const entry of entries) { + if (getAICacheSize() <= MAX_CACHE_SIZE_BYTES) break; + localStorage.removeItem(entry.key); + logger.debug("Evicted AI cache entry:", entry.key); + } +} diff --git a/src/components/common/AIAnalysis/aiContext.ts b/src/components/common/AIAnalysis/aiContext.ts new file mode 100644 index 0000000..1cd1b14 --- /dev/null +++ b/src/components/common/AIAnalysis/aiContext.ts @@ -0,0 +1,162 @@ +import type { ABI, ABIParameter } from "../../../types"; + +const MAX_ABI_FUNCTIONS = 30; +const MAX_ABI_EVENTS = 30; +const MAX_SOURCE_FILES = 25; + +type AIContractDataSummary = { + name?: string; + match?: string | null; + creation_match?: string | null; + runtime_match?: string | null; + compilerVersion?: string; + evmVersion?: string; + chainId?: string; + verifiedAt?: string; + metadata?: { + compiler?: { version: string }; + language?: string; + }; + abi?: { + functions: Array<{ + name: string; + inputs: string[]; + outputs: string[]; + stateMutability?: string; + }>; + events: Array<{ + name: string; + inputs: string[]; + anonymous?: boolean; + }>; + totals: { + functions: number; + events: number; + }; + }; + sourceFiles?: string[]; +}; + +type AIContractDataSummaryAbi = NonNullable; + +export function compactContractDataForAI( + contractData?: unknown, +): AIContractDataSummary | undefined { + if (!contractData || typeof contractData !== "object") return undefined; + const data = contractData as Record; + + const abi = Array.isArray(data.abi) ? (data.abi as ABI[]) : undefined; + const summarizedAbi = abi ? summarizeAbi(abi) : undefined; + const sourceFiles = extractSourceFileNames(data); + return { + name: asString(data.name), + match: asStringOrNull(data.match), + creation_match: asStringOrNull(data.creation_match), + runtime_match: asStringOrNull(data.runtime_match), + compilerVersion: asString(data.compilerVersion), + evmVersion: asString(data.evmVersion), + chainId: asString(data.chainId), + verifiedAt: asString(data.verifiedAt), + metadata: extractMetadata(data.metadata), + abi: summarizedAbi, + sourceFiles, + }; +} + +function extractMetadata(metadata: unknown): AIContractDataSummary["metadata"] | undefined { + if (!metadata || typeof metadata !== "object") return undefined; + const obj = metadata as Record; + const compiler = obj.compiler && typeof obj.compiler === "object" ? obj.compiler : undefined; + const compilerVersion = + compiler && typeof compiler === "object" + ? (compiler as Record).version + : undefined; + + const language = asString(obj.language); + if (!compilerVersion && !language) return undefined; + + return { + compiler: compilerVersion ? { version: String(compilerVersion) } : undefined, + language, + }; +} + +function extractSourceFileNames(data: Record): string[] | undefined { + const files = Array.isArray(data.files) ? data.files : undefined; + if (files) { + const names = files + .map((file) => { + if (!file || typeof file !== "object") return undefined; + const f = file as Record; + return asString(f.path) ?? asString(f.name); + }) + .filter((name): name is string => Boolean(name)); + return names.slice(0, MAX_SOURCE_FILES); + } + + const sources = data.sources && typeof data.sources === "object" ? data.sources : undefined; + if (sources) { + const keys = Object.keys(sources as Record); + return keys.slice(0, MAX_SOURCE_FILES); + } + + return undefined; +} + +function summarizeAbi(abi: ABI[]): AIContractDataSummaryAbi { + const functions: AIContractDataSummaryAbi["functions"] = []; + const events: AIContractDataSummaryAbi["events"] = []; + let totalFunctions = 0; + let totalEvents = 0; + + for (const item of abi) { + if (!item || typeof item !== "object") continue; + if (item.type === "function") { + totalFunctions += 1; + if (functions.length < MAX_ABI_FUNCTIONS) { + functions.push({ + name: item.name ?? "(anonymous)", + inputs: (item.inputs ?? []).map(formatAbiParam), + outputs: (item.outputs ?? []).map(formatAbiParam), + stateMutability: item.stateMutability, + }); + } + continue; + } + if (item.type === "event") { + totalEvents += 1; + if (events.length < MAX_ABI_EVENTS) { + events.push({ + name: item.name ?? "(anonymous)", + inputs: (item.inputs ?? []).map(formatAbiParam), + anonymous: Boolean(item.anonymous), + }); + } + } + } + + return { + functions, + events, + totals: { + functions: totalFunctions, + events: totalEvents, + }, + }; +} + +function formatAbiParam(param: ABIParameter | undefined | null): string { + if (!param) return "unknown"; + const label = param.name ? `${param.name}:` : ""; + return `${label}${param.type}`; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function asStringOrNull(value: unknown): string | null | undefined { + if (value === null) return null; + if (typeof value === "string" && value.length > 0) return value; + return undefined; +} diff --git a/src/components/pages/evm/address/displays/AccountDisplay.tsx b/src/components/pages/evm/address/displays/AccountDisplay.tsx index 5b04fae..125a97d 100644 --- a/src/components/pages/evm/address/displays/AccountDisplay.tsx +++ b/src/components/pages/evm/address/displays/AccountDisplay.tsx @@ -1,7 +1,11 @@ import type React from "react"; -import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; +import { useCallback, useMemo, useState } from "react"; +import { getNetworkById } from "../../../../../config/networks"; +import type { Address, ENSReverseResult, RPCMetadata, Transaction } from "../../../../../types"; +import AIAnalysisPanel from "../../../../common/AIAnalysis/AIAnalysisPanel"; import { AddressHeader, TransactionHistory } from "../shared"; import AccountInfoCards from "../shared/AccountInfoCards"; +import { formatNativeFromWei } from "../../../../../utils/unitFormatters"; interface AccountDisplayProps { address: Address; @@ -27,34 +31,86 @@ const AccountDisplay: React.FC = ({ reverseResult, isMainnet = true, }) => { - return ( -
- + const network = getNetworkById(networkId); + const networkName = network?.name ?? "Unknown Network"; + const networkCurrency = network?.currency ?? "ETH"; -
- {/* Account Info Cards - Overview + More Info side by side */} - + const [transactions, setTransactions] = useState([]); + + const handleTransactionsChange = useCallback((txs: Transaction[]) => { + setTransactions(txs); + }, []); + + const recentTxSummary = useMemo(() => { + if (transactions.length === 0) return undefined; + return transactions.slice(0, 10).map((tx) => ({ + hash: tx.hash, + from: tx.from, + to: tx.to ?? "contract creation", + valueNative: formatNativeFromWei(tx.value, networkCurrency, 6), + status: tx.receipt?.status === "0x1" || tx.receipt?.status === "1" ? "success" : "failed", + })); + }, [transactions, networkCurrency]); - {/* Transaction History */} - ({ + address: addressHash, + balanceNative: formatNativeFromWei(address.balance, networkCurrency, 6), + txCount: address.txCount, + accountType: "account", + hasCode: address.code !== "0x", + ensName: ensName ?? undefined, + recentTransactions: recentTxSummary, + }), + [ + addressHash, + address.balance, + address.txCount, + address.code, + ensName, + recentTxSummary, + networkCurrency, + ], + ); + + return ( +
+
+ + +
+ {/* Account Info Cards - Overview + More Info side by side */} + + + {/* Transaction History */} + +
+
); }; diff --git a/src/components/pages/evm/address/displays/ContractDisplay.tsx b/src/components/pages/evm/address/displays/ContractDisplay.tsx index 7148850..950cef4 100644 --- a/src/components/pages/evm/address/displays/ContractDisplay.tsx +++ b/src/components/pages/evm/address/displays/ContractDisplay.tsx @@ -1,11 +1,16 @@ import type React from "react"; import { useContext, useMemo } from "react"; +import { getNetworkById } from "../../../../../config/networks"; import { AppContext } from "../../../../../context"; import { useSourcify } from "../../../../../hooks/useSourcify"; import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; +import AIAnalysisPanel from "../../../../common/AIAnalysis/AIAnalysisPanel"; import { AddressHeader } from "../shared"; import ContractInfoCard from "../shared/ContractInfoCard"; import ContractInfoCards from "../shared/ContractInfoCards"; +import { logger } from "../../../../../utils"; +import { compactContractDataForAI } from "../../../../common/AIAnalysis/aiContext"; +import { formatNativeFromWei } from "../../../../../utils/unitFormatters"; interface ContractDisplayProps { address: Address; @@ -32,6 +37,9 @@ const ContractDisplay: React.FC = ({ isMainnet = true, }) => { const { jsonFiles } = useContext(AppContext); + const network = getNetworkById(networkId); + const networkName = network?.name ?? "Unknown Network"; + const networkCurrency = network?.currency ?? "ETH"; // Fetch Sourcify data const { @@ -83,43 +91,79 @@ const ContractDisplay: React.FC = ({ const hasVerifiedContract = isVerified || !!parsedLocalData; - return ( -
- + const aiContractData = useMemo(() => compactContractDataForAI(contractData), [contractData]); -
- {/* Overview + More Info Cards */} - + const aiContext = useMemo( + () => ({ + address: addressHash, + balanceNative: formatNativeFromWei(address.balance, networkCurrency, 6), + txCount: address.txCount, + accountType: "contract", + hasCode: true, + ensName: ensName ?? undefined, + isVerified: hasVerifiedContract, + contractName: aiContractData?.name ?? undefined, + contractData: aiContractData, + }), + [ + addressHash, + address.balance, + address.txCount, + ensName, + hasVerifiedContract, + aiContractData, + networkCurrency, + ], + ); - {/* Contract Info Card (includes Contract Details) */} - +
+ + +
+ {/* Overview + More Info Cards */} + + + {/* Contract Info Card (includes Contract Details) */} + +
+ +
); }; diff --git a/src/components/pages/evm/address/displays/ERC1155Display.tsx b/src/components/pages/evm/address/displays/ERC1155Display.tsx index 565bf97..d370b8f 100644 --- a/src/components/pages/evm/address/displays/ERC1155Display.tsx +++ b/src/components/pages/evm/address/displays/ERC1155Display.tsx @@ -1,5 +1,6 @@ import type React from "react"; import { useContext, useEffect, useMemo, useState } from "react"; +import { getNetworkById } from "../../../../../config/networks"; import { AppContext } from "../../../../../context"; import { useSourcify } from "../../../../../hooks/useSourcify"; import { @@ -10,6 +11,8 @@ import { import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; import { decodeAbiString } from "../../../../../utils/hexUtils"; import { logger } from "../../../../../utils/logger"; +import { formatNativeFromWei } from "../../../../../utils/unitFormatters"; +import AIAnalysisPanel from "../../../../common/AIAnalysis/AIAnalysisPanel"; import { AddressHeader } from "../shared"; import ContractInfoCard from "../shared/ContractInfoCard"; import ContractInfoCards from "../shared/ContractInfoCards"; @@ -187,57 +190,98 @@ const ERC1155Display: React.FC = ({ ? getAssetUrl(tokenMetadata.logo) : getAssetUrl(`assets/tokens/${networkId}/${addressHash.toLowerCase()}.png`); - return ( -
- + const network = getNetworkById(networkId); + const networkName = network?.name ?? "Unknown Network"; + const networkCurrency = network?.currency ?? "ETH"; -
- {/* Overview + More Info Cards */} - + const aiContext = useMemo( + () => ({ + address: addressHash, + balanceNative: formatNativeFromWei(address.balance, networkCurrency, 6), + txCount: address.txCount, + accountType: "erc1155", + hasCode: true, + ensName: ensName ?? undefined, + collectionName: collectionName ?? undefined, + collectionSymbol: collectionSymbol ?? undefined, + metadataUri: onChainData?.uri ?? undefined, + isVerified: hasVerifiedContract, + contractName: contractData?.name ?? undefined, + }), + [ + addressHash, + address.balance, + address.txCount, + ensName, + collectionName, + collectionSymbol, + onChainData?.uri, + hasVerifiedContract, + contractData?.name, + networkCurrency, + ], + ); - {/* NFT Collection Info Card */} - +
+ - {/* Contract Info Card (includes Contract Details) */} - +
+ {/* Overview + More Info Cards */} + + + {/* NFT Collection Info Card */} + + + {/* Contract Info Card (includes Contract Details) */} + +
+
); }; diff --git a/src/components/pages/evm/address/displays/ERC20Display.tsx b/src/components/pages/evm/address/displays/ERC20Display.tsx index 6b2a8d0..eb64cf6 100644 --- a/src/components/pages/evm/address/displays/ERC20Display.tsx +++ b/src/components/pages/evm/address/displays/ERC20Display.tsx @@ -1,5 +1,6 @@ import type React from "react"; import { useContext, useEffect, useMemo, useState } from "react"; +import { getNetworkById } from "../../../../../config/networks"; import { AppContext } from "../../../../../context"; import { useSourcify } from "../../../../../hooks/useSourcify"; import { @@ -10,6 +11,8 @@ import { import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; import { hexToUtf8 } from "../../../../../utils/erc20Utils"; import { logger } from "../../../../../utils/logger"; +import { formatNativeFromWei, formatTokenAmount } from "../../../../../utils/unitFormatters"; +import AIAnalysisPanel from "../../../../common/AIAnalysis/AIAnalysisPanel"; import { AddressHeader } from "../shared"; import ContractInfoCard from "../shared/ContractInfoCard"; import ContractInfoCards from "../shared/ContractInfoCards"; @@ -193,55 +196,103 @@ const ERC20Display: React.FC = ({ ? getAssetUrl(tokenMetadata.logo) : getAssetUrl(`assets/tokens/${networkId}/${addressHash.toLowerCase()}.png`); - return ( -
- + const network = getNetworkById(networkId); + const networkName = network?.name ?? "Unknown Network"; + const networkCurrency = network?.currency ?? "ETH"; -
- {/* Overview + More Info Cards */} - + const aiContext = useMemo( + () => ({ + address: addressHash, + balanceNative: formatNativeFromWei(address.balance, networkCurrency, 6), + txCount: address.txCount, + accountType: "erc20", + hasCode: true, + ensName: ensName ?? undefined, + tokenName: tokenName ?? undefined, + tokenSymbol: tokenSymbol ?? undefined, + tokenDecimals: tokenDecimals ?? undefined, + tokenTotalSupplyFormatted: formatTokenAmount( + tokenTotalSupply, + tokenDecimals ?? undefined, + 6, + tokenSymbol ?? undefined, + ), + isVerified: hasVerifiedContract, + contractName: contractData?.name ?? undefined, + }), + [ + addressHash, + address.balance, + address.txCount, + ensName, + tokenName, + tokenSymbol, + tokenDecimals, + tokenTotalSupply, + hasVerifiedContract, + contractData?.name, + networkCurrency, + ], + ); - {/* Token Info Card */} - +
+ - {/* Contract Info Card (includes Contract Details) */} - +
+ {/* Overview + More Info Cards */} + + + {/* Token Info Card */} + + + {/* Contract Info Card (includes Contract Details) */} + +
+
); }; diff --git a/src/components/pages/evm/address/displays/ERC721Display.tsx b/src/components/pages/evm/address/displays/ERC721Display.tsx index 623cca7..10905af 100644 --- a/src/components/pages/evm/address/displays/ERC721Display.tsx +++ b/src/components/pages/evm/address/displays/ERC721Display.tsx @@ -1,5 +1,6 @@ import type React from "react"; import { useContext, useEffect, useMemo, useState } from "react"; +import { getNetworkById } from "../../../../../config/networks"; import { AppContext } from "../../../../../context"; import { useSourcify } from "../../../../../hooks/useSourcify"; import { @@ -10,6 +11,8 @@ import { import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; import { decodeAbiString } from "../../../../../utils/hexUtils"; import { logger } from "../../../../../utils/logger"; +import { formatNativeFromWei } from "../../../../../utils/unitFormatters"; +import AIAnalysisPanel from "../../../../common/AIAnalysis/AIAnalysisPanel"; import { AddressHeader } from "../shared"; import ContractInfoCard from "../shared/ContractInfoCard"; import ContractInfoCards from "../shared/ContractInfoCards"; @@ -169,57 +172,98 @@ const ERC721Display: React.FC = ({ ? getAssetUrl(tokenMetadata.logo) : getAssetUrl(`assets/tokens/${networkId}/${addressHash.toLowerCase()}.png`); - return ( -
- + const network = getNetworkById(networkId); + const networkName = network?.name ?? "Unknown Network"; + const networkCurrency = network?.currency ?? "ETH"; -
- {/* Overview + More Info Cards */} - + const aiContext = useMemo( + () => ({ + address: addressHash, + balanceNative: formatNativeFromWei(address.balance, networkCurrency, 6), + txCount: address.txCount, + accountType: "erc721", + hasCode: true, + ensName: ensName ?? undefined, + collectionName: collectionName ?? undefined, + collectionSymbol: collectionSymbol ?? undefined, + totalSupply: totalSupply ?? undefined, + isVerified: hasVerifiedContract, + contractName: contractData?.name ?? undefined, + }), + [ + addressHash, + address.balance, + address.txCount, + ensName, + collectionName, + collectionSymbol, + totalSupply, + hasVerifiedContract, + contractData?.name, + networkCurrency, + ], + ); - {/* NFT Collection Info Card */} - +
+ - {/* Contract Info Card (includes Contract Details) */} - +
+ {/* Overview + More Info Cards */} + + + {/* NFT Collection Info Card */} + + + {/* Contract Info Card (includes Contract Details) */} + +
+
); }; diff --git a/src/components/pages/evm/address/shared/TransactionHistory.tsx b/src/components/pages/evm/address/shared/TransactionHistory.tsx index cffa9e5..fb623ea 100644 --- a/src/components/pages/evm/address/shared/TransactionHistory.tsx +++ b/src/components/pages/evm/address/shared/TransactionHistory.tsx @@ -82,6 +82,7 @@ interface TransactionHistoryProps { addressHash: string; contractAbi?: ABI[]; txCount?: number; // Nonce (outgoing tx count) - used as minimum estimate for progress + onTransactionsChange?: (transactions: Transaction[]) => void; } const TransactionHistory: React.FC = ({ @@ -89,6 +90,7 @@ const TransactionHistory: React.FC = ({ addressHash, contractAbi, txCount = 0, + onTransactionsChange, }) => { const numericNetworkId = Number(networkId) || 1; const dataService = useDataService(numericNetworkId); @@ -125,6 +127,12 @@ const TransactionHistory: React.FC = ({ const loadMoreDropdownRef = useRef(null); const { t } = useTranslation("address"); + + // Notify parent when transactions change + useEffect(() => { + onTransactionsChange?.(transactionDetails); + }, [transactionDetails, onTransactionsChange]); + // Close dropdowns when clicking outside useEffect(() => { if (!dropdownOpen && !loadMoreDropdownOpen) return; diff --git a/src/components/pages/evm/block/BlockDisplay.tsx b/src/components/pages/evm/block/BlockDisplay.tsx index 3285f71..2e4399d 100644 --- a/src/components/pages/evm/block/BlockDisplay.tsx +++ b/src/components/pages/evm/block/BlockDisplay.tsx @@ -1,9 +1,12 @@ -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; +import { getNetworkById } from "../../../../config/networks"; import type { Block, BlockArbitrum, RPCMetadata } from "../../../../types"; +import AIAnalysisPanel from "../../../common/AIAnalysis/AIAnalysisPanel"; import ExtraDataDisplay from "../../../common/ExtraDataDisplay"; import { RPCIndicator } from "../../../common/RPCIndicator"; +import { formatGweiFromWei, formatNativeFromWei } from "../../../../utils/unitFormatters"; interface BlockDisplayProps { block: Block | BlockArbitrum; @@ -16,6 +19,9 @@ interface BlockDisplayProps { const BlockDisplay: React.FC = React.memo( ({ block, networkId, metadata, selectedProvider, onProviderSelect }) => { const { t } = useTranslation("block"); + const network = networkId ? getNetworkById(networkId) : undefined; + const networkName = network?.name ?? "Unknown Network"; + const networkCurrency = network?.currency ?? "ETH"; const [showWithdrawals, setShowWithdrawals] = useState(false); const [showTransactions, setShowTransactions] = useState(false); const [showMoreDetails, setShowMoreDetails] = useState(false); @@ -77,23 +83,9 @@ const BlockDisplay: React.FC = React.memo( : t("time.in", { count: diffSeconds, unit: unitLabel }); }; - const formatGwei = (value: string) => { - try { - const gwei = Number(value) / 1e9; - return `${gwei.toFixed(9)} Gwei`; - } catch (_e) { - return value; - } - }; - - const formatEth = (value: string) => { - try { - const eth = Number(value) / 1e18; - return `${eth.toFixed(12)} ETH`; - } catch (_e) { - return value; - } - }; + const formatGwei = (value: string) => formatGweiFromWei(value, 9) ?? value; + const formatNative = (value: string) => + formatNativeFromWei(value, networkCurrency, 12) ?? value; const blockNumber = Number(block.number); const timestampFormatted = formatTimestamp(block.timestamp); @@ -104,338 +96,372 @@ const BlockDisplay: React.FC = React.memo( const burntFees = block.baseFeePerGas ? (BigInt(block.gasUsed) * BigInt(block.baseFeePerGas)).toString() : null; + const baseFeePerGasGwei = block.baseFeePerGas ? formatGwei(block.baseFeePerGas) : undefined; + const burntFeesNative = burntFees ? formatNative(burntFees) : undefined; + + const aiContext = useMemo(() => { + const ctx: Record = { + blockNumber: block.number, + hash: block.hash, + timestamp: block.timestamp, + finalized: true, + transactionCount: block.transactions?.length ?? 0, + feeRecipient: block.miner, + gasUsed: block.gasUsed, + gasLimit: block.gasLimit, + gasUsedPercentage: gasUsedPct, + baseFeePerGasGwei, + burntFeesNative, + size: block.size, + extraData: block.extraData !== "0x" ? block.extraData : undefined, + }; + if ("l1BlockNumber" in block) { + ctx.l1BlockNumber = (block as BlockArbitrum).l1BlockNumber; + ctx.sendCount = (block as BlockArbitrum).sendCount; + } + return ctx; + }, [block, gasUsedPct, baseFeePerGasGwei, burntFeesNative]); return ( -
-
-
- {networkId && blockNumber > 0 && ( - - ← - - )} -
- {t("block")} - #{blockNumber.toLocaleString()} +
+
+
+
+ {networkId && blockNumber > 0 && ( + + ← + + )} +
+ {t("block")} + #{blockNumber.toLocaleString()} +
+ {networkId && ( + + → + + )} + + + {timestampAge} + ({timestampFormatted}) + + + {t("finalized")}
- {networkId && ( - - → - + {metadata && selectedProvider !== undefined && onProviderSelect && ( + )} - - - {timestampAge} - ({timestampFormatted}) - - - {t("finalized")} -
- {metadata && selectedProvider !== undefined && onProviderSelect && ( - - )} -
- -
- {/* Transactions */} -
- {t("transactions")} - - - {block.transactions ? block.transactions.length : 0} {t("transactions")} - {" "} - {t("inThisBlock")} -
- {/* Withdrawals count */} - {block.withdrawals && block.withdrawals.length > 0 && ( +
+ {/* Transactions */}
- {t("withdrawals")} + {t("transactions")} - {block.withdrawals.length}{" "} - {block.withdrawals.length !== 1 ? t("withdrawalsPlural") : t("withdrawal")}{" "} + + {block.transactions ? block.transactions.length : 0} {t("transactions")} + {" "} {t("inThisBlock")}
- )} - - {/* Fee Recipient (Miner) */} -
- {t("feeRecipient")} - - {networkId ? ( - - {block.miner} - - ) : ( - block.miner - )} - -
- - {/* Gas Used */} -
- {t("gasUsed")} - - {Number(block.gasUsed).toLocaleString()} - ({gasUsedPct}%) - -
- - {/* Gas Limit */} -
- {t("gasLimit")} - {Number(block.gasLimit).toLocaleString()} -
- {/* Base Fee Per Gas */} - {block.baseFeePerGas && ( -
- {t("baseFeePerGas")} - {formatGwei(block.baseFeePerGas)} -
- )} + {/* Withdrawals count */} + {block.withdrawals && block.withdrawals.length > 0 && ( +
+ {t("withdrawals")} + + {block.withdrawals.length}{" "} + {block.withdrawals.length !== 1 ? t("withdrawalsPlural") : t("withdrawal")}{" "} + {t("inThisBlock")} + +
+ )} - {/* Burnt Fees */} - {burntFees && ( + {/* Fee Recipient (Miner) */}
- {t("burntFees")}: - - 🔥 {formatEth(burntFees)} + {t("feeRecipient")} + + {networkId ? ( + + {block.miner} + + ) : ( + block.miner + )}
- )} - {/* Extra Data */} - {block.extraData && block.extraData !== "0x" && ( + {/* Gas Used */}
- {t("extraData")}: + {t("gasUsed")} - + {Number(block.gasUsed).toLocaleString()} + ({gasUsedPct}%)
- )} - {/* Difficulty */} - {Number(block.difficulty) > 0 && ( + {/* Gas Limit */}
- {t("difficulty")}: - {Number(block.difficulty).toLocaleString()} + {t("gasLimit")} + {Number(block.gasLimit).toLocaleString()}
- )} - {/* Total Difficulty */} - {Number(block.totalDifficulty) > 0 && ( -
- {t("totalDifficulty")}: - {Number(block.totalDifficulty).toLocaleString()} -
- )} + {/* Base Fee Per Gas */} + {block.baseFeePerGas && ( +
+ {t("baseFeePerGas")} + {formatGwei(block.baseFeePerGas)} +
+ )} - {/* Size */} -
- {t("size")}: - {Number(block.size).toLocaleString()} bytes -
+ {/* Burnt Fees */} + {burntFees && ( +
+ {t("burntFees")}: + + 🔥 {formatNative(burntFees)} + +
+ )} - {/* Arbitrum-specific fields */} - {isArbitrumBlock(block) && ( - <> -
- {t("l1BlockNumber")}: - {Number(block.l1BlockNumber).toLocaleString()} + {/* Extra Data */} + {block.extraData && block.extraData !== "0x" && ( +
+ {t("extraData")}: + + +
-
- {t("sendCount")}: - {block.sendCount} + )} + + {/* Difficulty */} + {Number(block.difficulty) > 0 && ( +
+ {t("difficulty")}: + {Number(block.difficulty).toLocaleString()}
-
- {t("sendRoot")}: - {block.sendRoot} + )} + + {/* Total Difficulty */} + {Number(block.totalDifficulty) > 0 && ( +
+ {t("totalDifficulty")}: + {Number(block.totalDifficulty).toLocaleString()}
- - )} + )} - {/* More Details (collapsible) */} -
- {/** biome-ignore lint/a11y/useButtonType: */} - - - {showMoreDetails && ( -
-
- Hash: - {block.hash} -
-
- Parent Hash: - - {networkId && - block.parentHash !== - "0x0000000000000000000000000000000000000000000000000000000000000000" ? ( - - {block.parentHash} - - ) : ( - block.parentHash - )} - -
-
- State Root: - {block.stateRoot} -
-
- Transactions Root: - {block.transactionsRoot} -
-
- Receipts Root: - {block.receiptsRoot} -
- {block.withdrawalsRoot && ( -
- Withdrawals Root: - {block.withdrawalsRoot} -
- )} -
- Logs Bloom: -
- {block.logsBloom} -
-
-
- Nonce: - {block.nonce} + {/* Size */} +
+ {t("size")}: + {Number(block.size).toLocaleString()} bytes +
+ + {/* Arbitrum-specific fields */} + {isArbitrumBlock(block) && ( + <> +
+ {t("l1BlockNumber")}: + {Number(block.l1BlockNumber).toLocaleString()}
-
- Mix Hash: - {block.mixHash} +
+ {t("sendCount")}: + {block.sendCount}
-
- Sha3 Uncles: - {block.sha3Uncles} +
+ {t("sendRoot")}: + {block.sendRoot}
-
+ )} -
-
- {/* Transactions List */} - {block.transactions && block.transactions.length > 0 && ( -
-
+ {/* More Details (collapsible) */} +
{/** biome-ignore lint/a11y/useButtonType: */} -
- {showTransactions && ( -
- {block.transactions.map((txHash, index) => ( -
- {index} - - {networkId ? ( - - {txHash} + + {showMoreDetails && ( +
+
+ Hash: + {block.hash} +
+
+ Parent Hash: + + {networkId && + block.parentHash !== + "0x0000000000000000000000000000000000000000000000000000000000000000" ? ( + + {block.parentHash} ) : ( - txHash + block.parentHash )}
- ))} -
- )} +
+ State Root: + {block.stateRoot} +
+
+ Transactions Root: + {block.transactionsRoot} +
+
+ Receipts Root: + {block.receiptsRoot} +
+ {block.withdrawalsRoot && ( +
+ Withdrawals Root: + {block.withdrawalsRoot} +
+ )} +
+ Logs Bloom: +
+ {block.logsBloom} +
+
+
+ Nonce: + {block.nonce} +
+
+ Mix Hash: + {block.mixHash} +
+
+ Sha3 Uncles: + {block.sha3Uncles} +
+
+ )} +
- )} - {/* Withdrawals List */} - {block.withdrawals && block.withdrawals.length > 0 && ( -
-
- {/** biome-ignore lint/a11y/useButtonType: */} - + {/* Transactions List */} + {block.transactions && block.transactions.length > 0 && ( +
+
+ {/** biome-ignore lint/a11y/useButtonType: */} + +
+ {showTransactions && ( +
+ {block.transactions.map((txHash, index) => ( +
+ {index} + + {networkId ? ( + + {txHash} + + ) : ( + txHash + )} + +
+ ))} +
+ )}
- {showWithdrawals && ( -
- {block.withdrawals.map((withdrawal, index) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: -
-
{index}
-
-
- {t("index")} - - {Number(withdrawal.index).toLocaleString()} - -
-
- {t("validator")} - - {Number(withdrawal.validatorIndex).toLocaleString()} - -
-
- {t("address")} - - {networkId ? ( - - {withdrawal.address} - - ) : ( - withdrawal.address - )} - -
-
- {t("amount")} - - {(Number(withdrawal.amount) / 1e9).toFixed(9)} ETH - + )} + + {/* Withdrawals List */} + {block.withdrawals && block.withdrawals.length > 0 && ( +
+
+ {/** biome-ignore lint/a11y/useButtonType: */} + +
+ {showWithdrawals && ( +
+ {block.withdrawals.map((withdrawal, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: +
+
{index}
+
+
+ {t("index")} + + {Number(withdrawal.index).toLocaleString()} + +
+
+ {t("validator")} + + {Number(withdrawal.validatorIndex).toLocaleString()} + +
+
+ {t("address")} + + {networkId ? ( + + {withdrawal.address} + + ) : ( + withdrawal.address + )} + +
+
+ {t("amount")} + + {(Number(withdrawal.amount) / 1e9).toFixed(9)} ETH + +
-
- ))} -
- )} -
- )} + ))} +
+ )} +
+ )} +
+
); }, diff --git a/src/components/pages/evm/tx/TransactionDisplay.tsx b/src/components/pages/evm/tx/TransactionDisplay.tsx index ec44b3b..4cd5c57 100644 --- a/src/components/pages/evm/tx/TransactionDisplay.tsx +++ b/src/components/pages/evm/tx/TransactionDisplay.tsx @@ -1,13 +1,27 @@ import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; +import AIAnalysisPanel from "../../../common/AIAnalysis/AIAnalysisPanel"; import LongString from "../../../../components/common/LongString"; import { RPCIndicator } from "../../../../components/common/RPCIndicator"; +import { getNetworkById } from "../../../../config/networks"; import { AppContext } from "../../../../context"; import { useSourcify } from "../../../../hooks/useSourcify"; +import { useTransactionPreAnalysis } from "../../../../hooks/useTransactionPreAnalysis"; import type { DataService } from "../../../../services/DataService"; +import { + fetchToken, + fetchTokenList, + type TokenListItem, + type TokenMetadata, +} from "../../../../services/MetadataService"; import type { TraceResult } from "../../../../services/adapters/NetworkAdapter"; import { logger } from "../../../../utils/logger"; +import { + formatGweiFromWei, + formatNativeFromWei, + formatTokenAmount, +} from "../../../../utils/unitFormatters"; import type { RPCMetadata, Transaction, @@ -48,8 +62,16 @@ const TransactionDisplay: React.FC = React.memo( onProviderSelect, }) => { const { t } = useTranslation("transaction"); + const network = networkId ? getNetworkById(networkId) : undefined; + const networkName = network?.name ?? "Unknown Network"; + const networkCurrency = network?.currency ?? "ETH"; + const [_showRawData, _setShowRawData] = useState(false); const [_showLogs, _setShowLogs] = useState(false); + const [callTargetToken, setCallTargetToken] = useState(null); + const [callTargetTokenListMatch, setCallTargetTokenListMatch] = useState( + null, + ); const [showTrace, setShowTrace] = useState(false); const [traceData, setTraceData] = useState(null); // biome-ignore lint/suspicious/noExplicitAny: @@ -105,6 +127,51 @@ const TransactionDisplay: React.FC = React.memo( true, ); + useEffect(() => { + if (!networkId || !transaction.to) { + setCallTargetToken(null); + setCallTargetTokenListMatch(null); + return; + } + let cancelled = false; + const chainId = Number(networkId); + const target = transaction.to.toLowerCase(); + + fetchToken(chainId, target) + .then((token) => { + if (cancelled) return; + if (token) { + setCallTargetToken(token); + setCallTargetTokenListMatch(null); + return; + } + setCallTargetToken(null); + return fetchTokenList(chainId) + .then((list) => { + if (cancelled) return; + const match = list?.tokens.find((t) => t.address.toLowerCase() === target) ?? null; + setCallTargetTokenListMatch(match); + }) + .catch((err) => { + if (!cancelled) { + logger.warn("Failed to fetch token list for transaction target:", err); + setCallTargetTokenListMatch(null); + } + }); + }) + .catch((err) => { + if (!cancelled) { + logger.warn("Failed to fetch token metadata for transaction target:", err); + setCallTargetToken(null); + setCallTargetTokenListMatch(null); + } + }); + + return () => { + cancelled = true; + }; + }, [networkId, transaction.to]); + // Use local artifact data if available and sourcify is not verified, otherwise use sourcify const contractData = useMemo( () => (isVerified && sourcifyData ? sourcifyData : parsedLocalData), @@ -117,6 +184,181 @@ const TransactionDisplay: React.FC = React.memo( return decodeFunctionCall(transaction.data, contractData.abi); }, [contractData?.abi, transaction.data]); + // ERC-7730 pre-analysis for contract interactions + const { preAnalysis } = useTransactionPreAnalysis(transaction, Number(networkId)); + + // Build rich AI context combining transaction data + ERC-7730 pre-analysis + const aiContext = useMemo(() => { + const status = transaction.receipt?.status; + const isSuccess = status === "0x1" || status === "1"; + const fee = transaction.receipt + ? ( + BigInt(transaction.receipt.gasUsed) * BigInt(transaction.receipt.effectiveGasPrice) + ).toString() + : undefined; + const valueNative = formatNativeFromWei(transaction.value, networkCurrency, 6); + const gasPriceGwei = formatGweiFromWei(transaction.gasPrice, 2); + const maxFeePerGasGwei = transaction.maxFeePerGas + ? formatGweiFromWei(transaction.maxFeePerGas, 2) + : undefined; + const maxPriorityFeePerGasGwei = transaction.maxPriorityFeePerGas + ? formatGweiFromWei(transaction.maxPriorityFeePerGas, 2) + : undefined; + const transactionFeeNative = fee ? formatNativeFromWei(fee, networkCurrency, 6) : undefined; + + const tokenInfo = callTargetToken ?? callTargetTokenListMatch; + const tokenDecimals = + tokenInfo && typeof tokenInfo.decimals === "number" ? tokenInfo.decimals : undefined; + const tokenSymbol = tokenInfo?.symbol; + + const ctx: Record = { + hash: transaction.hash, + from: transaction.from, + to: transaction.to ?? "contract creation", + valueNative, + status: status ? (isSuccess ? "success" : "failed") : "pending", + gasUsed: transaction.receipt?.gasUsed, + gasPriceGwei, + maxFeePerGasGwei, + maxPriorityFeePerGasGwei, + transactionFeeNative, + blockNumber: transaction.blockNumber, + timestamp: transaction.timestamp, + nonce: transaction.nonce, + type: transaction.type, + isContractCreation: !transaction.to, + }; + if (tokenInfo) { + ctx.callTargetToken = { + name: tokenInfo.name, + symbol: tokenInfo.symbol, + decimals: tokenInfo.decimals, + type: "type" in tokenInfo ? tokenInfo.type : undefined, + source: callTargetToken ? "metadata" : "tokenList", + }; + } + + if (transaction.receipt?.contractAddress) { + ctx.contractAddress = transaction.receipt.contractAddress; + } + + // Decoded function call from ABI + if (decodedInput) { + ctx.decodedFunction = decodedInput.functionName; + ctx.decodedSignature = decodedInput.signature; + ctx.decodedParams = decodedInput.params.map((p) => ({ + name: p.name, + type: p.type, + value: p.value, + })); + } + + // Decoded event logs (first 10) + if (transaction.receipt?.logs?.length) { + const eventSummaries: { name: string; params?: { name: string; value: string }[] }[] = []; + for (const log of transaction.receipt.logs.slice(0, 10)) { + // biome-ignore lint/suspicious/noExplicitAny: log topics typing + const topics = (log as any).topics; + // biome-ignore lint/suspicious/noExplicitAny: log data typing + const data = (log as any).data || "0x"; + if (!topics) continue; + + let abiDec: DecodedInput | null = null; + const isFromRecipient = + transaction.to && + // biome-ignore lint/suspicious/noExplicitAny: log address typing + (log as any).address?.toLowerCase() === transaction.to.toLowerCase(); + if (isFromRecipient && contractData?.abi) { + abiDec = decodeEventWithAbi(topics, data, contractData.abi); + } + if (abiDec) { + eventSummaries.push({ + name: abiDec.functionName, + params: abiDec.params.slice(0, 4).map((p) => ({ name: p.name, value: p.value })), + }); + } else { + const decoded: DecodedEvent | null = decodeEventLog(topics, data); + if (decoded) { + eventSummaries.push({ name: decoded.name }); + } + } + } + if (eventSummaries.length > 0) { + ctx.eventLogs = eventSummaries; + } + } + + // L2-specific fields + const receipt = transaction.receipt; + if (receipt && "l1BlockNumber" in receipt) { + ctx.l1BlockNumber = (receipt as TransactionReceiptArbitrum).l1BlockNumber; + ctx.gasUsedForL1 = (receipt as TransactionReceiptArbitrum).gasUsedForL1; + } + if (receipt && "l1Fee" in receipt) { + const opReceipt = receipt as TransactionReceiptOptimism; + ctx.l1FeeNative = formatNativeFromWei(opReceipt.l1Fee, networkCurrency, 6); + ctx.l1GasPriceGwei = formatGweiFromWei(opReceipt.l1GasPrice, 2); + ctx.l1GasUsed = opReceipt.l1GasUsed; + } + + // ERC-7730 pre-analysis data + if (preAnalysis) { + ctx.erc7730Intent = preAnalysis.intent; + ctx.erc7730Confidence = preAnalysis.confidence; + if (preAnalysis.fields.length > 0) { + ctx.erc7730Fields = preAnalysis.fields.map((f) => { + const base = { + label: f.label, + value: f.value, + format: f.format, + rawValue: f.rawValue, + path: f.path, + }; + if (f.format === "tokenAmount" && tokenDecimals !== undefined && tokenSymbol) { + const raw = + typeof f.rawValue === "string" || typeof f.rawValue === "number" + ? String(f.rawValue) + : typeof f.rawValue === "bigint" + ? f.rawValue.toString() + : undefined; + const formatted = raw + ? formatTokenAmount(raw, tokenDecimals, 6, tokenSymbol) + : undefined; + return { + ...base, + tokenSymbol, + tokenDecimals, + formattedValue: formatted, + }; + } + return base; + }); + } + if (preAnalysis.warnings.length > 0) { + ctx.erc7730Warnings = preAnalysis.warnings.map((w) => ({ + type: w.type, + severity: w.severity, + message: w.message, + })); + } + if (preAnalysis.metadata.protocol) { + ctx.erc7730Protocol = preAnalysis.metadata.protocol; + } + ctx.erc7730Function = preAnalysis.functionName; + ctx.erc7730Signature = preAnalysis.signature; + } + + return ctx; + }, [ + transaction, + decodedInput, + contractData?.abi, + preAnalysis, + networkCurrency, + callTargetToken, + callTargetTokenListMatch, + ]); + // Check if trace is available (localhost only) const isTraceAvailable = dataService?.networkAdapter.isTraceAvailable() || false; @@ -171,23 +413,12 @@ const TransactionDisplay: React.FC = React.memo( return `${str.slice(0, start)}...${str.slice(-end)}`; }, []); - const formatValue = useCallback((value: string) => { - try { - const eth = Number(value) / 1e18; - return `${eth.toFixed(6)} ETH`; - } catch (_e) { - return value; - } - }, []); + const formatValue = useCallback( + (value: string) => formatNativeFromWei(value, networkCurrency, 6) ?? value, + [networkCurrency], + ); - const formatGwei = useCallback((value: string) => { - try { - const gwei = Number(value) / 1e9; - return `${gwei.toFixed(2)} Gwei`; - } catch (_e) { - return value; - } - }, []); + const formatGwei = useCallback((value: string) => formatGweiFromWei(value, 2) ?? value, []); const parseTimestampToMs = useCallback((timestamp?: string) => { if (!timestamp) return null; @@ -274,554 +505,579 @@ const TransactionDisplay: React.FC = React.memo( ); return ( -
-
- {t("transactionDetails")} - {metadata && selectedProvider !== undefined && onProviderSelect && ( - - )} -
- - {/* Row-based layout like Etherscan */} -
- {/* Transaction Hash */} -
- {t("transactionHash")} - - - -
- - {/* Status */} -
- {t("status")} - {getStatusBadge(transaction.receipt?.status)} -
- - {/* Block */} -
- {t("block")} - - {networkId ? ( - - {Number(transaction.blockNumber).toLocaleString()} - - ) : ( - Number(transaction.blockNumber).toLocaleString() - )} - {confirmations !== null && ( - - {confirmations > 100 ? "+100" : confirmations.toLocaleString()}{" "} - {t("blockConfirmations")} - - )} - +
+
+
+ {t("transactionDetails")} + {metadata && selectedProvider !== undefined && onProviderSelect && ( + + )}
- {/* Timestamp */} - {formattedTimestamp && ( + {/* Row-based layout like Etherscan */} +
+ {/* Transaction Hash */}
- {t("timestamp")} - - {timestampAge && {timestampAge}} - ({formattedTimestamp}) + {t("transactionHash")} + +
- )} - - {/* From */} -
- {t("from")} - - {networkId ? ( - - {transaction.from} - - ) : ( - transaction.from - )} - -
- {/* To */} -
- {transaction.to ? t("to") : t("interactedWith")} - - {transaction.to ? ( - networkId ? ( - - {transaction.to} - - ) : ( - transaction.to - ) - ) : ( - {t("contractCreation")} - )} - -
+ {/* Status */} +
+ {t("status")} + {getStatusBadge(transaction.receipt?.status)} +
- {/* Contract Address (if created) */} - {transaction.receipt?.contractAddress && ( + {/* Block */}
- {t("contractCreated")} - + {t("block")} + {networkId ? ( - {transaction.receipt.contractAddress} + {Number(transaction.blockNumber).toLocaleString()} ) : ( - transaction.receipt.contractAddress + Number(transaction.blockNumber).toLocaleString() + )} + {confirmations !== null && ( + + {confirmations > 100 ? "+100" : confirmations.toLocaleString()}{" "} + {t("blockConfirmations")} + )}
- )} - - {/* Value */} -
- {t("value")} - {formatValue(transaction.value)} -
- {/* Transaction Fee */} -
- {t("transactionFee")} - - {transaction.receipt - ? formatValue( - ( - BigInt(transaction.receipt.gasUsed) * - BigInt(transaction.receipt.effectiveGasPrice) - ).toString(), - ) - : t("pending")} - -
+ {/* Timestamp */} + {formattedTimestamp && ( +
+ {t("timestamp")} + + {timestampAge && {timestampAge}} + ({formattedTimestamp}) + +
+ )} - {/* Gas Price */} -
- {t("gasPrice")} - {formatGwei(transaction.gasPrice)} -
+ {/* From */} +
+ {t("from")} + + {networkId ? ( + + {transaction.from} + + ) : ( + transaction.from + )} + +
- {/* Gas Limit & Usage */} -
- {t("gasLimitUsage")} - - {Number(transaction.gas).toLocaleString()} - {transaction.receipt && ( - <> - {" | "} - {Number(transaction.receipt.gasUsed).toLocaleString()} - - ( - {( - (Number(transaction.receipt.gasUsed) / Number(transaction.gas)) * - 100 - ).toFixed(1)} - %) - - - )} - -
+ {/* To */} +
+ {transaction.to ? t("to") : t("interactedWith")} + + {transaction.to ? ( + networkId ? ( + + {transaction.to} + + ) : ( + transaction.to + ) + ) : ( + {t("contractCreation")} + )} + +
- {/* Effective Gas Price (if different from gas price) */} - {transaction.receipt && - transaction.receipt.effectiveGasPrice !== transaction.gasPrice && ( + {/* Contract Address (if created) */} + {transaction.receipt?.contractAddress && (
- {t("effectiveGasPrice")} - - {formatGwei(transaction.receipt.effectiveGasPrice)} + {t("contractCreated")} + + {networkId ? ( + + {transaction.receipt.contractAddress} + + ) : ( + transaction.receipt.contractAddress + )}
)} - {/* Arbitrum-specific fields */} - {isArbitrumTx(transaction) && - transaction.receipt && - isArbitrumReceipt(transaction.receipt) && ( - <> -
- {t("l1BlockNumber")} + {/* Value */} +
+ {t("value")} + {formatValue(transaction.value)} +
+ + {/* Transaction Fee */} +
+ {t("transactionFee")} + + {transaction.receipt + ? formatValue( + ( + BigInt(transaction.receipt.gasUsed) * + BigInt(transaction.receipt.effectiveGasPrice) + ).toString(), + ) + : t("pending")} + +
+ + {/* Gas Price */} +
+ {t("gasPrice")} + {formatGwei(transaction.gasPrice)} +
+ + {/* Gas Limit & Usage */} +
+ {t("gasLimitUsage")} + + {Number(transaction.gas).toLocaleString()} + {transaction.receipt && ( + <> + {" | "} + {Number(transaction.receipt.gasUsed).toLocaleString()} + + ( + {( + (Number(transaction.receipt.gasUsed) / Number(transaction.gas)) * + 100 + ).toFixed(1)} + %) + + + )} + +
+ + {/* Effective Gas Price (if different from gas price) */} + {transaction.receipt && + transaction.receipt.effectiveGasPrice !== transaction.gasPrice && ( +
+ {t("effectiveGasPrice")} - {Number(transaction.receipt.l1BlockNumber).toLocaleString()} + {formatGwei(transaction.receipt.effectiveGasPrice)}
-
- {t("gasUsedForL1")} + )} + + {/* Arbitrum-specific fields */} + {isArbitrumTx(transaction) && + transaction.receipt && + isArbitrumReceipt(transaction.receipt) && ( + <> +
+ {t("l1BlockNumber")} + + {Number(transaction.receipt.l1BlockNumber).toLocaleString()} + +
+
+ {t("gasUsedForL1")} + + {Number(transaction.receipt.gasUsedForL1).toLocaleString()} + +
+ + )} + + {/* OP Stack fields (Optimism, Base) */} + {transaction.receipt && isOptimismReceipt(transaction.receipt) && ( + <> +
+ {t("l1Fee")} + {formatValue(transaction.receipt.l1Fee)} +
+
+ {t("l1GasPrice")} + {formatGwei(transaction.receipt.l1GasPrice)} +
+
+ {t("l1GasUsed")} - {Number(transaction.receipt.gasUsedForL1).toLocaleString()} + {Number(transaction.receipt.l1GasUsed).toLocaleString()}
+
+ {t("l1FeeScalar")} + {transaction.receipt.l1FeeScalar} +
)} - {/* OP Stack fields (Optimism, Base) */} - {transaction.receipt && isOptimismReceipt(transaction.receipt) && ( - <> -
- {t("l1Fee")} - {formatValue(transaction.receipt.l1Fee)} -
-
- {t("l1GasPrice")} - {formatGwei(transaction.receipt.l1GasPrice)} -
-
- {t("l1GasUsed")} - - {Number(transaction.receipt.l1GasUsed).toLocaleString()} + {/* Other Attributes (Nonce, Index, Type) */} +
+ {t("otherAttributes")} + + + {t("nonce")} {transaction.nonce} + + + {t("position")} {transaction.transactionIndex} + + + {t("type")} {transaction.type} -
-
- {t("l1FeeScalar")} - {transaction.receipt.l1FeeScalar} -
- - )} - - {/* Other Attributes (Nonce, Index, Type) */} -
- {t("otherAttributes")} - - - {t("nonce")} {transaction.nonce} - - - {t("position")} {transaction.transactionIndex} - - - {t("type")} {transaction.type} - -
- - {/* Input Data */} -
- {t("inputData")} - {transaction.data && transaction.data !== "0x" ? ( -
- {transaction.data} -
- ) : ( - 0x - )} -
+
- {/* Decoded Input Data */} - {decodedInput && ( + {/* Input Data */}
- {t("decodedInput")} -
-
- {decodedInput.functionName} - {decodedInput.signature} + {t("inputData")} + {transaction.data && transaction.data !== "0x" ? ( +
+ {transaction.data}
- {decodedInput.params.length > 0 && ( -
- {decodedInput.params.map((param, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: params have stable order -
- {param.name} - ({param.type}) - - {param.type === "address" && networkId ? ( - - {param.value} - - ) : ( - formatDecodedValue(param.value, param.type) - )} - -
- ))} -
- )} -
+ ) : ( + 0x + )}
- )} -
- {/* Event Logs Section - Always visible */} - {transaction.receipt && transaction.receipt.logs.length > 0 && ( -
-
- - {t("eventLogs")} ({transaction.receipt.logs.length}) - -
-
- {/** biome-ignore lint/suspicious/noExplicitAny: */} - {transaction.receipt.logs.map((log: any, index: number) => { - // Try ABI-based decoding first if log is from tx.to and we have contract data - let decoded: DecodedEvent | null = null; - let abiDecoded: DecodedInput | null = null; - - const isFromTxRecipient = - transaction.to && - log.address && - log.address.toLowerCase() === transaction.to.toLowerCase(); - - if (isFromTxRecipient && contractData?.abi && log.topics) { - abiDecoded = decodeEventWithAbi(log.topics, log.data || "0x", contractData.abi); - } - - // Fallback to standard event lookup - if (!abiDecoded && log.topics) { - decoded = decodeEventLog(log.topics, log.data || "0x"); - } - - // Determine which decoded data to display - const hasDecoded = abiDecoded || decoded; - const displayName = abiDecoded?.functionName || decoded?.name; - const displaySignature = abiDecoded?.signature || decoded?.fullSignature; - const displayParams = abiDecoded?.params || decoded?.params || []; - - return ( - // biome-ignore lint/suspicious/noArrayIndexKey: -
-
{index}
-
- {/* Decoded Event Header */} - {hasDecoded && ( -
- - {displayName} - + {/* Decoded Input Data */} + {decodedInput && ( +
+ {t("decodedInput")} +
+
+ {decodedInput.functionName} + {decodedInput.signature} +
+ {decodedInput.params.length > 0 && ( +
+ {decodedInput.params.map((param, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: params have stable order +
+ {param.name} + ({param.type}) - {displaySignature} + {param.type === "address" && networkId ? ( + + {param.value} + + ) : ( + formatDecodedValue(param.value, param.type) + )} - {abiDecoded && ( - - {t("logsAbi")} - - )}
- )} - - {/* Address */} -
- {t("logsAddress")} - - {networkId ? ( - + )} +
+
+ )} +
+ + {/* Event Logs Section - Always visible */} + {transaction.receipt && transaction.receipt.logs.length > 0 && ( +
+
+ + {t("eventLogs")} ({transaction.receipt.logs.length}) + +
+
+ {/** biome-ignore lint/suspicious/noExplicitAny: */} + {transaction.receipt.logs.map((log: any, index: number) => { + // Try ABI-based decoding first if log is from tx.to and we have contract data + let decoded: DecodedEvent | null = null; + let abiDecoded: DecodedInput | null = null; + + const isFromTxRecipient = + transaction.to && + log.address && + log.address.toLowerCase() === transaction.to.toLowerCase(); + + if (isFromTxRecipient && contractData?.abi && log.topics) { + abiDecoded = decodeEventWithAbi(log.topics, log.data || "0x", contractData.abi); + } + + // Fallback to standard event lookup + if (!abiDecoded && log.topics) { + decoded = decodeEventLog(log.topics, log.data || "0x"); + } + + // Determine which decoded data to display + const hasDecoded = abiDecoded || decoded; + const displayName = abiDecoded?.functionName || decoded?.name; + const displaySignature = abiDecoded?.signature || decoded?.fullSignature; + const displayParams = abiDecoded?.params || decoded?.params || []; + + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: +
+
{index}
+
+ {/* Decoded Event Header */} + {hasDecoded && ( +
+ - {log.address} - - ) : ( - log.address - )} - -
+ {displayName} + + + {displaySignature} + + {abiDecoded && ( + + {t("logsAbi")} + + )} +
+ )} + + {/* Address */} +
+ {t("logsAddress")} + + {networkId ? ( + + {log.address} + + ) : ( + log.address + )} + +
- {/* Decoded Parameters */} - {displayParams.length > 0 && ( -
- {t("logsDecoded")} -
- {displayParams.map((param, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: -
- {param.name} - ({param.type}) - - {param.type === "address" && networkId ? ( - - {param.value} - - ) : ( - formatDecodedValue(param.value, param.type) + {/* Decoded Parameters */} + {displayParams.length > 0 && ( +
+ {t("logsDecoded")} +
+ {displayParams.map((param, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: +
+ {param.name} + ({param.type}) + + {param.type === "address" && networkId ? ( + + {param.value} + + ) : ( + formatDecodedValue(param.value, param.type) + )} + + {param.indexed && ( + {t("logsIndexed")} )} - - {param.indexed && ( - {t("logsIndexed")} - )} -
- ))} +
+ ))} +
-
- )} + )} - {/* Raw Topics (collapsed if decoded) */} - {log.topics && log.topics.length > 0 && ( -
- - {hasDecoded ? t("logsRawTopics") : t("logsTopics")} - -
- {log.topics.map((topic: string, i: number) => ( -
- [{i}] - {topic} -
- ))} + {/* Raw Topics (collapsed if decoded) */} + {log.topics && log.topics.length > 0 && ( +
+ + {hasDecoded ? t("logsRawTopics") : t("logsTopics")} + +
+ {log.topics.map((topic: string, i: number) => ( +
+ [{i}] + {topic} +
+ ))} +
-
- )} + )} - {/* Raw Data */} - {log.data && log.data !== "0x" && ( -
- - {hasDecoded ? t("logsRawData") : t("logsData")} - -
- {log.data} + {/* Raw Data */} + {log.data && log.data !== "0x" && ( +
+ + {hasDecoded ? t("logsRawData") : t("logsData")} + +
+ {log.data} +
-
- )} + )} +
-
- ); - })} + ); + })} +
-
- )} - - {/* Debug Trace Section (Localhost Only) */} - {isTraceAvailable && ( -
- {/** biome-ignore lint/a11y/useButtonType: */} - - - {showTrace && ( -
- {loadingTrace &&
{t("loadingTrace")}
} - - {/* Call Trace */} - {callTrace && ( -
-
{t("callTrace")}
-
-
- {t("traceType")} {callTrace.type} -
-
- {t("traceFrom")}{" "} - -
-
- {t("traceTo")}{" "} - -
-
- {t("traceValue")} {callTrace.value} -
-
- {t("traceGas")} {callTrace.gas} -
-
- {t("traceGasUsed")} {callTrace.gasUsed} -
- {callTrace.error && ( -
- {t("traceError")} {callTrace.error} + )} + + {/* Debug Trace Section (Localhost Only) */} + {isTraceAvailable && ( +
+ {/** biome-ignore lint/a11y/useButtonType: */} + + + {showTrace && ( +
+ {loadingTrace &&
{t("loadingTrace")}
} + + {/* Call Trace */} + {callTrace && ( +
+
{t("callTrace")}
+
+
+ {t("traceType")} {callTrace.type} +
+
+ {t("traceFrom")}{" "} + +
+
+ {t("traceTo")}{" "} + +
+
+ {t("traceValue")} {callTrace.value} +
+
+ {t("traceGas")} {callTrace.gas} +
+
+ {t("traceGasUsed")} {callTrace.gasUsed}
- )} - {callTrace.calls && callTrace.calls.length > 0 && ( -
-
- {t("internalCalls")} ({callTrace.calls.length}): + {callTrace.error && ( +
+ {t("traceError")} {callTrace.error}
-
- {JSON.stringify(callTrace.calls, null, 2)} + )} + {callTrace.calls && callTrace.calls.length > 0 && ( +
+
+ {t("internalCalls")} ({callTrace.calls.length}): +
+
+ {JSON.stringify(callTrace.calls, null, 2)} +
-
- )} + )} +
-
- )} + )} - {/* Opcode Trace */} - {traceData && ( -
-
{t("executionTrace")}
-
-
- {t("opcodeTrace.totalGasUsed")}:{" "} - {traceData.gas} -
-
- {t("opcodeTrace.failed")}:{" "} - {traceData.failed ? t("opcodeTrace.yes") : t("opcodeTrace.no")} -
-
- {t("opcodeTrace.returnValue")}:{" "} - -
-
- {t("opcodeTrace.executed")}{" "} - {traceData.structLogs.length} + {/* Opcode Trace */} + {traceData && ( +
+
{t("executionTrace")}
+
+
+ {t("opcodeTrace.totalGasUsed")}:{" "} + {traceData.gas} +
+
+ {t("opcodeTrace.failed")}:{" "} + {traceData.failed ? t("opcodeTrace.yes") : t("opcodeTrace.no")} +
+
+ {t("opcodeTrace.returnValue")}:{" "} + +
+
+ {t("opcodeTrace.executed")}{" "} + {traceData.structLogs.length} +
-
-
{t("opcodeTrace.executionLog")}
-
- {traceData.structLogs.slice(0, 100).map((log, index) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: -
-
- {t("opcodeTrace.step")} {index}: {log.op} +
{t("opcodeTrace.executionLog")}
+
+ {traceData.structLogs.slice(0, 100).map((log, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: +
+
+ {t("opcodeTrace.step")} {index}: {log.op} +
+
+ {t("opcodeTrace.PC")}: {log.pc} | {t("opcodeTrace.gas")}: {log.gas} |{" "} + {t("opcodeTrace.cost")}: {log.gasCost} | {t("opcodeTrace.depth")}:{" "} + {log.depth} +
+ {log.stack && log.stack.length > 0 && ( +
+ {t("opcodeTrace.stack")}: [{log.stack.slice(0, 3).join(", ")} + {log.stack.length > 3 ? "..." : ""}] +
+ )}
-
- {t("opcodeTrace.PC")}: {log.pc} | {t("opcodeTrace.gas")}: {log.gas} |{" "} - {t("opcodeTrace.cost")}: {log.gasCost} | {t("opcodeTrace.depth")}:{" "} - {log.depth} + ))} + {traceData.structLogs.length > 100 && ( +
+ {t("opcodeTrace.showingFirst100", { + total: traceData.structLogs.length, + })}
- {log.stack && log.stack.length > 0 && ( -
- {t("opcodeTrace.stack")}: [{log.stack.slice(0, 3).join(", ")} - {log.stack.length > 3 ? "..." : ""}] -
- )} -
- ))} - {traceData.structLogs.length > 100 && ( -
- {t("opcodeTrace.showingFirst100", { total: traceData.structLogs.length })} -
- )} + )} +
-
- )} -
- )} -
- )} + )} +
+ )} +
+ )} +
+
); }, diff --git a/src/components/pages/settings/index.tsx b/src/components/pages/settings/index.tsx index aa63343..3a39d47 100644 --- a/src/components/pages/settings/index.tsx +++ b/src/components/pages/settings/index.tsx @@ -8,7 +8,9 @@ import { useSettings } from "../../../context/SettingsContext"; import { useMetaMaskExplorer } from "../../../hooks/useMetaMaskExplorer"; import { SUPPORTED_LANGUAGES } from "../../../i18n"; import { clearSupportersCache } from "../../../services/MetadataService"; -import type { RPCUrls, RpcUrlsContextType } from "../../../types"; +import type { AIProvider, RPCUrls, RpcUrlsContextType } from "../../../types"; +import { AI_PROVIDERS, AI_PROVIDER_ORDER } from "../../../config/aiProviders"; +import { clearAICache } from "../../common/AIAnalysis/aiCache"; import { logger } from "../../../utils/logger"; import { getChainIdFromNetwork } from "../../../utils/networkResolver"; @@ -71,11 +73,20 @@ const Settings: React.FC = () => { const [localApiKeys, setLocalApiKeys] = useState({ infura: settings.apiKeys?.infura || "", alchemy: settings.apiKeys?.alchemy || "", + groq: settings.apiKeys?.groq || "", + openai: settings.apiKeys?.openai || "", + anthropic: settings.apiKeys?.anthropic || "", + togetherai: settings.apiKeys?.togetherai || "", }); const [showApiKeys, setShowApiKeys] = useState({ infura: false, alchemy: false, + groq: false, + openai: false, + anthropic: false, + togetherai: false, }); + const [aiKeysExpanded, setAiKeysExpanded] = useState(false); const [metamaskStatus, setMetamaskStatus] = useState< Record >({}); @@ -108,6 +119,8 @@ const Settings: React.FC = () => { clearSupportersCache(); // Clear localStorage caches if any localStorage.removeItem("openscan_cache"); + // Clear AI analysis cache + clearAICache(); setCacheCleared(true); setTimeout(() => setCacheCleared(false), 3000); }, []); @@ -408,6 +421,10 @@ const Settings: React.FC = () => { apiKeys: { infura: localApiKeys.infura || undefined, alchemy: localApiKeys.alchemy || undefined, + groq: localApiKeys.groq || undefined, + openai: localApiKeys.openai || undefined, + anthropic: localApiKeys.anthropic || undefined, + togetherai: localApiKeys.togetherai || undefined, }, }); @@ -429,6 +446,11 @@ const Settings: React.FC = () => { }); }, []); + const primaryAIProviderId: AIProvider = "groq"; + const otherAIProviderIds = AI_PROVIDER_ORDER.filter( + (providerId) => providerId !== primaryAIProviderId, + ); + return ( <> {/* Fixed Toast Notifications */} @@ -684,6 +706,126 @@ const Settings: React.FC = () => {
+ + {/* AI Provider API Keys */} +
+

🤖 {t("apiKeys.aiTitle")}

+

{t("apiKeys.aiDescription")}

+ +
+
+ + {t(`apiKeys.${primaryAIProviderId}.name`)} + + + {t(`apiKeys.${primaryAIProviderId}.getKey`)} → + +
+
+ + setLocalApiKeys((prev) => ({ + ...prev, + [primaryAIProviderId]: e.target.value, + })) + } + placeholder={t(`apiKeys.${primaryAIProviderId}.placeholder`)} + /> + +
+
+ + + + {aiKeysExpanded && ( +
+ {otherAIProviderIds.map((providerId) => { + const provider = AI_PROVIDERS[providerId]; + return ( +
+
+ + {t(`apiKeys.${providerId}.name`)} + + + {t(`apiKeys.${providerId}.getKey`)} → + +
+
+ + setLocalApiKeys((prev) => ({ + ...prev, + [providerId]: e.target.value, + })) + } + placeholder={t(`apiKeys.${providerId}.placeholder`)} + /> + +
+
+ ); + })} +
+ )} +
{/* Save Button - positioned after general settings */} diff --git a/src/config/aiProviders.ts b/src/config/aiProviders.ts new file mode 100644 index 0000000..393038b --- /dev/null +++ b/src/config/aiProviders.ts @@ -0,0 +1,42 @@ +import type { AIProvider, AIProviderConfig } from "../types"; + +/** + * Static configuration for supported AI providers. + * No API keys stored here - users provide their own keys via Settings. + */ +export const AI_PROVIDERS: Record = { + groq: { + id: "groq", + name: "Groq", + baseUrl: "https://api.groq.com/openai/v1", + defaultModel: "llama-3.3-70b-versatile", + keyUrl: "https://console.groq.com/keys", + }, + openai: { + id: "openai", + name: "OpenAI", + baseUrl: "https://api.openai.com/v1", + defaultModel: "gpt-4o-mini", + keyUrl: "https://platform.openai.com/api-keys", + }, + anthropic: { + id: "anthropic", + name: "Anthropic", + baseUrl: "https://api.anthropic.com/v1", + defaultModel: "claude-sonnet-4-5-20250929", + keyUrl: "https://console.anthropic.com/settings/keys", + }, + togetherai: { + id: "togetherai", + name: "Together AI", + baseUrl: "https://api.together.xyz/v1", + defaultModel: "meta-llama/Llama-3.3-70B-Instruct-Turbo", + keyUrl: "https://api.together.xyz/settings/api-keys", + }, +}; + +/** + * Ordered list of AI provider IDs for priority resolution. + * When resolving which provider to use, the first provider with a configured key wins. + */ +export const AI_PROVIDER_ORDER: AIProvider[] = ["groq", "openai", "anthropic", "togetherai"]; diff --git a/src/hooks/useAIAnalysis.ts b/src/hooks/useAIAnalysis.ts new file mode 100644 index 0000000..9d791f5 --- /dev/null +++ b/src/hooks/useAIAnalysis.ts @@ -0,0 +1,114 @@ +import { useCallback, useState } from "react"; +import { AI_PROVIDERS, AI_PROVIDER_ORDER } from "../config/aiProviders"; +import { useSettings } from "../context/SettingsContext"; +import { AIService, AIServiceError } from "../services/AIService"; +import type { AIAnalysisResult, AIAnalysisType, AIProvider } from "../types"; +import { + getCachedAnalysis, + hashContext, + setCachedAnalysis, +} from "../components/common/AIAnalysis/aiCache"; +import { logger } from "../utils/logger"; + +interface UseAIAnalysisReturn { + result: AIAnalysisResult | null; + loading: boolean; + error: string | null; + errorType: string | null; + analyze: () => Promise; + refresh: () => Promise; +} + +/** + * Hook for AI-powered blockchain analysis. + * Resolves the first available AI provider from user settings, + * manages cache with context-hash invalidation, and handles errors. + */ +export function useAIAnalysis( + analysisType: AIAnalysisType, + context: Record, + networkName: string, + networkCurrency: string, + cacheKey: string, + language?: string, +): UseAIAnalysisReturn { + const { settings } = useSettings(); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [errorType, setErrorType] = useState(null); + + const resolveProvider = useCallback((): { + provider: (typeof AI_PROVIDERS)[AIProvider]; + apiKey: string; + } | null => { + const apiKeys = settings.apiKeys; + if (!apiKeys) return null; + + for (const id of AI_PROVIDER_ORDER) { + const key = apiKeys[id]; + if (key) { + return { provider: AI_PROVIDERS[id], apiKey: key }; + } + } + return null; + }, [settings.apiKeys]); + + const performAnalysis = useCallback( + async (bypassCache: boolean) => { + setLoading(true); + setError(null); + setErrorType(null); + + const resolved = resolveProvider(); + if (!resolved) { + setError("no_api_key"); + setErrorType("no_api_key"); + setLoading(false); + return; + } + + const contextHash = hashContext(context); + + if (!bypassCache) { + const cached = getCachedAnalysis(cacheKey, contextHash); + if (cached) { + setResult(cached); + setLoading(false); + return; + } + } + + try { + const service = new AIService(resolved.provider, resolved.apiKey); + const analysisResult = await service.analyze({ + type: analysisType, + context, + networkName, + networkCurrency, + language, + }); + + setCachedAnalysis(cacheKey, contextHash, analysisResult); + setResult(analysisResult); + } catch (err) { + if (err instanceof AIServiceError) { + setError(err.type); + setErrorType(err.type); + } else { + setError("generic"); + setErrorType("generic"); + } + logger.error("AI analysis error:", err); + } finally { + setLoading(false); + } + }, + [resolveProvider, context, cacheKey, analysisType, networkName, networkCurrency, language], + ); + + const analyze = useCallback(() => performAnalysis(false), [performAnalysis]); + const refresh = useCallback(() => performAnalysis(true), [performAnalysis]); + + return { result, loading, error, errorType, analyze, refresh }; +} diff --git a/src/hooks/useTransactionPreAnalysis.ts b/src/hooks/useTransactionPreAnalysis.ts new file mode 100644 index 0000000..130bd18 --- /dev/null +++ b/src/hooks/useTransactionPreAnalysis.ts @@ -0,0 +1,70 @@ +import { ClearSigner } from "@erc7730/sdk"; +import type { DecodedTransaction } from "@erc7730/sdk"; +import { useEffect, useMemo, useState } from "react"; +import type { Transaction } from "../types"; +import { logger } from "../utils/logger"; + +interface UseTransactionPreAnalysisReturn { + preAnalysis: DecodedTransaction | null; + preAnalysisLoading: boolean; +} + +/** + * Hook that uses @erc7730/sdk's ClearSigner to decode transaction calldata + * into human-readable format (intent, formatted fields, security warnings). + * Returns null for simple ETH transfers or when decoding fails. + */ +export function useTransactionPreAnalysis( + transaction: Transaction | null, + chainId: number, +): UseTransactionPreAnalysisReturn { + const [preAnalysis, setPreAnalysis] = useState(null); + const [preAnalysisLoading, setPreAnalysisLoading] = useState(false); + + const signer = useMemo(() => { + return new ClearSigner({ chainId, useSourcifyFallback: true }); + }, [chainId]); + + const hasCalldata = transaction?.data && transaction.data !== "0x"; + + useEffect(() => { + if (!transaction || !hasCalldata || !transaction.to) { + setPreAnalysis(null); + return; + } + + let cancelled = false; + setPreAnalysisLoading(true); + + signer + .decode({ + to: transaction.to, + data: transaction.data, + value: transaction.value, + chainId, + from: transaction.from, + }) + .then((result) => { + if (!cancelled) { + setPreAnalysis(result); + } + }) + .catch((err) => { + if (!cancelled) { + logger.warn("ERC-7730 pre-analysis failed (non-blocking):", err); + setPreAnalysis(null); + } + }) + .finally(() => { + if (!cancelled) { + setPreAnalysisLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [signer, transaction, hasCalldata, chainId]); + + return { preAnalysis, preAnalysisLoading }; +} diff --git a/src/locales/en/address.json b/src/locales/en/address.json index 01f1d2c..3491431 100644 --- a/src/locales/en/address.json +++ b/src/locales/en/address.json @@ -184,6 +184,9 @@ "unknownError": "Unknown error", "failedToFetchAddressData": "Failed to fetch address data", "nonce": "Nonce (Transactions Sent)", + "aiAnalysis": { + "sectionTitle": "AI Analysis" + }, "fetchingSentTxsByNonce": "Fetching sent transactions ({{current}}/{{total}})...", "searchingReceivedTxs": "Searching for received transactions..." } diff --git a/src/locales/en/block.json b/src/locales/en/block.json index d3c693b..0cb0d6c 100644 --- a/src/locales/en/block.json +++ b/src/locales/en/block.json @@ -57,6 +57,9 @@ "minute": "minute", "minute_other": "minutes" }, + "aiAnalysis": { + "sectionTitle": "AI Analysis" + }, "errors": { "failedToFetchBlock": "Failed to fetch block data", "failedToFetchBlocks": "Failed to fetch blocks" diff --git a/src/locales/en/common.json b/src/locales/en/common.json index a228ee8..28af34a 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -266,6 +266,28 @@ "buildWarning": { "notProductionBuild": "This is not a production verified build" }, + "aiAnalysis": { + "title": "AI Analysis", + "analyzeButton": "Analyze with AI", + "analyzing": "Analyzing...", + "expand": "Expand analysis", + "collapse": "Collapse analysis", + "refreshButton": "Refresh", + "disclaimer": "AI-generated analysis. May contain errors.", + "generatedBy": "by {{model}} ", + "cachedResult": "cached", + "errors": { + "rateLimited": "Rate limited. Please try again in a moment.", + "invalidKey": "Invalid API key. Check your key in Settings.", + "no_api_key": "No API key configured. Add an AI provider key in Settings.", + "networkError": "Network error. Check your connection and try again.", + "serviceUnavailable": "AI service temporarily unavailable. Try again later.", + "parseError": "Failed to parse AI response. Try again.", + "generic": "Analysis failed. Please try again.", + "tryAgain": "Try Again", + "goToSettings": "Go to Settings" + } + }, "search": { "title": "Search", "resultsTitle": "Search Results", diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 42ee543..a882edf 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -66,7 +66,31 @@ "placeholder": "Enter your Alchemy API key" }, "toggleHide": "Hide API key", - "toggleShow": "Show API key" + "toggleShow": "Show API key", + "aiTitle": "AI Analysis Keys", + "aiDescription": "Enter API keys for AI-powered blockchain analysis. At least one key is required to use the AI analyzer.", + "aiProvidersShow": "Show other providers", + "aiProvidersHide": "Hide other providers", + "groq": { + "name": "Groq", + "getKey": "Get Free Key", + "placeholder": "Enter your Groq API key" + }, + "openai": { + "name": "OpenAI", + "getKey": "Get Key", + "placeholder": "Enter your OpenAI API key" + }, + "anthropic": { + "name": "Anthropic", + "getKey": "Get Key", + "placeholder": "Enter your Anthropic API key" + }, + "togetherai": { + "name": "Together AI", + "getKey": "Get Key", + "placeholder": "Enter your Together AI API key" + } }, "saveConfiguration": "Save Configuration", "toasts": { diff --git a/src/locales/en/transaction.json b/src/locales/en/transaction.json index 59fac54..0a1fef4 100644 --- a/src/locales/en/transaction.json +++ b/src/locales/en/transaction.json @@ -90,6 +90,9 @@ "olderTitle": "View older transactions" } }, + "aiAnalysis": { + "sectionTitle": "AI Analysis" + }, "opcodeTrace": { "totalGasUsed": "Total Gas Used", "failed": "Failed", diff --git a/src/locales/es/address.json b/src/locales/es/address.json index 3038989..d277fec 100644 --- a/src/locales/es/address.json +++ b/src/locales/es/address.json @@ -184,6 +184,9 @@ "unknownError": "Error desconocido", "failedToFetchAddressData": "No se pudieron obtener los datos de la dirección", "nonce": "Nonce (transacciones enviadas)", + "aiAnalysis": { + "sectionTitle": "Análisis IA" + }, "fetchingSentTxsByNonce": "Obteniendo transacciones enviadas ({{current}}/{{total}})...", "searchingReceivedTxs": "Buscando transacciones recibidas..." } diff --git a/src/locales/es/block.json b/src/locales/es/block.json index c27424d..56eb3c5 100644 --- a/src/locales/es/block.json +++ b/src/locales/es/block.json @@ -57,6 +57,9 @@ "minute": "minuto", "minute_other": "minutos" }, + "aiAnalysis": { + "sectionTitle": "Análisis IA" + }, "errors": { "failedToFetchBlock": "No se pudieron obtener los datos del bloque", "failedToFetchBlocks": "No se pudieron obtener los bloques" diff --git a/src/locales/es/common.json b/src/locales/es/common.json index 1300b00..ff4de6b 100644 --- a/src/locales/es/common.json +++ b/src/locales/es/common.json @@ -258,6 +258,28 @@ "fallbackNote": "Modo fallback: los proveedores se prueban en secuencia", "raceNote": "Modo carrera: se devuelve la primera respuesta exitosa. Las pendientes se descartan" }, + "aiAnalysis": { + "title": "Análisis IA", + "analyzeButton": "Analizar con IA", + "analyzing": "Analizando...", + "expand": "Expandir análisis", + "collapse": "Contraer análisis", + "refreshButton": "Actualizar", + "disclaimer": "Análisis generado por IA. Puede contener errores.", + "generatedBy": "por {{model}} ", + "cachedResult": "en caché", + "errors": { + "rateLimited": "Límite de solicitudes alcanzado. Intentá de nuevo en un momento.", + "invalidKey": "API key inválida. Verificá tu clave en Configuración.", + "no_api_key": "No hay API key configurada. Agregá una clave de proveedor IA en Configuración.", + "networkError": "Error de red. Verificá tu conexión e intentá de nuevo.", + "serviceUnavailable": "Servicio de IA temporalmente no disponible. Intentá más tarde.", + "parseError": "No se pudo procesar la respuesta de IA. Intentá de nuevo.", + "generic": "El análisis falló. Intentá de nuevo.", + "tryAgain": "Intentar de nuevo", + "goToSettings": "Ir a Configuración" + } + }, "search": { "title": "Buscar", "resultsTitle": "Resultados de búsqueda", diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index d69cbb6..8acf90d 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -66,7 +66,31 @@ "placeholder": "Ingresá tu API key de Alchemy" }, "toggleHide": "Ocultar API key", - "toggleShow": "Mostrar API key" + "toggleShow": "Mostrar API key", + "aiTitle": "Claves de Análisis IA", + "aiDescription": "Ingresá las API keys para el análisis de blockchain con IA. Se necesita al menos una clave para usar el analizador IA.", + "aiProvidersShow": "Mostrar otros proveedores", + "aiProvidersHide": "Ocultar otros proveedores", + "groq": { + "name": "Groq", + "getKey": "Obtener Key Gratis", + "placeholder": "Ingresá tu API key de Groq" + }, + "openai": { + "name": "OpenAI", + "getKey": "Obtener Key", + "placeholder": "Ingresá tu API key de OpenAI" + }, + "anthropic": { + "name": "Anthropic", + "getKey": "Obtener Key", + "placeholder": "Ingresá tu API key de Anthropic" + }, + "togetherai": { + "name": "Together AI", + "getKey": "Obtener Key", + "placeholder": "Ingresá tu API key de Together AI" + } }, "saveConfiguration": "Guardar Configuración", "toasts": { diff --git a/src/locales/es/transaction.json b/src/locales/es/transaction.json index 1a357e4..3fbc46e 100644 --- a/src/locales/es/transaction.json +++ b/src/locales/es/transaction.json @@ -90,6 +90,9 @@ "olderTitle": "Ver transacciones más viejas" } }, + "aiAnalysis": { + "sectionTitle": "Análisis IA" + }, "opcodeTrace": { "totalGasUsed": "Gas Total Usado", "failed": "Falló", diff --git a/src/services/AIPromptTemplates.ts b/src/services/AIPromptTemplates.ts new file mode 100644 index 0000000..1fc5296 --- /dev/null +++ b/src/services/AIPromptTemplates.ts @@ -0,0 +1,295 @@ +import type { AIAnalysisType } from "../types"; + +interface PromptContext { + networkName: string; + networkCurrency: string; + language?: string; +} + +interface PromptPair { + system: string; + user: string; +} + +interface PromptConfig { + role: string; + conciseness: string; + focusAreas: string; + audience: string; + task: string; + sections: string[]; + customRules?: string; +} + +const DONT_GUESS_RULE = + "Do not guess or fabricate details that are not present in the provided context. If information is missing, do not speculate; either omit it or note it briefly inline only when it materially affects the analysis. Avoid meta commentary about the prompt/data (e.g., do not end with sentences like '...is not provided in the given context'). Avoid generic boilerplate, 'General context' statements, or any generic statements not grounded in the provided context; stick strictly to what is supported by the provided context. Never convert or restate raw numeric values into a different unit unless the unit is explicitly provided in the context or the value is already formatted with a unit. If the context includes any confidence level or confidence/certainty indicator, you must mention it explicitly with its provided label/value in the most relevant section (typically Notable Aspects)."; + +function presentationRules(networkCurrency: string): string { + return `Presentation rules: Express native-currency amounts in ${networkCurrency} (not wei/base units) and avoid printing wei values. Prefer e.g. 0.000467 ${networkCurrency} over 467384405630799 wei. Gas price or base fee per gas should be expressed in Gwei when mentioned. If the context provides pre-formatted values with units, use them directly and do not recalculate or convert. If a value is provided without an explicit unit or token symbol/name, describe it as "raw units" and do not infer a unit. Do not echo full addresses or hashes; refer to roles like 'sender', 'recipient', 'this address/contract', or 'the transaction'.`; +} + +export function buildPrompt( + type: AIAnalysisType, + context: Record, + promptContext: PromptContext, +): PromptPair { + switch (type) { + case "transaction": + return buildTransactionPrompt(context, promptContext); + case "account": + return buildAccountPrompt(context, promptContext); + case "contract": + return buildContractPrompt(context, promptContext); + case "block": + return buildBlockPrompt(context, promptContext); + } +} + +function languageInstruction(language?: string): string { + if (!language || language === "en") return ""; + const LANGUAGE_NAMES: Record = { es: "Spanish" }; + const name = LANGUAGE_NAMES[language] ?? language; + return ` Respond in ${name}.`; +} + +const ROLE_INSTRUCTION = (role: string, networkName: string, networkCurrency: string) => + `You are a ${role} for the ${networkName} network (native currency: ${networkCurrency}).`; + +const CONCISENESS_INSTRUCTION = (range: string) => + `Be concise (${range}). Use markdown formatting.`; + +const FOCUS_INSTRUCTION = (focusAreas: string) => `Focus on: ${focusAreas}.`; + +const SHARED_RULES = { + DONT_GUESS: DONT_GUESS_RULE, + PRESENTATION: (currency: string) => presentationRules(currency), + LANGUAGE: (lang?: string) => languageInstruction(lang), +}; + +const PROMPT_CONFIGS: Record = { + transaction: { + role: "blockchain analyst", + conciseness: "3-5 sentences", + focusAreas: "what happened, who was involved, value/fees, and notable aspects", + audience: "power user", + task: "Explain this transaction in plain English", + sections: ["Transaction Analysis", "Participants", "Value and Fees", "Notable Aspects"], + customRules: + "If the transaction failed and no explicit reason is provided, you may mention 1-2 common causes as possibilities, clearly labeled as possibilities (not claims). If ERC-7730 fields include a formatted token amount (value already includes a token symbol/name), use that. If erc7730Fields include formattedValue, prefer it. Otherwise do not assume ETH or any token for raw numeric values. If callTargetToken is provided, use its symbol/name when describing token amounts related to direct token transfers; if no formatted amount is available, describe them as raw units of that token.", + }, + account: { + role: "blockchain analyst", + conciseness: "3-5 sentences", + focusAreas: + "activity level, balance significance, and any patterns visible from recent transactions", + audience: "power user", + task: "Provide a brief analysis of this address", + sections: [ + "Analysis of the Address", + "Activity", + "Balance", + "Transaction Patterns", + "Known Vulnerabilities", + ], + }, + contract: { + role: "smart contract analyst", + conciseness: "5-8 sentences", + focusAreas: + "contract purpose, key functions, security considerations, protocol or token standard identification, and any known vulnerabilities associated with this address if present in the context", + audience: "power user", + task: "Analyze this smart contract", + sections: [ + "Contract Analysis", + "Key Functions", + "Security Considerations", + "Protocol or Token Standard", + "Known Vulnerabilities", + ], + customRules: + 'Avoid generic boilerplate or a "General context" paragraph; stick to the provided context.', + }, + block: { + role: "blockchain analyst", + conciseness: "3-5 sentences", + focusAreas: "transaction count, gas usage patterns, block utilization, and any notable aspects", + audience: "power user", + task: "Analyze this block", + sections: ["Block Analysis", "Utilization", "Transactions", "Notable Aspects"], + }, +}; + +function buildSystemPrompt( + config: PromptConfig, + { networkName, networkCurrency, language }: PromptContext, + customRules?: string, +): string { + const sections = [ + ROLE_INSTRUCTION(config.role, networkName, networkCurrency), + `${config.task} for a ${config.audience} audience.`, + CONCISENESS_INSTRUCTION(config.conciseness), + FOCUS_INSTRUCTION(config.focusAreas), + `Use the following section headers exactly and in order: ${config.sections + .map((section) => `"${section}"`) + .join(", ")}. If a section cannot be supported by the provided context, omit it entirely.`, + SHARED_RULES.PRESENTATION(networkCurrency), + SHARED_RULES.DONT_GUESS, + config.customRules ?? "", + customRules ?? "", + SHARED_RULES.LANGUAGE(language), + ]; + + return sections.filter(Boolean).join(" "); +} + +function buildTransactionPrompt( + context: Record, + promptContext: PromptContext, +): PromptPair { + const hasPreAnalysis = "erc7730Intent" in context; + const preAnalysisHint = hasPreAnalysis + ? "ERC-7730 pre-analysis data is included (erc7730Intent, erc7730Fields, erc7730Warnings, erc7730Protocol). Use it as authoritative context for understanding the transaction purpose and parameters. Highlight any security warnings if present." + : ""; + + return { + system: buildSystemPrompt(PROMPT_CONFIGS.transaction, promptContext, preAnalysisHint), + user: formatContext(context), + }; +} + +function buildAccountPrompt( + context: Record, + promptContext: PromptContext, +): PromptPair { + return { + system: buildSystemPrompt(PROMPT_CONFIGS.account, promptContext), + user: formatContext(context), + }; +} + +function buildContractPrompt( + context: Record, + promptContext: PromptContext, +): PromptPair { + return { + system: buildSystemPrompt(PROMPT_CONFIGS.contract, promptContext), + user: formatContext(context), + }; +} + +function buildBlockPrompt( + context: Record, + promptContext: PromptContext, +): PromptPair { + return { + system: buildSystemPrompt(PROMPT_CONFIGS.block, promptContext), + user: formatContext(context), + }; +} + +function formatContext(context: Record): string { + const sanitized = sanitizeContextForPrompt(context); + const json = safeJsonStringify(sanitized, 2); + return ["Context (JSON; some long fields may be truncated):", "```json", json, "```"].join("\n"); +} + +const DEFAULT_MAX_STRING_LENGTH = 1400; +const DEFAULT_MAX_ARRAY_LENGTH = 20; +const DEFAULT_MAX_OBJECT_KEYS = 80; +const DEFAULT_MAX_DEPTH = 6; + +const ARRAY_LIMITS_BY_KEY: Record = { + eventLogs: 10, + decodedParams: 20, + erc7730Fields: 20, + erc7730Warnings: 20, + recentTransactions: 10, +}; + +const STRING_LIMITS_BY_KEY: Record = { + inputData: 600, + data: 600, + logsBloom: 600, +}; + +function sanitizeContextForPrompt(context: Record): Record { + const out: Record = {}; + const keys = Object.keys(context).sort(); + for (const key of keys) { + const value = context[key]; + if (value === undefined || value === null || value === "") continue; + const sanitized = sanitizeValueForPrompt(value, key, 0); + if (sanitized === undefined) continue; + out[key] = sanitized; + } + return out; +} + +function sanitizeValueForPrompt( + value: unknown, + keyHint: string | undefined, + depth: number, +): unknown { + if (value === undefined || value === null) return undefined; + if (depth > DEFAULT_MAX_DEPTH) return "[Truncated: max depth reached]"; + + if (typeof value === "string") { + const maxLen = keyHint + ? (STRING_LIMITS_BY_KEY[keyHint] ?? DEFAULT_MAX_STRING_LENGTH) + : DEFAULT_MAX_STRING_LENGTH; + return truncateString(value, maxLen); + } + + if (typeof value === "number" || typeof value === "boolean") { + return value; + } + + if (typeof value === "bigint") { + return value.toString(); + } + + if (Array.isArray(value)) { + const maxLen = keyHint + ? (ARRAY_LIMITS_BY_KEY[keyHint] ?? DEFAULT_MAX_ARRAY_LENGTH) + : DEFAULT_MAX_ARRAY_LENGTH; + const items = value + .slice(0, maxLen) + .map((v) => sanitizeValueForPrompt(v, undefined, depth + 1)) + .filter((v) => v !== undefined); + if (value.length > maxLen) { + items.push({ __truncated__: true, total: value.length, shown: maxLen }); + } + return items; + } + + if (typeof value === "object") { + const obj = value as Record; + const out: Record = {}; + const keys = Object.keys(obj).sort().slice(0, DEFAULT_MAX_OBJECT_KEYS); + for (const key of keys) { + const v = obj[key]; + if (v === undefined || v === null || v === "") continue; + const sanitized = sanitizeValueForPrompt(v, key, depth + 1); + if (sanitized === undefined) continue; + out[key] = sanitized; + } + const totalKeys = Object.keys(obj).length; + if (totalKeys > DEFAULT_MAX_OBJECT_KEYS) { + out.__truncatedKeys__ = { total: totalKeys, shown: DEFAULT_MAX_OBJECT_KEYS }; + } + return out; + } + + return String(value); +} + +function truncateString(value: string, maxLength: number): string { + if (value.length <= maxLength) return value; + const head = value.slice(0, Math.max(0, Math.floor(maxLength * 0.6))); + const tail = value.slice(-Math.max(0, Math.floor(maxLength * 0.2))); + return `${head}…${tail}`; +} + +function safeJsonStringify(value: unknown, space: number): string { + return JSON.stringify(value, (_key, v) => (typeof v === "bigint" ? v.toString() : v), space); +} diff --git a/src/services/AIService.ts b/src/services/AIService.ts new file mode 100644 index 0000000..ffc3168 --- /dev/null +++ b/src/services/AIService.ts @@ -0,0 +1,187 @@ +import type { AIAnalysisResult, AIAnalysisType, AIProviderConfig } from "../types"; +import { logger } from "../utils/logger"; +import { buildPrompt } from "./AIPromptTemplates"; + +const MAX_TOKENS = 1024; +const RETRY_DELAY_MS = 5000; + +export type AIErrorType = + | "rate_limited" + | "invalid_key" + | "service_unavailable" + | "network_error" + | "parse_error" + | "no_api_key" + | "generic"; + +export class AIServiceError extends Error { + constructor( + message: string, + public readonly type: AIErrorType, + ) { + super(message); + this.name = "AIServiceError"; + } +} + +export interface AIAnalysisRequest { + type: AIAnalysisType; + context: Record; + networkName: string; + networkCurrency: string; + language?: string; +} + +/** + * Provider-agnostic AI analysis service. + * Supports OpenAI-compatible APIs (Groq, OpenAI, Together AI) and Anthropic. + */ +export class AIService { + private readonly provider: AIProviderConfig; + private readonly apiKey: string; + + constructor(provider: AIProviderConfig, apiKey: string) { + this.provider = provider; + this.apiKey = apiKey; + } + + async analyze(request: AIAnalysisRequest): Promise { + const { system, user } = buildPrompt(request.type, request.context, { + networkName: request.networkName, + networkCurrency: request.networkCurrency, + language: request.language, + }); + + try { + const content = await this.callAPI(system, user); + return { + summary: content, + timestamp: Date.now(), + model: this.provider.defaultModel, + provider: this.provider.id, + cached: false, + }; + } catch (error) { + if (error instanceof AIServiceError) { + throw error; + } + logger.error("AI analysis failed:", error); + throw new AIServiceError("Analysis failed unexpectedly", "generic"); + } + } + + private async callAPI(system: string, user: string): Promise { + if (this.provider.id === "anthropic") { + return this.callAnthropic(system, user); + } + return this.callOpenAICompatible(system, user); + } + + private async callOpenAICompatible(system: string, user: string): Promise { + const url = `${this.provider.baseUrl}/chat/completions`; + const body = { + model: this.provider.defaultModel, + messages: [ + { role: "system", content: system }, + { role: "user", content: user }, + ], + max_tokens: MAX_TOKENS, + temperature: 0.3, + }; + + const response = await this.fetchWithRetry(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + const content = data?.choices?.[0]?.message?.content; + if (typeof content !== "string") { + logger.error("Unexpected OpenAI-compatible response format:", data); + throw new AIServiceError("Failed to parse AI response", "parse_error"); + } + return content; + } + + private async callAnthropic(system: string, user: string): Promise { + const url = `${this.provider.baseUrl}/messages`; + const body = { + model: this.provider.defaultModel, + max_tokens: MAX_TOKENS, + system, + messages: [{ role: "user", content: user }], + temperature: 0.3, + }; + + const response = await this.fetchWithRetry(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": this.apiKey, + "anthropic-version": "2023-06-01", + "anthropic-dangerous-direct-browser-access": "true", + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + const content = data?.content?.[0]?.text; + if (typeof content !== "string") { + logger.error("Unexpected Anthropic response format:", data); + throw new AIServiceError("Failed to parse AI response", "parse_error"); + } + return content; + } + + private async fetchWithRetry(url: string, init: RequestInit): Promise { + const response = await this.doFetch(url, init); + + if (response.status === 429) { + const retryAfter = response.headers.get("Retry-After"); + const delayMs = retryAfter ? Number.parseInt(retryAfter, 10) * 1000 : RETRY_DELAY_MS; + logger.warn(`AI API rate limited, retrying in ${delayMs}ms`); + await this.delay(Math.min(delayMs, 10000)); + + const retryResponse = await this.doFetch(url, init); + if (!retryResponse.ok) { + this.handleErrorResponse(retryResponse.status); + } + return retryResponse; + } + + if (!response.ok) { + this.handleErrorResponse(response.status); + } + + return response; + } + + private async doFetch(url: string, init: RequestInit): Promise { + try { + return await fetch(url, init); + } catch { + throw new AIServiceError("Network error connecting to AI service", "network_error"); + } + } + + private handleErrorResponse(status: number): never { + switch (status) { + case 401: + throw new AIServiceError("Invalid API key", "invalid_key"); + case 429: + throw new AIServiceError("Rate limited by AI provider", "rate_limited"); + case 503: + throw new AIServiceError("AI service temporarily unavailable", "service_unavailable"); + default: + throw new AIServiceError(`AI service error (HTTP ${status})`, "generic"); + } + } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/src/styles/ai-analysis.css b/src/styles/ai-analysis.css new file mode 100644 index 0000000..4257118 --- /dev/null +++ b/src/styles/ai-analysis.css @@ -0,0 +1,218 @@ +/* AI Analysis - inline expandable panel styles */ + +.page-with-analysis { + display: flex; + flex-direction: column; + gap: 16px; +} + +/* AI Analysis Panel */ +.ai-analysis-panel { + display: flex; + flex-direction: column; + gap: 12px; +} + +.ai-analysis-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.ai-analysis-title { + font-size: 0.75rem; + text-transform: uppercase; + color: var(--text-secondary); + margin: 0; + letter-spacing: 0.05em; + font-weight: 600; +} + +.ai-analysis-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.ai-analysis-toggle { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.ai-analysis-content { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* Analyze Button */ +.ai-analysis-button { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: auto; + min-width: 140px; + cursor: pointer; +} + +.ai-analysis-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.ai-analysis-preview { + font-size: 0.875rem; + line-height: 1.6; + color: var(--text-secondary); +} + +.ai-analysis-preview-text { + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + white-space: pre-line; +} + +/* Loading Spinner */ +.ai-analysis-spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid var(--text-secondary); + border-top-color: transparent; + border-radius: 50%; + animation: ai-spin 0.8s linear infinite; +} + +@keyframes ai-spin { + to { + transform: rotate(360deg); + } +} + +/* Result Content */ +.ai-analysis-result { + margin-top: 0; + font-size: 0.875rem; + line-height: 1.6; + color: var(--text-primary); +} + +.ai-analysis-result p { + margin: 8px 0; +} + +.ai-analysis-result p:first-child { + margin-top: 0; +} + +.ai-analysis-result strong { + font-weight: 600; +} + +.ai-analysis-result ul, +.ai-analysis-result ol { + padding-left: 20px; + margin: 8px 0; +} + +.ai-analysis-result li { + margin: 4px 0; +} + +.ai-analysis-result code { + background: var(--color-primary-alpha-8); + padding: 2px 4px; + border-radius: 3px; + font-size: 0.8rem; +} + +/* Footer */ +.ai-analysis-footer { + margin-top: 0; + padding-top: 8px; + border-top: 1px solid var(--color-primary-alpha-8); + display: flex; + flex-direction: column; + gap: 6px; +} + +.ai-analysis-meta { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.75rem; + color: var(--text-tertiary); +} + +.ai-analysis-disclaimer { + font-size: 0.7rem; + color: var(--text-tertiary); + font-style: italic; +} + +.ai-analysis-refresh { + font-size: 0.75rem; + color: var(--color-primary); + background: none; + border: none; + cursor: pointer; + padding: 0; + text-decoration: underline; +} + +.ai-analysis-refresh:hover { + opacity: 0.8; +} + +/* Cached badge */ +.ai-analysis-cached { + font-size: 0.7rem; + color: var(--text-tertiary); + background: var(--color-primary-alpha-8); + padding: 1px 6px; + border-radius: 4px; +} + +/* Error State */ +.ai-analysis-error { + margin-top: 0; + padding: 10px; + background: var(--color-error-alpha-10, rgba(220, 38, 38, 0.1)); + border: 1px solid var(--color-error-alpha-20, rgba(220, 38, 38, 0.2)); + border-radius: 6px; + font-size: 0.825rem; + color: var(--text-primary); +} + +.ai-analysis-error-message { + margin-bottom: 8px; +} + +.ai-analysis-error-action { + display: flex; + gap: 8px; + align-items: center; +} + +.ai-analysis-retry { + font-size: 0.8rem; + color: var(--color-primary); + background: none; + border: none; + cursor: pointer; + padding: 0; + text-decoration: underline; +} + +.ai-analysis-settings-link { + font-size: 0.8rem; + color: var(--color-primary); +} diff --git a/src/styles/styles.css b/src/styles/styles.css index f0a15f1..c4033c2 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -5113,6 +5113,33 @@ code { opacity: 1; } +/* Collapsible section toggle */ +.settings-section-collapse-button { + font-size: 0.75rem; + font-weight: 500; + font-family: "Outfit", sans-serif; + color: var(--color-primary); + background: var(--color-primary-alpha-10); + border: 1px solid var(--color-primary-alpha-20); + padding: 6px 12px; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + margin: 16px auto 0; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.settings-section-collapse-button:hover { + background: var(--color-primary-alpha-20); + border-color: var(--color-primary-alpha-40); +} + +.settings-ai-other-providers { + margin-top: 12px; +} + /* Toast Notifications */ .settings-toast-container { position: fixed; @@ -5907,4 +5934,4 @@ code { .profile-link-item { justify-content: center; } -} \ No newline at end of file +} diff --git a/src/types/index.ts b/src/types/index.ts index 95f15fe..dff6b16 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -419,13 +419,49 @@ export type RpcUrlsContextType = Record; // ==================== SETTINGS TYPES ==================== /** - * API keys for RPC providers + * API keys for RPC providers and AI providers */ export interface ApiKeys { infura?: string; alchemy?: string; + groq?: string; + openai?: string; + anthropic?: string; + togetherai?: string; } +/** + * Supported AI providers for blockchain analysis + */ +export type AIProvider = "groq" | "openai" | "anthropic" | "togetherai"; + +/** + * Configuration for an AI provider + */ +export interface AIProviderConfig { + id: AIProvider; + name: string; + baseUrl: string; + defaultModel: string; + keyUrl: string; +} + +/** + * Result of an AI analysis request + */ +export interface AIAnalysisResult { + summary: string; + timestamp: number; + model: string; + provider: AIProvider; + cached: boolean; +} + +/** + * Analysis types for the AI analyzer + */ +export type AIAnalysisType = "transaction" | "account" | "contract" | "block"; + /** * User settings for the application */ diff --git a/src/utils/erc20Utils.ts b/src/utils/erc20Utils.ts index 470f461..1a33485 100644 --- a/src/utils/erc20Utils.ts +++ b/src/utils/erc20Utils.ts @@ -1,6 +1,7 @@ /** * ERC20 utility functions for fetching token balances and metadata */ +import { formatUnitsValue } from "./unitFormatters"; // ERC20 function selectors const ERC20_BALANCE_OF_SELECTOR = "0x70a08231"; // balanceOf(address) @@ -177,39 +178,28 @@ export async function fetchERC20TokenInfo( } /** - * Format token balance with decimals + * Format token balance with decimals and locale-formatted whole part. * @param balance - Raw balance string * @param decimals - Token decimals * @param maxDisplayDecimals - Maximum decimals to display (default 6) - * @returns Formatted balance string + * @returns Formatted balance string with locale separators (e.g. "1,234.56") */ export function formatTokenBalance( balance: string, decimals: number, maxDisplayDecimals = 6, ): string { - try { - const balanceBigInt = BigInt(balance); - const divisor = BigInt(10 ** decimals); - const wholePart = balanceBigInt / divisor; - const fractionalPart = balanceBigInt % divisor; - - if (fractionalPart === BigInt(0)) { - return wholePart.toLocaleString(); - } - - // Convert fractional part to decimal string - const fractionalStr = fractionalPart.toString().padStart(decimals, "0"); - const trimmedFractional = fractionalStr.slice(0, maxDisplayDecimals).replace(/0+$/, ""); + const formatted = formatUnitsValue(balance, decimals, { maxDecimals: maxDisplayDecimals }); + if (formatted === undefined) return balance; - if (!trimmedFractional) { - return wholePart.toLocaleString(); - } - - return `${wholePart.toLocaleString()}.${trimmedFractional}`; - } catch { - return balance; + const dotIndex = formatted.indexOf("."); + if (dotIndex === -1) { + return BigInt(formatted).toLocaleString(); } + + const whole = formatted.slice(0, dotIndex); + const frac = formatted.slice(dotIndex); + return `${BigInt(whole).toLocaleString()}${frac}`; } /** diff --git a/src/utils/unitFormatters.ts b/src/utils/unitFormatters.ts new file mode 100644 index 0000000..659ee5a --- /dev/null +++ b/src/utils/unitFormatters.ts @@ -0,0 +1,71 @@ +type FormatOptions = { + maxDecimals?: number; + unit?: string; +}; + +function parseBigIntValue(value: string): bigint | null { + if (!value) return null; + try { + return value.startsWith("0x") ? BigInt(value) : BigInt(value); + } catch { + return null; + } +} + +export function formatUnitsValue( + value: string, + decimals: number, + { maxDecimals = 6 }: FormatOptions = {}, +): string | undefined { + const bn = parseBigIntValue(value); + if (bn === null) return undefined; + if (decimals <= 0) return bn.toString(); + + const divisor = 10n ** BigInt(decimals); + const whole = bn / divisor; + const fraction = bn % divisor; + + if (fraction === 0n || maxDecimals === 0) return whole.toString(); + + const padded = fraction.toString().padStart(decimals, "0"); + const trimmed = padded.slice(0, Math.min(decimals, maxDecimals)).replace(/0+$/, ""); + return trimmed.length > 0 ? `${whole.toString()}.${trimmed}` : whole.toString(); +} + +export function formatNativeFromWei( + value?: string, + unit = "ETH", + maxDecimals = 6, +): string | undefined { + if (!value) return undefined; + const formatted = formatUnitsValue(value, 18, { maxDecimals }); + return formatted ? `${formatted} ${unit}` : undefined; +} + +export function formatEthFromWei(value?: string, maxDecimals = 6): string | undefined { + return formatNativeFromWei(value, "ETH", maxDecimals); +} + +export function formatGweiFromWei(value?: string, maxDecimals = 2): string | undefined { + if (!value) return undefined; + const formatted = formatUnitsValue(value, 9, { maxDecimals }); + return formatted ? `${formatted} Gwei` : undefined; +} + +export function formatTokenAmount( + value?: string, + decimals?: number, + maxDecimals = 6, + symbol?: string, +): string | undefined { + if (!value || decimals === undefined || decimals === null) return undefined; + const formatted = formatUnitsValue(value, decimals, { maxDecimals }); + if (!formatted) return undefined; + return symbol ? `${formatted} ${symbol}` : formatted; +} + +export function toDecimalString(value?: string): string | undefined { + if (!value) return undefined; + const bn = parseBigIntValue(value); + return bn === null ? undefined : bn.toString(); +} diff --git a/tsconfig.json b/tsconfig.json index 1a1b4ad..90ee006 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "es2022", "module": "esnext", "jsx": "react-jsx", "strict": true,