From 3d3e03689cb90b80aefe91002f21fb16532bc77d Mon Sep 17 00:00:00 2001 From: Mati OS Date: Tue, 10 Feb 2026 16:01:39 -0300 Subject: [PATCH 01/23] feat: add AI provider types and interfaces --- src/types/index.ts | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) 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 */ From c99bd0e416be41f233e5af01a71db9f44cc2454b Mon Sep 17 00:00:00 2001 From: Mati OS Date: Tue, 10 Feb 2026 16:02:06 -0300 Subject: [PATCH 02/23] feat: add AI provider configuration --- src/config/aiProviders.ts | 42 +++++++++ src/services/AIService.ts | 185 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 src/config/aiProviders.ts create mode 100644 src/services/AIService.ts 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/services/AIService.ts b/src/services/AIService.ts new file mode 100644 index 0000000..61832d8 --- /dev/null +++ b/src/services/AIService.ts @@ -0,0 +1,185 @@ +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; +} + +/** + * 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, + }); + + 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)); + } +} From c17759ae7fe5920987ea253f95cc3ec589c19f99 Mon Sep 17 00:00:00 2001 From: Mati OS Date: Tue, 10 Feb 2026 16:02:44 -0300 Subject: [PATCH 03/23] feat: add AI prompt templates and caching utilities --- src/services/AIPromptTemplates.ts | 81 ++++++++++++++++ src/utils/aiCache.ts | 151 ++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 src/services/AIPromptTemplates.ts create mode 100644 src/utils/aiCache.ts diff --git a/src/services/AIPromptTemplates.ts b/src/services/AIPromptTemplates.ts new file mode 100644 index 0000000..46c4ae4 --- /dev/null +++ b/src/services/AIPromptTemplates.ts @@ -0,0 +1,81 @@ +import type { AIAnalysisType } from "../types"; + +interface PromptContext { + networkName: string; + networkCurrency: string; +} + +interface PromptPair { + system: string; + user: string; +} + +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 buildTransactionPrompt( + context: Record, + { networkName, networkCurrency }: PromptContext, +): PromptPair { + return { + system: `You are a blockchain analyst for the ${networkName} network (native currency: ${networkCurrency}). Explain this transaction in plain English. Be concise (3-5 sentences). Use markdown formatting. Focus on: what happened, who was involved, how much was transferred, and any notable aspects. If the transaction failed, explain why it might have failed.`, + user: formatContext(context), + }; +} + +function buildAccountPrompt( + context: Record, + { networkName, networkCurrency }: PromptContext, +): PromptPair { + return { + system: `You are a blockchain analyst for the ${networkName} network (native currency: ${networkCurrency}). Provide a brief analysis of this address. Be concise (3-5 sentences). Use markdown formatting. Focus on: account type (EOA vs contract), activity level, balance significance, and any patterns visible from recent transactions.`, + user: formatContext(context), + }; +} + +function buildContractPrompt( + context: Record, + { networkName, networkCurrency }: PromptContext, +): PromptPair { + return { + system: `You are a smart contract analyst for the ${networkName} network (native currency: ${networkCurrency}). Analyze this smart contract. Be concise but thorough (5-8 sentences). Use markdown formatting. Focus on: contract purpose, key functions, security considerations, and protocol or token standard identification.`, + user: formatContext(context), + }; +} + +function buildBlockPrompt( + context: Record, + { networkName, networkCurrency }: PromptContext, +): PromptPair { + return { + system: `You are a blockchain analyst for the ${networkName} network (native currency: ${networkCurrency}). Analyze this block. Be concise (3-5 sentences). Use markdown formatting. Focus on: transaction count, gas usage patterns, block utilization, and any notable aspects (e.g., high gas usage, unusual activity).`, + user: formatContext(context), + }; +} + +function formatContext(context: Record): string { + const lines: string[] = []; + for (const [key, value] of Object.entries(context)) { + if (value === undefined || value === null || value === "") continue; + if (typeof value === "object") { + lines.push(`${key}: ${JSON.stringify(value)}`); + } else { + lines.push(`${key}: ${String(value)}`); + } + } + return lines.join("\n"); +} diff --git a/src/utils/aiCache.ts b/src/utils/aiCache.ts new file mode 100644 index 0000000..0646d3f --- /dev/null +++ b/src/utils/aiCache.ts @@ -0,0 +1,151 @@ +import type { AIAnalysisResult } from "../types"; +import { logger } from "./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); + 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); + } +} From b3cc8a688608ce0f76a3e2e4197055909081ed05 Mon Sep 17 00:00:00 2001 From: Mati OS Date: Tue, 10 Feb 2026 16:03:08 -0300 Subject: [PATCH 04/23] feat: implement useAIAnalysis hook --- src/components/common/AIAnalysis.tsx | 115 +++++++++++++++ src/hooks/useAIAnalysis.ts | 108 ++++++++++++++ src/styles/ai-analysis.css | 201 +++++++++++++++++++++++++++ 3 files changed, 424 insertions(+) create mode 100644 src/components/common/AIAnalysis.tsx create mode 100644 src/hooks/useAIAnalysis.ts create mode 100644 src/styles/ai-analysis.css diff --git a/src/components/common/AIAnalysis.tsx b/src/components/common/AIAnalysis.tsx new file mode 100644 index 0000000..6f7acfc --- /dev/null +++ b/src/components/common/AIAnalysis.tsx @@ -0,0 +1,115 @@ +import type React 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 AIAnalysis: React.FC = ({ + analysisType, + context, + networkName, + networkCurrency, + cacheKey, +}) => { + const { t } = useTranslation("common"); + const { result, loading, error, errorType, analyze, refresh } = useAIAnalysis( + analysisType, + context, + networkName, + networkCurrency, + cacheKey, + ); + + return ( +
+
{t("aiAnalysis.title")}
+ + + + {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 AIAnalysis; diff --git a/src/hooks/useAIAnalysis.ts b/src/hooks/useAIAnalysis.ts new file mode 100644 index 0000000..3013b96 --- /dev/null +++ b/src/hooks/useAIAnalysis.ts @@ -0,0 +1,108 @@ +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 "../utils/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, +): 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, + }); + + 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], + ); + + const analyze = useCallback(() => performAnalysis(false), [performAnalysis]); + const refresh = useCallback(() => performAnalysis(true), [performAnalysis]); + + return { result, loading, error, errorType, analyze, refresh }; +} diff --git a/src/styles/ai-analysis.css b/src/styles/ai-analysis.css new file mode 100644 index 0000000..a348a5d --- /dev/null +++ b/src/styles/ai-analysis.css @@ -0,0 +1,201 @@ +/* AI Analysis - 2-column layout and panel styles */ + +.page-with-analysis { + display: grid; + grid-template-columns: 1fr; + gap: 16px; +} + +@media (min-width: 1200px) { + .page-with-analysis { + grid-template-columns: 2fr 1fr; + } +} + +.page-analysis-panel { + position: sticky; + top: 16px; + align-self: start; +} + +/* AI Analysis Panel Card */ +.ai-analysis-panel { + background: var(--color-surface); + border: 1px solid var(--color-primary-alpha-10); + border-radius: 8px; + padding: 16px; +} + +.ai-analysis-title { + font-size: 0.75rem; + text-transform: uppercase; + color: var(--text-secondary); + margin-bottom: 12px; + letter-spacing: 0.05em; + font-weight: 600; +} + +/* Analyze Button */ +.ai-analysis-button { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 10px 16px; + border: 1px solid var(--color-primary-alpha-20); + border-radius: 6px; + background: var(--color-primary-alpha-8); + color: var(--text-primary); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; +} + +.ai-analysis-button:hover:not(:disabled) { + background: var(--color-primary-alpha-20); + border-color: var(--color-primary-alpha-30); +} + +.ai-analysis-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* 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: 12px; + 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: 12px; + 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: 12px; + 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); +} From a3647a517b6e82366d4989b979d4c6fb0b4280eb Mon Sep 17 00:00:00 2001 From: Mati OS Date: Tue, 10 Feb 2026 16:03:38 -0300 Subject: [PATCH 05/23] feat: integrate AI provider settings into settings UI --- src/components/pages/settings/index.tsx | 70 +++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/components/pages/settings/index.tsx b/src/components/pages/settings/index.tsx index aa63343..1c6041f 100644 --- a/src/components/pages/settings/index.tsx +++ b/src/components/pages/settings/index.tsx @@ -9,6 +9,8 @@ import { useMetaMaskExplorer } from "../../../hooks/useMetaMaskExplorer"; import { SUPPORTED_LANGUAGES } from "../../../i18n"; import { clearSupportersCache } from "../../../services/MetadataService"; import type { RPCUrls, RpcUrlsContextType } from "../../../types"; +import { AI_PROVIDERS, AI_PROVIDER_ORDER } from "../../../config/aiProviders"; +import { clearAICache } from "../../../utils/aiCache"; import { logger } from "../../../utils/logger"; import { getChainIdFromNetwork } from "../../../utils/networkResolver"; @@ -71,10 +73,18 @@ 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 [metamaskStatus, setMetamaskStatus] = useState< Record @@ -108,6 +118,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 +420,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, }, }); @@ -686,6 +702,60 @@ const Settings: React.FC = () => { + {/* AI Provider API Keys */} +
+
+

🤖 {t("apiKeys.aiTitle")}

+

{t("apiKeys.aiDescription")}

+ + {AI_PROVIDER_ORDER.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 */}
- - {/* AI Provider API Keys */} -
+ {/* AI Provider API Keys */}

🤖 {t("apiKeys.aiTitle")}

{t("apiKeys.aiDescription")}

- {AI_PROVIDER_ORDER.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`)} - /> - -
-
- ); - })} +
+
+ + {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`)} + /> + +
+
+ ); + })} +
+ )}
diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index d87db9a..a882edf 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -69,6 +69,8 @@ "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", diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index 4278dcc..8acf90d 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -69,6 +69,8 @@ "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", 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 +} From c00206631d50d7da7f181417b233dc673f940932 Mon Sep 17 00:00:00 2001 From: Mati OS Date: Tue, 10 Feb 2026 17:52:23 -0300 Subject: [PATCH 10/23] feat(i18n): add AI analysis translation keys for address pages - Add aiAnalysis.sectionTitle to English and Spanish address translations --- src/locales/en/address.json | 5 ++++- src/locales/es/address.json | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/locales/en/address.json b/src/locales/en/address.json index 41f5c47..d35506b 100644 --- a/src/locales/en/address.json +++ b/src/locales/en/address.json @@ -183,5 +183,8 @@ "errorResolvingENS": "Error resolving ENS", "unknownError": "Unknown error", "failedToFetchAddressData": "Failed to fetch address data", - "nonce": "Nonce (Transactions Sent)" + "nonce": "Nonce (Transactions Sent)", + "aiAnalysis": { + "sectionTitle": "AI Analysis" + } } diff --git a/src/locales/es/address.json b/src/locales/es/address.json index a8ead65..0ca459c 100644 --- a/src/locales/es/address.json +++ b/src/locales/es/address.json @@ -183,5 +183,8 @@ "errorResolvingENS": "Error al resolver ENS", "unknownError": "Error desconocido", "failedToFetchAddressData": "No se pudieron obtener los datos de la dirección", - "nonce": "Nonce (transacciones enviadas)" + "nonce": "Nonce (transacciones enviadas)", + "aiAnalysis": { + "sectionTitle": "Análisis IA" + } } From f08799c3587921de951e1982f4f26e2fac5f5727 Mon Sep 17 00:00:00 2001 From: Mati OS Date: Tue, 10 Feb 2026 17:52:32 -0300 Subject: [PATCH 11/23] feat(address): integrate AI analysis panel into address display pages - Wrap AccountDisplay, ERC20Display, ERC721Display, ERC1155Display in page-with-analysis 2-column grid layout - Add AIAnalysis panel to each display with appropriate analysis type (account for EOA, contract for token displays) - Build context objects with address data, token metadata, and verification status per display type - Add onTransactionsChange callback to TransactionHistory to expose loaded transactions to AccountDisplay for richer AI context - Include recent transaction summaries (first 10 txs) in EOA context --- .../evm/address/displays/AccountDisplay.tsx | 95 ++++++++++--- .../evm/address/displays/ERC1155Display.tsx | 132 ++++++++++++------ .../evm/address/displays/ERC20Display.tsx | 130 +++++++++++------ .../evm/address/displays/ERC721Display.tsx | 132 ++++++++++++------ .../evm/address/shared/TransactionHistory.tsx | 8 ++ 5 files changed, 344 insertions(+), 153 deletions(-) diff --git a/src/components/pages/evm/address/displays/AccountDisplay.tsx b/src/components/pages/evm/address/displays/AccountDisplay.tsx index 5b04fae..c08dcfc 100644 --- a/src/components/pages/evm/address/displays/AccountDisplay.tsx +++ b/src/components/pages/evm/address/displays/AccountDisplay.tsx @@ -1,5 +1,8 @@ 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 AIAnalysis from "../../../../common/AIAnalysis"; import { AddressHeader, TransactionHistory } from "../shared"; import AccountInfoCards from "../shared/AccountInfoCards"; @@ -27,32 +30,78 @@ const AccountDisplay: React.FC = ({ reverseResult, isMainnet = true, }) => { + const network = getNetworkById(networkId); + const networkName = network?.name ?? "Unknown Network"; + const networkCurrency = network?.currency ?? "ETH"; + + 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", + value: tx.value, + status: tx.receipt?.status === "0x1" || tx.receipt?.status === "1" ? "success" : "failed", + })); + }, [transactions]); + + const aiContext = useMemo( + () => ({ + address: addressHash, + balance: address.balance, + txCount: address.txCount, + accountType: "account", + hasCode: address.code !== "0x", + ensName: ensName ?? undefined, + recentTransactions: recentTxSummary, + }), + [addressHash, address.balance, address.txCount, address.code, ensName, recentTxSummary], + ); + return ( -
- - -
- {/* Account Info Cards - Overview + More Info side by side */} - +
+ - {/* Transaction History */} - + {/* Account Info Cards - Overview + More Info side by side */} + + + {/* Transaction History */} + +
+
+
+
diff --git a/src/components/pages/evm/address/displays/ERC1155Display.tsx b/src/components/pages/evm/address/displays/ERC1155Display.tsx index 565bf97..123ae99 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,7 @@ import { import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; import { decodeAbiString } from "../../../../../utils/hexUtils"; import { logger } from "../../../../../utils/logger"; +import AIAnalysis from "../../../../common/AIAnalysis"; import { AddressHeader } from "../shared"; import ContractInfoCard from "../shared/ContractInfoCard"; import ContractInfoCards from "../shared/ContractInfoCards"; @@ -187,55 +189,97 @@ const ERC1155Display: React.FC = ({ ? getAssetUrl(tokenMetadata.logo) : getAssetUrl(`assets/tokens/${networkId}/${addressHash.toLowerCase()}.png`); + const network = getNetworkById(networkId); + const networkName = network?.name ?? "Unknown Network"; + const networkCurrency = network?.currency ?? "ETH"; + + const aiContext = useMemo( + () => ({ + address: addressHash, + balance: address.balance, + 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, + ], + ); + return ( -
- - -
- {/* Overview + More Info Cards */} - +
+ - {/* NFT Collection Info Card */} - +
+ {/* Overview + More Info Cards */} + - {/* Contract Info Card (includes Contract Details) */} - + + {/* 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..b790039 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,7 @@ import { import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; import { hexToUtf8 } from "../../../../../utils/erc20Utils"; import { logger } from "../../../../../utils/logger"; +import AIAnalysis from "../../../../common/AIAnalysis"; import { AddressHeader } from "../shared"; import ContractInfoCard from "../shared/ContractInfoCard"; import ContractInfoCards from "../shared/ContractInfoCards"; @@ -193,53 +195,97 @@ const ERC20Display: React.FC = ({ ? getAssetUrl(tokenMetadata.logo) : getAssetUrl(`assets/tokens/${networkId}/${addressHash.toLowerCase()}.png`); + const network = getNetworkById(networkId); + const networkName = network?.name ?? "Unknown Network"; + const networkCurrency = network?.currency ?? "ETH"; + + const aiContext = useMemo( + () => ({ + address: addressHash, + balance: address.balance, + txCount: address.txCount, + accountType: "erc20", + hasCode: true, + ensName: ensName ?? undefined, + tokenName: tokenName ?? undefined, + tokenSymbol: tokenSymbol ?? undefined, + tokenDecimals: tokenDecimals ?? undefined, + tokenTotalSupply: tokenTotalSupply ?? undefined, + isVerified: hasVerifiedContract, + contractName: contractData?.name ?? undefined, + }), + [ + addressHash, + address.balance, + address.txCount, + ensName, + tokenName, + tokenSymbol, + tokenDecimals, + tokenTotalSupply, + hasVerifiedContract, + contractData?.name, + ], + ); + return ( -
- - -
- {/* Overview + More Info Cards */} - +
+ - - {/* 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..97ba3aa 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,7 @@ import { import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; import { decodeAbiString } from "../../../../../utils/hexUtils"; import { logger } from "../../../../../utils/logger"; +import AIAnalysis from "../../../../common/AIAnalysis"; import { AddressHeader } from "../shared"; import ContractInfoCard from "../shared/ContractInfoCard"; import ContractInfoCards from "../shared/ContractInfoCards"; @@ -169,55 +171,97 @@ const ERC721Display: React.FC = ({ ? getAssetUrl(tokenMetadata.logo) : getAssetUrl(`assets/tokens/${networkId}/${addressHash.toLowerCase()}.png`); + const network = getNetworkById(networkId); + const networkName = network?.name ?? "Unknown Network"; + const networkCurrency = network?.currency ?? "ETH"; + + const aiContext = useMemo( + () => ({ + address: addressHash, + balance: address.balance, + 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, + ], + ); + return ( -
- - -
- {/* Overview + More Info Cards */} - +
+ - {/* NFT Collection Info Card */} - +
+ {/* Overview + More Info Cards */} + - {/* Contract Info Card (includes Contract Details) */} - + + {/* 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; From 3849bf5752303341fa3d1f5880bb9181336307ae Mon Sep 17 00:00:00 2001 From: Mati OS Date: Tue, 10 Feb 2026 17:52:39 -0300 Subject: [PATCH 12/23] feat(ai): add language-aware AI analysis responses - Thread app language through AIAnalysis -> useAIAnalysis -> AIService -> AIPromptTemplates - Add language instruction to all prompt templates so AI responds in the user's selected language (e.g., Spanish when app is set to es) - Default to English with no extra instruction for en locale --- src/components/common/AIAnalysis.tsx | 3 ++- src/hooks/useAIAnalysis.ts | 4 +++- src/services/AIPromptTemplates.ts | 24 ++++++++++++++++-------- src/services/AIService.ts | 2 ++ 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/components/common/AIAnalysis.tsx b/src/components/common/AIAnalysis.tsx index 6f7acfc..3bde9d5 100644 --- a/src/components/common/AIAnalysis.tsx +++ b/src/components/common/AIAnalysis.tsx @@ -20,13 +20,14 @@ const AIAnalysis: React.FC = ({ networkCurrency, cacheKey, }) => { - const { t } = useTranslation("common"); + const { t, i18n } = useTranslation("common"); const { result, loading, error, errorType, analyze, refresh } = useAIAnalysis( analysisType, context, networkName, networkCurrency, cacheKey, + i18n.language, ); return ( diff --git a/src/hooks/useAIAnalysis.ts b/src/hooks/useAIAnalysis.ts index 3013b96..62f7491 100644 --- a/src/hooks/useAIAnalysis.ts +++ b/src/hooks/useAIAnalysis.ts @@ -26,6 +26,7 @@ export function useAIAnalysis( networkName: string, networkCurrency: string, cacheKey: string, + language?: string, ): UseAIAnalysisReturn { const { settings } = useSettings(); const [result, setResult] = useState(null); @@ -81,6 +82,7 @@ export function useAIAnalysis( context, networkName, networkCurrency, + language, }); setCachedAnalysis(cacheKey, contextHash, analysisResult); @@ -98,7 +100,7 @@ export function useAIAnalysis( setLoading(false); } }, - [resolveProvider, context, cacheKey, analysisType, networkName, networkCurrency], + [resolveProvider, context, cacheKey, analysisType, networkName, networkCurrency, language], ); const analyze = useCallback(() => performAnalysis(false), [performAnalysis]); diff --git a/src/services/AIPromptTemplates.ts b/src/services/AIPromptTemplates.ts index 46c4ae4..795bd5d 100644 --- a/src/services/AIPromptTemplates.ts +++ b/src/services/AIPromptTemplates.ts @@ -3,6 +3,7 @@ import type { AIAnalysisType } from "../types"; interface PromptContext { networkName: string; networkCurrency: string; + language?: string; } interface PromptPair { @@ -27,42 +28,49 @@ export function buildPrompt( } } +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}.`; +} + function buildTransactionPrompt( context: Record, - { networkName, networkCurrency }: PromptContext, + { networkName, networkCurrency, language }: PromptContext, ): PromptPair { return { - system: `You are a blockchain analyst for the ${networkName} network (native currency: ${networkCurrency}). Explain this transaction in plain English. Be concise (3-5 sentences). Use markdown formatting. Focus on: what happened, who was involved, how much was transferred, and any notable aspects. If the transaction failed, explain why it might have failed.`, + system: `You are a blockchain analyst for the ${networkName} network (native currency: ${networkCurrency}). Explain this transaction in plain English. Be concise (3-5 sentences). Use markdown formatting. Focus on: what happened, who was involved, how much was transferred, and any notable aspects. If the transaction failed, explain why it might have failed.${languageInstruction(language)}`, user: formatContext(context), }; } function buildAccountPrompt( context: Record, - { networkName, networkCurrency }: PromptContext, + { networkName, networkCurrency, language }: PromptContext, ): PromptPair { return { - system: `You are a blockchain analyst for the ${networkName} network (native currency: ${networkCurrency}). Provide a brief analysis of this address. Be concise (3-5 sentences). Use markdown formatting. Focus on: account type (EOA vs contract), activity level, balance significance, and any patterns visible from recent transactions.`, + system: `You are a blockchain analyst for the ${networkName} network (native currency: ${networkCurrency}). Provide a brief analysis of this address. Be concise (3-5 sentences). Use markdown formatting. Focus on: account type (EOA vs contract), activity level, balance significance, and any patterns visible from recent transactions.${languageInstruction(language)}`, user: formatContext(context), }; } function buildContractPrompt( context: Record, - { networkName, networkCurrency }: PromptContext, + { networkName, networkCurrency, language }: PromptContext, ): PromptPair { return { - system: `You are a smart contract analyst for the ${networkName} network (native currency: ${networkCurrency}). Analyze this smart contract. Be concise but thorough (5-8 sentences). Use markdown formatting. Focus on: contract purpose, key functions, security considerations, and protocol or token standard identification.`, + system: `You are a smart contract analyst for the ${networkName} network (native currency: ${networkCurrency}). Analyze this smart contract. Be concise but thorough (5-8 sentences). Use markdown formatting. Focus on: contract purpose, key functions, security considerations, and protocol or token standard identification.${languageInstruction(language)}`, user: formatContext(context), }; } function buildBlockPrompt( context: Record, - { networkName, networkCurrency }: PromptContext, + { networkName, networkCurrency, language }: PromptContext, ): PromptPair { return { - system: `You are a blockchain analyst for the ${networkName} network (native currency: ${networkCurrency}). Analyze this block. Be concise (3-5 sentences). Use markdown formatting. Focus on: transaction count, gas usage patterns, block utilization, and any notable aspects (e.g., high gas usage, unusual activity).`, + system: `You are a blockchain analyst for the ${networkName} network (native currency: ${networkCurrency}). Analyze this block. Be concise (3-5 sentences). Use markdown formatting. Focus on: transaction count, gas usage patterns, block utilization, and any notable aspects (e.g., high gas usage, unusual activity).${languageInstruction(language)}`, user: formatContext(context), }; } diff --git a/src/services/AIService.ts b/src/services/AIService.ts index 61832d8..ffc3168 100644 --- a/src/services/AIService.ts +++ b/src/services/AIService.ts @@ -29,6 +29,7 @@ export interface AIAnalysisRequest { context: Record; networkName: string; networkCurrency: string; + language?: string; } /** @@ -48,6 +49,7 @@ export class AIService { const { system, user } = buildPrompt(request.type, request.context, { networkName: request.networkName, networkCurrency: request.networkCurrency, + language: request.language, }); try { From 89639e17b3edfb89a8294bea59783c1d804391eb Mon Sep 17 00:00:00 2001 From: Mati OS Date: Wed, 11 Feb 2026 09:48:28 -0300 Subject: [PATCH 13/23] feat(ai): add ERC-7730 transaction pre-analysis hook Use @erc7730/sdk ClearSigner to decode transaction calldata into human-readable format (intent, formatted fields, security warnings) before sending context to the LLM. --- src/hooks/useTransactionPreAnalysis.ts | 70 ++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/hooks/useTransactionPreAnalysis.ts 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 }; +} From d0fc2a68b0c335738e81be805713ac304befe09f Mon Sep 17 00:00:00 2001 From: Mati OS Date: Wed, 11 Feb 2026 09:48:33 -0300 Subject: [PATCH 14/23] feat(ai): enrich transaction prompt with ERC-7730 pre-analysis context Conditionally inject system prompt hint when erc7730Intent is present in the context, instructing the LLM to use decoded fields and highlight security warnings. --- src/services/AIPromptTemplates.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/services/AIPromptTemplates.ts b/src/services/AIPromptTemplates.ts index 795bd5d..2035799 100644 --- a/src/services/AIPromptTemplates.ts +++ b/src/services/AIPromptTemplates.ts @@ -39,8 +39,13 @@ function buildTransactionPrompt( context: Record, { networkName, networkCurrency, language }: 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: `You are a blockchain analyst for the ${networkName} network (native currency: ${networkCurrency}). Explain this transaction in plain English. Be concise (3-5 sentences). Use markdown formatting. Focus on: what happened, who was involved, how much was transferred, and any notable aspects. If the transaction failed, explain why it might have failed.${languageInstruction(language)}`, + system: `You are a blockchain analyst for the ${networkName} network (native currency: ${networkCurrency}). Explain this transaction in plain English. Be concise (3-5 sentences). Use markdown formatting. Focus on: what happened, who was involved, how much was transferred, and any notable aspects. If the transaction failed, explain why it might have failed.${preAnalysisHint}${languageInstruction(language)}`, user: formatContext(context), }; } From 33703260e62c6c8bc8081e60f7e59c18198ac378 Mon Sep 17 00:00:00 2001 From: Mati OS Date: Wed, 11 Feb 2026 09:48:40 -0300 Subject: [PATCH 15/23] feat(tx): integrate AI analysis panel into transaction display - Add 2-column layout with AI analysis panel on the right - Build rich context from tx data, decoded input, event logs, L2 fields, and ERC-7730 pre-analysis for LLM consumption - Add i18n keys for transaction AI analysis section (en + es) --- .../pages/evm/tx/TransactionDisplay.tsx | 1117 ++++++++++------- src/locales/en/transaction.json | 3 + src/locales/es/transaction.json | 3 + 3 files changed, 640 insertions(+), 483 deletions(-) diff --git a/src/components/pages/evm/tx/TransactionDisplay.tsx b/src/components/pages/evm/tx/TransactionDisplay.tsx index ec44b3b..8711721 100644 --- a/src/components/pages/evm/tx/TransactionDisplay.tsx +++ b/src/components/pages/evm/tx/TransactionDisplay.tsx @@ -1,10 +1,13 @@ import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; +import AIAnalysis from "../../../../components/common/AIAnalysis"; 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 type { TraceResult } from "../../../../services/adapters/NetworkAdapter"; import { logger } from "../../../../utils/logger"; @@ -48,6 +51,10 @@ 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 [showTrace, setShowTrace] = useState(false); @@ -117,6 +124,123 @@ 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 ctx: Record = { + hash: transaction.hash, + from: transaction.from, + to: transaction.to ?? "contract creation", + value: transaction.value, + status: status ? (isSuccess ? "success" : "failed") : "pending", + gasUsed: transaction.receipt?.gasUsed, + gasPrice: transaction.gasPrice, + transactionFee: fee, + blockNumber: transaction.blockNumber, + timestamp: transaction.timestamp, + nonce: transaction.nonce, + type: transaction.type, + isContractCreation: !transaction.to, + }; + + 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.l1Fee = opReceipt.l1Fee; + ctx.l1GasPrice = opReceipt.l1GasPrice; + 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) => ({ + label: f.label, + value: f.value, + })); + } + 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; + } + } + + return ctx; + }, [transaction, decodedInput, contractData?.abi, preAnalysis]); + // Check if trace is available (localhost only) const isTraceAvailable = dataService?.networkAdapter.isTraceAvailable() || false; @@ -274,554 +398,581 @@ 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}
- )} - {callTrace.calls && callTrace.calls.length > 0 && ( -
-
- {t("internalCalls")} ({callTrace.calls.length}): +
+ {t("traceGasUsed")} {callTrace.gasUsed} +
+ {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/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/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ó", From 720484476932ec3d7193a3ff22347d55fe8fc1dd Mon Sep 17 00:00:00 2001 From: Mati OS Date: Wed, 11 Feb 2026 15:35:49 -0300 Subject: [PATCH 16/23] feat(ai): add analysis to block page --- .../pages/evm/block/BlockDisplay.tsx | 605 ++++++++++-------- src/locales/en/block.json | 3 + src/locales/es/block.json | 3 + 3 files changed, 328 insertions(+), 283 deletions(-) diff --git a/src/components/pages/evm/block/BlockDisplay.tsx b/src/components/pages/evm/block/BlockDisplay.tsx index 3285f71..f99c4c5 100644 --- a/src/components/pages/evm/block/BlockDisplay.tsx +++ b/src/components/pages/evm/block/BlockDisplay.tsx @@ -1,7 +1,9 @@ -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 AIAnalysis from "../../../common/AIAnalysis"; import ExtraDataDisplay from "../../../common/ExtraDataDisplay"; import { RPCIndicator } from "../../../common/RPCIndicator"; @@ -16,6 +18,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); @@ -105,337 +110,371 @@ const BlockDisplay: React.FC = React.memo( ? (BigInt(block.gasUsed) * BigInt(block.baseFeePerGas)).toString() : null; + 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, + baseFeePerGas: block.baseFeePerGas ?? undefined, + burntFees: burntFees ?? undefined, + 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, burntFees]); + 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")}: + + 🔥 {formatEth(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/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/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" From 198a73094507ec2b0760fbb3260ae3ac9e06abc9 Mon Sep 17 00:00:00 2001 From: Mati OS Date: Wed, 11 Feb 2026 20:36:42 -0300 Subject: [PATCH 17/23] feat(ui): move ai analysis into expandable panel --- src/components/common/AIAnalysis.tsx | 116 ------------- src/components/common/AIAnalysisPanel.tsx | 162 ++++++++++++++++++ .../evm/address/displays/AccountDisplay.tsx | 18 +- .../evm/address/displays/ERC1155Display.tsx | 18 +- .../evm/address/displays/ERC20Display.tsx | 18 +- .../evm/address/displays/ERC721Display.tsx | 18 +- .../pages/evm/block/BlockDisplay.tsx | 18 +- .../pages/evm/tx/TransactionDisplay.tsx | 18 +- 8 files changed, 210 insertions(+), 176 deletions(-) delete mode 100644 src/components/common/AIAnalysis.tsx create mode 100644 src/components/common/AIAnalysisPanel.tsx diff --git a/src/components/common/AIAnalysis.tsx b/src/components/common/AIAnalysis.tsx deleted file mode 100644 index 3bde9d5..0000000 --- a/src/components/common/AIAnalysis.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import type React 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 AIAnalysis: React.FC = ({ - analysisType, - context, - networkName, - networkCurrency, - cacheKey, -}) => { - const { t, i18n } = useTranslation("common"); - const { result, loading, error, errorType, analyze, refresh } = useAIAnalysis( - analysisType, - context, - networkName, - networkCurrency, - cacheKey, - i18n.language, - ); - - return ( -
-
{t("aiAnalysis.title")}
- - - - {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 AIAnalysis; diff --git a/src/components/common/AIAnalysisPanel.tsx b/src/components/common/AIAnalysisPanel.tsx new file mode 100644 index 0000000..7f034fa --- /dev/null +++ b/src/components/common/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/pages/evm/address/displays/AccountDisplay.tsx b/src/components/pages/evm/address/displays/AccountDisplay.tsx index c08dcfc..7e6d93c 100644 --- a/src/components/pages/evm/address/displays/AccountDisplay.tsx +++ b/src/components/pages/evm/address/displays/AccountDisplay.tsx @@ -2,7 +2,7 @@ import type React from "react"; import { useCallback, useMemo, useState } from "react"; import { getNetworkById } from "../../../../../config/networks"; import type { Address, ENSReverseResult, RPCMetadata, Transaction } from "../../../../../types"; -import AIAnalysis from "../../../../common/AIAnalysis"; +import AIAnalysisPanel from "../../../../common/AIAnalysisPanel"; import { AddressHeader, TransactionHistory } from "../shared"; import AccountInfoCards from "../shared/AccountInfoCards"; @@ -95,15 +95,13 @@ const AccountDisplay: React.FC = ({ />
-
- -
+
); }; diff --git a/src/components/pages/evm/address/displays/ERC1155Display.tsx b/src/components/pages/evm/address/displays/ERC1155Display.tsx index 123ae99..c2eb61f 100644 --- a/src/components/pages/evm/address/displays/ERC1155Display.tsx +++ b/src/components/pages/evm/address/displays/ERC1155Display.tsx @@ -11,7 +11,7 @@ import { import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; import { decodeAbiString } from "../../../../../utils/hexUtils"; import { logger } from "../../../../../utils/logger"; -import AIAnalysis from "../../../../common/AIAnalysis"; +import AIAnalysisPanel from "../../../../common/AIAnalysisPanel"; import { AddressHeader } from "../shared"; import ContractInfoCard from "../shared/ContractInfoCard"; import ContractInfoCards from "../shared/ContractInfoCards"; @@ -273,15 +273,13 @@ const ERC1155Display: React.FC = ({ />
-
- -
+
); }; diff --git a/src/components/pages/evm/address/displays/ERC20Display.tsx b/src/components/pages/evm/address/displays/ERC20Display.tsx index b790039..8c6a9d8 100644 --- a/src/components/pages/evm/address/displays/ERC20Display.tsx +++ b/src/components/pages/evm/address/displays/ERC20Display.tsx @@ -11,7 +11,7 @@ import { import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; import { hexToUtf8 } from "../../../../../utils/erc20Utils"; import { logger } from "../../../../../utils/logger"; -import AIAnalysis from "../../../../common/AIAnalysis"; +import AIAnalysisPanel from "../../../../common/AIAnalysisPanel"; import { AddressHeader } from "../shared"; import ContractInfoCard from "../shared/ContractInfoCard"; import ContractInfoCards from "../shared/ContractInfoCards"; @@ -279,15 +279,13 @@ const ERC20Display: React.FC = ({ />
-
- -
+
); }; diff --git a/src/components/pages/evm/address/displays/ERC721Display.tsx b/src/components/pages/evm/address/displays/ERC721Display.tsx index 97ba3aa..357246f 100644 --- a/src/components/pages/evm/address/displays/ERC721Display.tsx +++ b/src/components/pages/evm/address/displays/ERC721Display.tsx @@ -11,7 +11,7 @@ import { import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; import { decodeAbiString } from "../../../../../utils/hexUtils"; import { logger } from "../../../../../utils/logger"; -import AIAnalysis from "../../../../common/AIAnalysis"; +import AIAnalysisPanel from "../../../../common/AIAnalysisPanel"; import { AddressHeader } from "../shared"; import ContractInfoCard from "../shared/ContractInfoCard"; import ContractInfoCards from "../shared/ContractInfoCards"; @@ -255,15 +255,13 @@ const ERC721Display: React.FC = ({ />
-
- -
+
); }; diff --git a/src/components/pages/evm/block/BlockDisplay.tsx b/src/components/pages/evm/block/BlockDisplay.tsx index f99c4c5..b24299d 100644 --- a/src/components/pages/evm/block/BlockDisplay.tsx +++ b/src/components/pages/evm/block/BlockDisplay.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { getNetworkById } from "../../../../config/networks"; import type { Block, BlockArbitrum, RPCMetadata } from "../../../../types"; -import AIAnalysis from "../../../common/AIAnalysis"; +import AIAnalysisPanel from "../../../common/AIAnalysisPanel"; import ExtraDataDisplay from "../../../common/ExtraDataDisplay"; import { RPCIndicator } from "../../../common/RPCIndicator"; @@ -466,15 +466,13 @@ const BlockDisplay: React.FC = React.memo(
)}
-
- -
+
); }, diff --git a/src/components/pages/evm/tx/TransactionDisplay.tsx b/src/components/pages/evm/tx/TransactionDisplay.tsx index 8711721..44d1c9d 100644 --- a/src/components/pages/evm/tx/TransactionDisplay.tsx +++ b/src/components/pages/evm/tx/TransactionDisplay.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; -import AIAnalysis from "../../../../components/common/AIAnalysis"; +import AIAnalysisPanel from "../../../../components/common/AIAnalysisPanel"; import LongString from "../../../../components/common/LongString"; import { RPCIndicator } from "../../../../components/common/RPCIndicator"; import { getNetworkById } from "../../../../config/networks"; @@ -964,15 +964,13 @@ const TransactionDisplay: React.FC = React.memo(
)}
-
- -
+
); }, From 2c9f2114fc4d442665d50c0f8436436d23b7e9ee Mon Sep 17 00:00:00 2001 From: Mati OS Date: Wed, 11 Feb 2026 20:36:59 -0300 Subject: [PATCH 18/23] style(ui): update ai analysis panel styling --- src/styles/ai-analysis.css | 91 ++++++++++++++++++++++---------------- 1 file changed, 54 insertions(+), 37 deletions(-) diff --git a/src/styles/ai-analysis.css b/src/styles/ai-analysis.css index a348a5d..4257118 100644 --- a/src/styles/ai-analysis.css +++ b/src/styles/ai-analysis.css @@ -1,61 +1,63 @@ -/* AI Analysis - 2-column layout and panel styles */ +/* AI Analysis - inline expandable panel styles */ .page-with-analysis { - display: grid; - grid-template-columns: 1fr; + display: flex; + flex-direction: column; gap: 16px; } -@media (min-width: 1200px) { - .page-with-analysis { - grid-template-columns: 2fr 1fr; - } -} - -.page-analysis-panel { - position: sticky; - top: 16px; - align-self: start; +/* AI Analysis Panel */ +.ai-analysis-panel { + display: flex; + flex-direction: column; + gap: 12px; } -/* AI Analysis Panel Card */ -.ai-analysis-panel { - background: var(--color-surface); - border: 1px solid var(--color-primary-alpha-10); - border-radius: 8px; - padding: 16px; +.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-bottom: 12px; + 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: 100%; - padding: 10px 16px; - border: 1px solid var(--color-primary-alpha-20); - border-radius: 6px; - background: var(--color-primary-alpha-8); - color: var(--text-primary); - font-size: 0.875rem; - font-weight: 500; + width: auto; + min-width: 140px; cursor: pointer; - transition: background 0.15s ease, border-color 0.15s ease; -} - -.ai-analysis-button:hover:not(:disabled) { - background: var(--color-primary-alpha-20); - border-color: var(--color-primary-alpha-30); } .ai-analysis-button:disabled { @@ -63,6 +65,21 @@ 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; @@ -82,7 +99,7 @@ /* Result Content */ .ai-analysis-result { - margin-top: 12px; + margin-top: 0; font-size: 0.875rem; line-height: 1.6; color: var(--text-primary); @@ -119,7 +136,7 @@ /* Footer */ .ai-analysis-footer { - margin-top: 12px; + margin-top: 0; padding-top: 8px; border-top: 1px solid var(--color-primary-alpha-8); display: flex; @@ -166,7 +183,7 @@ /* Error State */ .ai-analysis-error { - margin-top: 12px; + 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)); From 75c4234e30b7defde27814ce009aeeb975243397 Mon Sep 17 00:00:00 2001 From: Mati OS Date: Wed, 11 Feb 2026 20:37:16 -0300 Subject: [PATCH 19/23] i18n(ai): update ai analysis button labels --- src/locales/en/common.json | 4 +++- src/locales/es/common.json | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/locales/en/common.json b/src/locales/en/common.json index 6ae108d..28af34a 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -268,8 +268,10 @@ }, "aiAnalysis": { "title": "AI Analysis", - "analyzeButton": "Analyze", + "analyzeButton": "Analyze with AI", "analyzing": "Analyzing...", + "expand": "Expand analysis", + "collapse": "Collapse analysis", "refreshButton": "Refresh", "disclaimer": "AI-generated analysis. May contain errors.", "generatedBy": "by {{model}} ", diff --git a/src/locales/es/common.json b/src/locales/es/common.json index 6621a21..ff4de6b 100644 --- a/src/locales/es/common.json +++ b/src/locales/es/common.json @@ -260,8 +260,10 @@ }, "aiAnalysis": { "title": "Análisis IA", - "analyzeButton": "Analizar", + "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}} ", From 75c8491a8da0802b18105feadefb4e2a9b006911 Mon Sep 17 00:00:00 2001 From: Mati OS Date: Thu, 12 Feb 2026 12:10:55 -0300 Subject: [PATCH 20/23] chore(ts): Update target to modern browsers --- tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From 40584f30314ae1b1ef343e9540871a698400026a Mon Sep 17 00:00:00 2001 From: Mati OS Date: Thu, 12 Feb 2026 12:15:07 -0300 Subject: [PATCH 21/23] feat(AI): Improve prompting --- .../evm/address/displays/AccountDisplay.tsx | 19 +- .../evm/address/displays/ContractDisplay.tsx | 108 +++++--- .../evm/address/displays/ERC1155Display.tsx | 6 +- .../evm/address/displays/ERC20Display.tsx | 13 +- .../evm/address/displays/ERC721Display.tsx | 6 +- .../pages/evm/block/BlockDisplay.tsx | 31 +-- .../pages/evm/tx/TransactionDisplay.tsx | 159 ++++++++++-- src/services/AIPromptTemplates.ts | 233 ++++++++++++++++-- src/utils/aiCache.ts | 4 +- src/utils/aiContext.ts | 162 ++++++++++++ src/utils/aiUnits.ts | 71 ++++++ 11 files changed, 704 insertions(+), 108 deletions(-) create mode 100644 src/utils/aiContext.ts create mode 100644 src/utils/aiUnits.ts diff --git a/src/components/pages/evm/address/displays/AccountDisplay.tsx b/src/components/pages/evm/address/displays/AccountDisplay.tsx index 7e6d93c..2bb11be 100644 --- a/src/components/pages/evm/address/displays/AccountDisplay.tsx +++ b/src/components/pages/evm/address/displays/AccountDisplay.tsx @@ -5,6 +5,7 @@ import type { Address, ENSReverseResult, RPCMetadata, Transaction } from "../../ import AIAnalysisPanel from "../../../../common/AIAnalysisPanel"; import { AddressHeader, TransactionHistory } from "../shared"; import AccountInfoCards from "../shared/AccountInfoCards"; +import { formatNativeFromWei } from "../../../../../utils/aiUnits"; interface AccountDisplayProps { address: Address; @@ -46,22 +47,30 @@ const AccountDisplay: React.FC = ({ hash: tx.hash, from: tx.from, to: tx.to ?? "contract creation", - value: tx.value, + valueNative: formatNativeFromWei(tx.value, networkCurrency, 6), status: tx.receipt?.status === "0x1" || tx.receipt?.status === "1" ? "success" : "failed", })); - }, [transactions]); + }, [transactions, networkCurrency]); const aiContext = useMemo( () => ({ address: addressHash, - balance: address.balance, + 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], + [ + addressHash, + address.balance, + address.txCount, + address.code, + ensName, + recentTxSummary, + networkCurrency, + ], ); return ( @@ -100,7 +109,7 @@ const AccountDisplay: React.FC = ({ context={aiContext} networkName={networkName} networkCurrency={networkCurrency} - cacheKey={`account_${networkId}_${addressHash}`} + cacheKey={`openscan_ai_account_${networkId}_${addressHash}`} />
); diff --git a/src/components/pages/evm/address/displays/ContractDisplay.tsx b/src/components/pages/evm/address/displays/ContractDisplay.tsx index 7148850..84466fa 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/AIAnalysisPanel"; import { AddressHeader } from "../shared"; import ContractInfoCard from "../shared/ContractInfoCard"; import ContractInfoCards from "../shared/ContractInfoCards"; +import { logger } from "../../../../../utils"; +import { compactContractDataForAI } from "../../../../../utils/aiContext"; +import { formatNativeFromWei } from "../../../../../utils/aiUnits"; 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 c2eb61f..3e7449b 100644 --- a/src/components/pages/evm/address/displays/ERC1155Display.tsx +++ b/src/components/pages/evm/address/displays/ERC1155Display.tsx @@ -11,6 +11,7 @@ import { import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; import { decodeAbiString } from "../../../../../utils/hexUtils"; import { logger } from "../../../../../utils/logger"; +import { formatNativeFromWei } from "../../../../../utils/aiUnits"; import AIAnalysisPanel from "../../../../common/AIAnalysisPanel"; import { AddressHeader } from "../shared"; import ContractInfoCard from "../shared/ContractInfoCard"; @@ -196,7 +197,7 @@ const ERC1155Display: React.FC = ({ const aiContext = useMemo( () => ({ address: addressHash, - balance: address.balance, + balanceNative: formatNativeFromWei(address.balance, networkCurrency, 6), txCount: address.txCount, accountType: "erc1155", hasCode: true, @@ -217,6 +218,7 @@ const ERC1155Display: React.FC = ({ onChainData?.uri, hasVerifiedContract, contractData?.name, + networkCurrency, ], ); @@ -278,7 +280,7 @@ const ERC1155Display: React.FC = ({ context={aiContext} networkName={networkName} networkCurrency={networkCurrency} - cacheKey={`account_${networkId}_${addressHash}`} + cacheKey={`openscan_ai_contract_${networkId}_${addressHash}`} />
); diff --git a/src/components/pages/evm/address/displays/ERC20Display.tsx b/src/components/pages/evm/address/displays/ERC20Display.tsx index 8c6a9d8..482b111 100644 --- a/src/components/pages/evm/address/displays/ERC20Display.tsx +++ b/src/components/pages/evm/address/displays/ERC20Display.tsx @@ -11,6 +11,7 @@ import { import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; import { hexToUtf8 } from "../../../../../utils/erc20Utils"; import { logger } from "../../../../../utils/logger"; +import { formatNativeFromWei, formatTokenAmount } from "../../../../../utils/aiUnits"; import AIAnalysisPanel from "../../../../common/AIAnalysisPanel"; import { AddressHeader } from "../shared"; import ContractInfoCard from "../shared/ContractInfoCard"; @@ -202,7 +203,7 @@ const ERC20Display: React.FC = ({ const aiContext = useMemo( () => ({ address: addressHash, - balance: address.balance, + balanceNative: formatNativeFromWei(address.balance, networkCurrency, 6), txCount: address.txCount, accountType: "erc20", hasCode: true, @@ -210,7 +211,12 @@ const ERC20Display: React.FC = ({ tokenName: tokenName ?? undefined, tokenSymbol: tokenSymbol ?? undefined, tokenDecimals: tokenDecimals ?? undefined, - tokenTotalSupply: tokenTotalSupply ?? undefined, + tokenTotalSupplyFormatted: formatTokenAmount( + tokenTotalSupply, + tokenDecimals ?? undefined, + 6, + tokenSymbol ?? undefined, + ), isVerified: hasVerifiedContract, contractName: contractData?.name ?? undefined, }), @@ -225,6 +231,7 @@ const ERC20Display: React.FC = ({ tokenTotalSupply, hasVerifiedContract, contractData?.name, + networkCurrency, ], ); @@ -284,7 +291,7 @@ const ERC20Display: React.FC = ({ context={aiContext} networkName={networkName} networkCurrency={networkCurrency} - cacheKey={`account_${networkId}_${addressHash}`} + cacheKey={`openscan_ai_contract_${networkId}_${addressHash}`} />
); diff --git a/src/components/pages/evm/address/displays/ERC721Display.tsx b/src/components/pages/evm/address/displays/ERC721Display.tsx index 357246f..203d5c1 100644 --- a/src/components/pages/evm/address/displays/ERC721Display.tsx +++ b/src/components/pages/evm/address/displays/ERC721Display.tsx @@ -11,6 +11,7 @@ import { import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; import { decodeAbiString } from "../../../../../utils/hexUtils"; import { logger } from "../../../../../utils/logger"; +import { formatNativeFromWei } from "../../../../../utils/aiUnits"; import AIAnalysisPanel from "../../../../common/AIAnalysisPanel"; import { AddressHeader } from "../shared"; import ContractInfoCard from "../shared/ContractInfoCard"; @@ -178,7 +179,7 @@ const ERC721Display: React.FC = ({ const aiContext = useMemo( () => ({ address: addressHash, - balance: address.balance, + balanceNative: formatNativeFromWei(address.balance, networkCurrency, 6), txCount: address.txCount, accountType: "erc721", hasCode: true, @@ -199,6 +200,7 @@ const ERC721Display: React.FC = ({ totalSupply, hasVerifiedContract, contractData?.name, + networkCurrency, ], ); @@ -260,7 +262,7 @@ const ERC721Display: React.FC = ({ context={aiContext} networkName={networkName} networkCurrency={networkCurrency} - cacheKey={`account_${networkId}_${addressHash}`} + cacheKey={`openscan_ai_contract_${networkId}_${addressHash}`} />
); diff --git a/src/components/pages/evm/block/BlockDisplay.tsx b/src/components/pages/evm/block/BlockDisplay.tsx index b24299d..57db565 100644 --- a/src/components/pages/evm/block/BlockDisplay.tsx +++ b/src/components/pages/evm/block/BlockDisplay.tsx @@ -6,6 +6,7 @@ import type { Block, BlockArbitrum, RPCMetadata } from "../../../../types"; import AIAnalysisPanel from "../../../common/AIAnalysisPanel"; import ExtraDataDisplay from "../../../common/ExtraDataDisplay"; import { RPCIndicator } from "../../../common/RPCIndicator"; +import { formatGweiFromWei, formatNativeFromWei } from "../../../../utils/aiUnits"; interface BlockDisplayProps { block: Block | BlockArbitrum; @@ -82,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); @@ -109,6 +96,8 @@ 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 = { @@ -121,8 +110,8 @@ const BlockDisplay: React.FC = React.memo( gasUsed: block.gasUsed, gasLimit: block.gasLimit, gasUsedPercentage: gasUsedPct, - baseFeePerGas: block.baseFeePerGas ?? undefined, - burntFees: burntFees ?? undefined, + baseFeePerGasGwei, + burntFeesNative, size: block.size, extraData: block.extraData !== "0x" ? block.extraData : undefined, }; @@ -131,7 +120,7 @@ const BlockDisplay: React.FC = React.memo( ctx.sendCount = (block as BlockArbitrum).sendCount; } return ctx; - }, [block, gasUsedPct, burntFees]); + }, [block, gasUsedPct, baseFeePerGasGwei, burntFeesNative]); return (
@@ -243,7 +232,7 @@ const BlockDisplay: React.FC = React.memo(
{t("burntFees")}: - 🔥 {formatEth(burntFees)} + 🔥 {formatNative(burntFees)}
)} diff --git a/src/components/pages/evm/tx/TransactionDisplay.tsx b/src/components/pages/evm/tx/TransactionDisplay.tsx index 44d1c9d..0b6d109 100644 --- a/src/components/pages/evm/tx/TransactionDisplay.tsx +++ b/src/components/pages/evm/tx/TransactionDisplay.tsx @@ -9,8 +9,19 @@ 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/aiUnits"; import type { RPCMetadata, Transaction, @@ -57,6 +68,10 @@ const TransactionDisplay: React.FC = React.memo( 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: @@ -112,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), @@ -136,22 +196,47 @@ const TransactionDisplay: React.FC = React.memo( 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", - value: transaction.value, + valueNative, status: status ? (isSuccess ? "success" : "failed") : "pending", gasUsed: transaction.receipt?.gasUsed, - gasPrice: transaction.gasPrice, - transactionFee: fee, + 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; @@ -211,8 +296,8 @@ const TransactionDisplay: React.FC = React.memo( } if (receipt && "l1Fee" in receipt) { const opReceipt = receipt as TransactionReceiptOptimism; - ctx.l1Fee = opReceipt.l1Fee; - ctx.l1GasPrice = opReceipt.l1GasPrice; + ctx.l1FeeNative = formatNativeFromWei(opReceipt.l1Fee, networkCurrency, 6); + ctx.l1GasPriceGwei = formatGweiFromWei(opReceipt.l1GasPrice, 2); ctx.l1GasUsed = opReceipt.l1GasUsed; } @@ -221,10 +306,33 @@ const TransactionDisplay: React.FC = React.memo( ctx.erc7730Intent = preAnalysis.intent; ctx.erc7730Confidence = preAnalysis.confidence; if (preAnalysis.fields.length > 0) { - ctx.erc7730Fields = preAnalysis.fields.map((f) => ({ - label: f.label, - value: f.value, - })); + 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) => ({ @@ -236,10 +344,20 @@ const TransactionDisplay: React.FC = React.memo( if (preAnalysis.metadata.protocol) { ctx.erc7730Protocol = preAnalysis.metadata.protocol; } + ctx.erc7730Function = preAnalysis.functionName; + ctx.erc7730Signature = preAnalysis.signature; } return ctx; - }, [transaction, decodedInput, contractData?.abi, preAnalysis]); + }, [ + transaction, + decodedInput, + contractData?.abi, + preAnalysis, + networkCurrency, + callTargetToken, + callTargetTokenListMatch, + ]); // Check if trace is available (localhost only) const isTraceAvailable = dataService?.networkAdapter.isTraceAvailable() || false; @@ -295,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; diff --git a/src/services/AIPromptTemplates.ts b/src/services/AIPromptTemplates.ts index 2035799..1fc5296 100644 --- a/src/services/AIPromptTemplates.ts +++ b/src/services/AIPromptTemplates.ts @@ -11,6 +11,23 @@ interface PromptPair { 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, @@ -35,60 +52,244 @@ function languageInstruction(language?: string): string { 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, - { networkName, networkCurrency, language }: PromptContext, + 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." + ? "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: `You are a blockchain analyst for the ${networkName} network (native currency: ${networkCurrency}). Explain this transaction in plain English. Be concise (3-5 sentences). Use markdown formatting. Focus on: what happened, who was involved, how much was transferred, and any notable aspects. If the transaction failed, explain why it might have failed.${preAnalysisHint}${languageInstruction(language)}`, + system: buildSystemPrompt(PROMPT_CONFIGS.transaction, promptContext, preAnalysisHint), user: formatContext(context), }; } function buildAccountPrompt( context: Record, - { networkName, networkCurrency, language }: PromptContext, + promptContext: PromptContext, ): PromptPair { return { - system: `You are a blockchain analyst for the ${networkName} network (native currency: ${networkCurrency}). Provide a brief analysis of this address. Be concise (3-5 sentences). Use markdown formatting. Focus on: account type (EOA vs contract), activity level, balance significance, and any patterns visible from recent transactions.${languageInstruction(language)}`, + system: buildSystemPrompt(PROMPT_CONFIGS.account, promptContext), user: formatContext(context), }; } function buildContractPrompt( context: Record, - { networkName, networkCurrency, language }: PromptContext, + promptContext: PromptContext, ): PromptPair { return { - system: `You are a smart contract analyst for the ${networkName} network (native currency: ${networkCurrency}). Analyze this smart contract. Be concise but thorough (5-8 sentences). Use markdown formatting. Focus on: contract purpose, key functions, security considerations, and protocol or token standard identification.${languageInstruction(language)}`, + system: buildSystemPrompt(PROMPT_CONFIGS.contract, promptContext), user: formatContext(context), }; } function buildBlockPrompt( context: Record, - { networkName, networkCurrency, language }: PromptContext, + promptContext: PromptContext, ): PromptPair { return { - system: `You are a blockchain analyst for the ${networkName} network (native currency: ${networkCurrency}). Analyze this block. Be concise (3-5 sentences). Use markdown formatting. Focus on: transaction count, gas usage patterns, block utilization, and any notable aspects (e.g., high gas usage, unusual activity).${languageInstruction(language)}`, + system: buildSystemPrompt(PROMPT_CONFIGS.block, promptContext), user: formatContext(context), }; } function formatContext(context: Record): string { - const lines: string[] = []; - for (const [key, value] of Object.entries(context)) { + 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; - if (typeof value === "object") { - lines.push(`${key}: ${JSON.stringify(value)}`); - } else { - lines.push(`${key}: ${String(value)}`); + 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 lines.join("\n"); + + 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/utils/aiCache.ts b/src/utils/aiCache.ts index 0646d3f..7b7ff47 100644 --- a/src/utils/aiCache.ts +++ b/src/utils/aiCache.ts @@ -17,7 +17,9 @@ interface CachedAnalysis { * Used to hash serialized context objects for cache invalidation. */ export function hashContext(context: Record): string { - const str = JSON.stringify(context); + 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; diff --git a/src/utils/aiContext.ts b/src/utils/aiContext.ts new file mode 100644 index 0000000..e8fe5b4 --- /dev/null +++ b/src/utils/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/utils/aiUnits.ts b/src/utils/aiUnits.ts new file mode 100644 index 0000000..9a5a72f --- /dev/null +++ b/src/utils/aiUnits.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; + } +} + +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(); +} From d9b08ec2de580c9fd803d09bcfefe5176b6e90c5 Mon Sep 17 00:00:00 2001 From: Mati OS Date: Fri, 13 Feb 2026 15:19:05 -0300 Subject: [PATCH 22/23] refactor(utils): Unify logic to format units --- .../evm/address/displays/AccountDisplay.tsx | 2 +- .../evm/address/displays/ContractDisplay.tsx | 2 +- .../evm/address/displays/ERC1155Display.tsx | 2 +- .../evm/address/displays/ERC20Display.tsx | 2 +- .../evm/address/displays/ERC721Display.tsx | 2 +- .../pages/evm/block/BlockDisplay.tsx | 2 +- .../pages/evm/tx/TransactionDisplay.tsx | 2 +- src/locales/en/address.json | 2 +- src/locales/es/address.json | 2 +- src/utils/erc20Utils.ts | 34 +++++++------------ src/utils/{aiUnits.ts => unitFormatters.ts} | 2 +- 11 files changed, 22 insertions(+), 32 deletions(-) rename src/utils/{aiUnits.ts => unitFormatters.ts} (98%) diff --git a/src/components/pages/evm/address/displays/AccountDisplay.tsx b/src/components/pages/evm/address/displays/AccountDisplay.tsx index 2bb11be..3713f79 100644 --- a/src/components/pages/evm/address/displays/AccountDisplay.tsx +++ b/src/components/pages/evm/address/displays/AccountDisplay.tsx @@ -5,7 +5,7 @@ import type { Address, ENSReverseResult, RPCMetadata, Transaction } from "../../ import AIAnalysisPanel from "../../../../common/AIAnalysisPanel"; import { AddressHeader, TransactionHistory } from "../shared"; import AccountInfoCards from "../shared/AccountInfoCards"; -import { formatNativeFromWei } from "../../../../../utils/aiUnits"; +import { formatNativeFromWei } from "../../../../../utils/unitFormatters"; interface AccountDisplayProps { address: Address; diff --git a/src/components/pages/evm/address/displays/ContractDisplay.tsx b/src/components/pages/evm/address/displays/ContractDisplay.tsx index 84466fa..fc76047 100644 --- a/src/components/pages/evm/address/displays/ContractDisplay.tsx +++ b/src/components/pages/evm/address/displays/ContractDisplay.tsx @@ -10,7 +10,7 @@ import ContractInfoCard from "../shared/ContractInfoCard"; import ContractInfoCards from "../shared/ContractInfoCards"; import { logger } from "../../../../../utils"; import { compactContractDataForAI } from "../../../../../utils/aiContext"; -import { formatNativeFromWei } from "../../../../../utils/aiUnits"; +import { formatNativeFromWei } from "../../../../../utils/unitFormatters"; interface ContractDisplayProps { address: Address; diff --git a/src/components/pages/evm/address/displays/ERC1155Display.tsx b/src/components/pages/evm/address/displays/ERC1155Display.tsx index 3e7449b..0af2bd4 100644 --- a/src/components/pages/evm/address/displays/ERC1155Display.tsx +++ b/src/components/pages/evm/address/displays/ERC1155Display.tsx @@ -11,7 +11,7 @@ import { import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; import { decodeAbiString } from "../../../../../utils/hexUtils"; import { logger } from "../../../../../utils/logger"; -import { formatNativeFromWei } from "../../../../../utils/aiUnits"; +import { formatNativeFromWei } from "../../../../../utils/unitFormatters"; import AIAnalysisPanel from "../../../../common/AIAnalysisPanel"; import { AddressHeader } from "../shared"; import ContractInfoCard from "../shared/ContractInfoCard"; diff --git a/src/components/pages/evm/address/displays/ERC20Display.tsx b/src/components/pages/evm/address/displays/ERC20Display.tsx index 482b111..2f1505a 100644 --- a/src/components/pages/evm/address/displays/ERC20Display.tsx +++ b/src/components/pages/evm/address/displays/ERC20Display.tsx @@ -11,7 +11,7 @@ import { import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; import { hexToUtf8 } from "../../../../../utils/erc20Utils"; import { logger } from "../../../../../utils/logger"; -import { formatNativeFromWei, formatTokenAmount } from "../../../../../utils/aiUnits"; +import { formatNativeFromWei, formatTokenAmount } from "../../../../../utils/unitFormatters"; import AIAnalysisPanel from "../../../../common/AIAnalysisPanel"; import { AddressHeader } from "../shared"; import ContractInfoCard from "../shared/ContractInfoCard"; diff --git a/src/components/pages/evm/address/displays/ERC721Display.tsx b/src/components/pages/evm/address/displays/ERC721Display.tsx index 203d5c1..4bdbaef 100644 --- a/src/components/pages/evm/address/displays/ERC721Display.tsx +++ b/src/components/pages/evm/address/displays/ERC721Display.tsx @@ -11,7 +11,7 @@ import { import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; import { decodeAbiString } from "../../../../../utils/hexUtils"; import { logger } from "../../../../../utils/logger"; -import { formatNativeFromWei } from "../../../../../utils/aiUnits"; +import { formatNativeFromWei } from "../../../../../utils/unitFormatters"; import AIAnalysisPanel from "../../../../common/AIAnalysisPanel"; import { AddressHeader } from "../shared"; import ContractInfoCard from "../shared/ContractInfoCard"; diff --git a/src/components/pages/evm/block/BlockDisplay.tsx b/src/components/pages/evm/block/BlockDisplay.tsx index 57db565..99054c7 100644 --- a/src/components/pages/evm/block/BlockDisplay.tsx +++ b/src/components/pages/evm/block/BlockDisplay.tsx @@ -6,7 +6,7 @@ import type { Block, BlockArbitrum, RPCMetadata } from "../../../../types"; import AIAnalysisPanel from "../../../common/AIAnalysisPanel"; import ExtraDataDisplay from "../../../common/ExtraDataDisplay"; import { RPCIndicator } from "../../../common/RPCIndicator"; -import { formatGweiFromWei, formatNativeFromWei } from "../../../../utils/aiUnits"; +import { formatGweiFromWei, formatNativeFromWei } from "../../../../utils/unitFormatters"; interface BlockDisplayProps { block: Block | BlockArbitrum; diff --git a/src/components/pages/evm/tx/TransactionDisplay.tsx b/src/components/pages/evm/tx/TransactionDisplay.tsx index 0b6d109..c71225f 100644 --- a/src/components/pages/evm/tx/TransactionDisplay.tsx +++ b/src/components/pages/evm/tx/TransactionDisplay.tsx @@ -21,7 +21,7 @@ import { formatGweiFromWei, formatNativeFromWei, formatTokenAmount, -} from "../../../../utils/aiUnits"; +} from "../../../../utils/unitFormatters"; import type { RPCMetadata, Transaction, diff --git a/src/locales/en/address.json b/src/locales/en/address.json index a96c3d7..3491431 100644 --- a/src/locales/en/address.json +++ b/src/locales/en/address.json @@ -186,7 +186,7 @@ "nonce": "Nonce (Transactions Sent)", "aiAnalysis": { "sectionTitle": "AI Analysis" - } + }, "fetchingSentTxsByNonce": "Fetching sent transactions ({{current}}/{{total}})...", "searchingReceivedTxs": "Searching for received transactions..." } diff --git a/src/locales/es/address.json b/src/locales/es/address.json index 89dc389..d277fec 100644 --- a/src/locales/es/address.json +++ b/src/locales/es/address.json @@ -186,7 +186,7 @@ "nonce": "Nonce (transacciones enviadas)", "aiAnalysis": { "sectionTitle": "Análisis IA" - } + }, "fetchingSentTxsByNonce": "Obteniendo transacciones enviadas ({{current}}/{{total}})...", "searchingReceivedTxs": "Buscando transacciones recibidas..." } 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/aiUnits.ts b/src/utils/unitFormatters.ts similarity index 98% rename from src/utils/aiUnits.ts rename to src/utils/unitFormatters.ts index 9a5a72f..659ee5a 100644 --- a/src/utils/aiUnits.ts +++ b/src/utils/unitFormatters.ts @@ -12,7 +12,7 @@ function parseBigIntValue(value: string): bigint | null { } } -function formatUnitsValue( +export function formatUnitsValue( value: string, decimals: number, { maxDecimals = 6 }: FormatOptions = {}, From f0c4132a1f55e4adbd6d8ce031b00fd41e50df71 Mon Sep 17 00:00:00 2001 From: Mati OS Date: Fri, 13 Feb 2026 16:02:28 -0300 Subject: [PATCH 23/23] refactor(ai): Keep AI functionality closer --- src/components/common/{ => AIAnalysis}/AIAnalysisPanel.tsx | 4 ++-- src/{utils => components/common/AIAnalysis}/aiCache.ts | 4 ++-- src/{utils => components/common/AIAnalysis}/aiContext.ts | 2 +- .../pages/evm/address/displays/AccountDisplay.tsx | 2 +- .../pages/evm/address/displays/ContractDisplay.tsx | 4 ++-- .../pages/evm/address/displays/ERC1155Display.tsx | 2 +- src/components/pages/evm/address/displays/ERC20Display.tsx | 2 +- src/components/pages/evm/address/displays/ERC721Display.tsx | 2 +- src/components/pages/evm/block/BlockDisplay.tsx | 2 +- src/components/pages/evm/tx/TransactionDisplay.tsx | 2 +- src/components/pages/settings/index.tsx | 2 +- src/hooks/useAIAnalysis.ts | 6 +++++- 12 files changed, 19 insertions(+), 15 deletions(-) rename src/components/common/{ => AIAnalysis}/AIAnalysisPanel.tsx (97%) rename src/{utils => components/common/AIAnalysis}/aiCache.ts (97%) rename src/{utils => components/common/AIAnalysis}/aiContext.ts (98%) diff --git a/src/components/common/AIAnalysisPanel.tsx b/src/components/common/AIAnalysis/AIAnalysisPanel.tsx similarity index 97% rename from src/components/common/AIAnalysisPanel.tsx rename to src/components/common/AIAnalysis/AIAnalysisPanel.tsx index 7f034fa..50819af 100644 --- a/src/components/common/AIAnalysisPanel.tsx +++ b/src/components/common/AIAnalysis/AIAnalysisPanel.tsx @@ -3,8 +3,8 @@ 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"; +import { useAIAnalysis } from "../../../hooks/useAIAnalysis"; +import type { AIAnalysisType } from "../../../types"; interface AIAnalysisProps { analysisType: AIAnalysisType; diff --git a/src/utils/aiCache.ts b/src/components/common/AIAnalysis/aiCache.ts similarity index 97% rename from src/utils/aiCache.ts rename to src/components/common/AIAnalysis/aiCache.ts index 7b7ff47..a915168 100644 --- a/src/utils/aiCache.ts +++ b/src/components/common/AIAnalysis/aiCache.ts @@ -1,5 +1,5 @@ -import type { AIAnalysisResult } from "../types"; -import { logger } from "./logger"; +import type { AIAnalysisResult } from "../../../types"; +import { logger } from "../../../utils/logger"; const CACHE_PREFIX = "openscan_ai_"; const CACHE_VERSION = 1; diff --git a/src/utils/aiContext.ts b/src/components/common/AIAnalysis/aiContext.ts similarity index 98% rename from src/utils/aiContext.ts rename to src/components/common/AIAnalysis/aiContext.ts index e8fe5b4..1cd1b14 100644 --- a/src/utils/aiContext.ts +++ b/src/components/common/AIAnalysis/aiContext.ts @@ -1,4 +1,4 @@ -import type { ABI, ABIParameter } from "../types"; +import type { ABI, ABIParameter } from "../../../types"; const MAX_ABI_FUNCTIONS = 30; const MAX_ABI_EVENTS = 30; diff --git a/src/components/pages/evm/address/displays/AccountDisplay.tsx b/src/components/pages/evm/address/displays/AccountDisplay.tsx index 3713f79..125a97d 100644 --- a/src/components/pages/evm/address/displays/AccountDisplay.tsx +++ b/src/components/pages/evm/address/displays/AccountDisplay.tsx @@ -2,7 +2,7 @@ import type React from "react"; import { useCallback, useMemo, useState } from "react"; import { getNetworkById } from "../../../../../config/networks"; import type { Address, ENSReverseResult, RPCMetadata, Transaction } from "../../../../../types"; -import AIAnalysisPanel from "../../../../common/AIAnalysisPanel"; +import AIAnalysisPanel from "../../../../common/AIAnalysis/AIAnalysisPanel"; import { AddressHeader, TransactionHistory } from "../shared"; import AccountInfoCards from "../shared/AccountInfoCards"; import { formatNativeFromWei } from "../../../../../utils/unitFormatters"; diff --git a/src/components/pages/evm/address/displays/ContractDisplay.tsx b/src/components/pages/evm/address/displays/ContractDisplay.tsx index fc76047..950cef4 100644 --- a/src/components/pages/evm/address/displays/ContractDisplay.tsx +++ b/src/components/pages/evm/address/displays/ContractDisplay.tsx @@ -4,12 +4,12 @@ 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/AIAnalysisPanel"; +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 "../../../../../utils/aiContext"; +import { compactContractDataForAI } from "../../../../common/AIAnalysis/aiContext"; import { formatNativeFromWei } from "../../../../../utils/unitFormatters"; interface ContractDisplayProps { diff --git a/src/components/pages/evm/address/displays/ERC1155Display.tsx b/src/components/pages/evm/address/displays/ERC1155Display.tsx index 0af2bd4..d370b8f 100644 --- a/src/components/pages/evm/address/displays/ERC1155Display.tsx +++ b/src/components/pages/evm/address/displays/ERC1155Display.tsx @@ -12,7 +12,7 @@ import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../type import { decodeAbiString } from "../../../../../utils/hexUtils"; import { logger } from "../../../../../utils/logger"; import { formatNativeFromWei } from "../../../../../utils/unitFormatters"; -import AIAnalysisPanel from "../../../../common/AIAnalysisPanel"; +import AIAnalysisPanel from "../../../../common/AIAnalysis/AIAnalysisPanel"; import { AddressHeader } from "../shared"; import ContractInfoCard from "../shared/ContractInfoCard"; import ContractInfoCards from "../shared/ContractInfoCards"; diff --git a/src/components/pages/evm/address/displays/ERC20Display.tsx b/src/components/pages/evm/address/displays/ERC20Display.tsx index 2f1505a..eb64cf6 100644 --- a/src/components/pages/evm/address/displays/ERC20Display.tsx +++ b/src/components/pages/evm/address/displays/ERC20Display.tsx @@ -12,7 +12,7 @@ import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../type import { hexToUtf8 } from "../../../../../utils/erc20Utils"; import { logger } from "../../../../../utils/logger"; import { formatNativeFromWei, formatTokenAmount } from "../../../../../utils/unitFormatters"; -import AIAnalysisPanel from "../../../../common/AIAnalysisPanel"; +import AIAnalysisPanel from "../../../../common/AIAnalysis/AIAnalysisPanel"; import { AddressHeader } from "../shared"; import ContractInfoCard from "../shared/ContractInfoCard"; import ContractInfoCards from "../shared/ContractInfoCards"; diff --git a/src/components/pages/evm/address/displays/ERC721Display.tsx b/src/components/pages/evm/address/displays/ERC721Display.tsx index 4bdbaef..10905af 100644 --- a/src/components/pages/evm/address/displays/ERC721Display.tsx +++ b/src/components/pages/evm/address/displays/ERC721Display.tsx @@ -12,7 +12,7 @@ import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../type import { decodeAbiString } from "../../../../../utils/hexUtils"; import { logger } from "../../../../../utils/logger"; import { formatNativeFromWei } from "../../../../../utils/unitFormatters"; -import AIAnalysisPanel from "../../../../common/AIAnalysisPanel"; +import AIAnalysisPanel from "../../../../common/AIAnalysis/AIAnalysisPanel"; import { AddressHeader } from "../shared"; import ContractInfoCard from "../shared/ContractInfoCard"; import ContractInfoCards from "../shared/ContractInfoCards"; diff --git a/src/components/pages/evm/block/BlockDisplay.tsx b/src/components/pages/evm/block/BlockDisplay.tsx index 99054c7..2e4399d 100644 --- a/src/components/pages/evm/block/BlockDisplay.tsx +++ b/src/components/pages/evm/block/BlockDisplay.tsx @@ -3,7 +3,7 @@ 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/AIAnalysisPanel"; +import AIAnalysisPanel from "../../../common/AIAnalysis/AIAnalysisPanel"; import ExtraDataDisplay from "../../../common/ExtraDataDisplay"; import { RPCIndicator } from "../../../common/RPCIndicator"; import { formatGweiFromWei, formatNativeFromWei } from "../../../../utils/unitFormatters"; diff --git a/src/components/pages/evm/tx/TransactionDisplay.tsx b/src/components/pages/evm/tx/TransactionDisplay.tsx index c71225f..4cd5c57 100644 --- a/src/components/pages/evm/tx/TransactionDisplay.tsx +++ b/src/components/pages/evm/tx/TransactionDisplay.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; -import AIAnalysisPanel from "../../../../components/common/AIAnalysisPanel"; +import AIAnalysisPanel from "../../../common/AIAnalysis/AIAnalysisPanel"; import LongString from "../../../../components/common/LongString"; import { RPCIndicator } from "../../../../components/common/RPCIndicator"; import { getNetworkById } from "../../../../config/networks"; diff --git a/src/components/pages/settings/index.tsx b/src/components/pages/settings/index.tsx index 2ba2449..3a39d47 100644 --- a/src/components/pages/settings/index.tsx +++ b/src/components/pages/settings/index.tsx @@ -10,7 +10,7 @@ import { SUPPORTED_LANGUAGES } from "../../../i18n"; import { clearSupportersCache } from "../../../services/MetadataService"; import type { AIProvider, RPCUrls, RpcUrlsContextType } from "../../../types"; import { AI_PROVIDERS, AI_PROVIDER_ORDER } from "../../../config/aiProviders"; -import { clearAICache } from "../../../utils/aiCache"; +import { clearAICache } from "../../common/AIAnalysis/aiCache"; import { logger } from "../../../utils/logger"; import { getChainIdFromNetwork } from "../../../utils/networkResolver"; diff --git a/src/hooks/useAIAnalysis.ts b/src/hooks/useAIAnalysis.ts index 62f7491..9d791f5 100644 --- a/src/hooks/useAIAnalysis.ts +++ b/src/hooks/useAIAnalysis.ts @@ -3,7 +3,11 @@ 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 "../utils/aiCache"; +import { + getCachedAnalysis, + hashContext, + setCachedAnalysis, +} from "../components/common/AIAnalysis/aiCache"; import { logger } from "../utils/logger"; interface UseAIAnalysisReturn {