diff --git a/frontend/src/components/explorer/ContractCodeWorkspace.tsx b/frontend/src/components/explorer/ContractCodeWorkspace.tsx new file mode 100644 index 0000000..1838166 --- /dev/null +++ b/frontend/src/components/explorer/ContractCodeWorkspace.tsx @@ -0,0 +1,357 @@ +'use client' + +import { FormEvent, useMemo, useState } from 'react' +import clsx from 'clsx' +import { Card } from '@/libs/frontend-ui-primitives' +import EntityBadge from '@/components/common/EntityBadge' +import { getExplorerApiBase } from '@/services/api/blockscout' +import type { ContractProfile, ContractSourceFile } from '@/services/api/contracts' + +interface ContractCodeWorkspaceProps { + address: string + profile: ContractProfile +} + +interface OutlineEntry { + type: 'contract' | 'interface' | 'library' | 'function' | 'event' | 'error' + name: string + line: number +} + +const QUICK_PROMPTS = [ + 'What does this contract do?', + 'What are the functions available in this contract?', + 'Which functions can change state or move funds?', + 'Who has special permissions or control in this contract?', + 'What are potential risks or red flags in this contract?', +] as const + +function makeFallbackSourceFile(profile: ContractProfile): ContractSourceFile | null { + if (!profile.source_code_preview && !profile.abi_full && !profile.abi) return null + return { + path: profile.contract_name ? `${profile.contract_name}.sol` : 'Contract.sol', + content: profile.source_code_full || profile.source_code_preview || profile.abi_full || profile.abi || '', + } +} + +function parseOutline(content: string): OutlineEntry[] { + const entries: OutlineEntry[] = [] + content.split('\n').forEach((line, index) => { + const lineNumber = index + 1 + const typeMatch = line.match(/^\s*(?:abstract\s+)?(contract|interface|library)\s+([A-Za-z_][A-Za-z0-9_]*)/) + if (typeMatch) { + entries.push({ + type: typeMatch[1] as OutlineEntry['type'], + name: typeMatch[2], + line: lineNumber, + }) + return + } + + const memberMatch = line.match(/^\s*(function|event|error)\s+([A-Za-z_][A-Za-z0-9_]*)/) + if (memberMatch) { + entries.push({ + type: memberMatch[1] as OutlineEntry['type'], + name: memberMatch[2], + line: lineNumber, + }) + } + }) + return entries +} + +function sourceExcerptForPrompt(files: ContractSourceFile[]): string { + return files + .slice(0, 4) + .map((file) => `File: ${file.path}\n${file.content.slice(0, 2600)}`) + .join('\n\n') + .slice(0, 5200) +} + +export default function ContractCodeWorkspace({ address, profile }: ContractCodeWorkspaceProps) { + const files = useMemo(() => { + const normalized = profile.source_files?.length ? profile.source_files : [] + const fallback = makeFallbackSourceFile(profile) + return normalized.length > 0 ? normalized : fallback ? [fallback] : [] + }, [profile]) + + const [activeTab, setActiveTab] = useState<'source' | 'reader'>('source') + const [activePath, setActivePath] = useState(files[0]?.path || '') + const [prompt, setPrompt] = useState('What does this contract do?') + const [model, setModel] = useState('Explorer AI') + const [saveHistory, setSaveHistory] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [readerAnswer, setReaderAnswer] = useState('') + const [readerError, setReaderError] = useState('') + const [expanded, setExpanded] = useState(false) + + const activeFile = files.find((file) => file.path === activePath) || files[0] + const outline = useMemo(() => parseOutline(activeFile?.content || ''), [activeFile?.content]) + const sourceLines = useMemo(() => (activeFile?.content || '').split('\n'), [activeFile?.content]) + + const selectedFiles = files + const sourceAvailable = files.length > 0 && Boolean(activeFile?.content) + + const handleCopySource = async () => { + if (!activeFile?.content || typeof navigator === 'undefined') return + await navigator.clipboard?.writeText(activeFile.content) + } + + const handleCopyLink = async () => { + if (typeof navigator === 'undefined' || typeof window === 'undefined') return + await navigator.clipboard?.writeText(`${window.location.href.split('#')[0]}#contract-source`) + } + + const askReader = async (question: string) => { + const trimmed = question.trim() + if (!trimmed || submitting) return + setPrompt(trimmed) + setReaderError('') + setReaderAnswer('') + setSubmitting(true) + + try { + const context = [ + `Contract address: ${address}`, + profile.contract_name ? `Contract name: ${profile.contract_name}` : '', + profile.compiler_version ? `Compiler: ${profile.compiler_version}` : '', + profile.license_type ? `License: ${profile.license_type}` : '', + profile.proxy_type ? `Proxy type: ${profile.proxy_type}` : '', + `Read methods: ${profile.read_methods.map((method) => method.signature).slice(0, 24).join(', ') || 'none reported'}`, + `Write methods: ${profile.write_methods.map((method) => method.signature).slice(0, 24).join(', ') || 'none reported'}`, + sourceAvailable ? `Verified source excerpts:\n${sourceExcerptForPrompt(selectedFiles)}` : 'Verified source text is not available.', + ].filter(Boolean).join('\n') + + const response = await fetch(`${getExplorerApiBase()}/api/v1/ai/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: [ + { + role: 'user', + content: `${trimmed}\n\nUse this contract context and answer concisely. Do not invent behavior that is not supported by the ABI or source.\n\n${context}`, + }, + ], + pageContext: { + path: `/addresses/${address}`, + view: 'contract-code-reader', + address, + }, + }), + }) + + const payload = await response.json().catch(() => null) + if (!response.ok) { + throw new Error(payload?.error?.message || `AI reader returned HTTP ${response.status}`) + } + setReaderAnswer(String(payload?.reply || payload?.message?.content || 'No answer returned.')) + } catch (error) { + setReaderError(error instanceof Error ? error.message : 'Code Reader is temporarily unavailable.') + } finally { + setSubmitting(false) + } + } + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault() + await askReader(prompt) + } + + if (!sourceAvailable && !profile.abi_available) { + return null + } + + return ( + +
+
+
+ + +
+
+ {profile.source_verified ? : null} + {profile.abi_available ? : null} + {profile.compiler_version ? : null} +
+
+ + {activeTab === 'source' ? ( +
+
+ + +
+
+
+
{activeFile?.path || 'Source'}
+
{sourceLines.length} lines
+
+
+ + + +
+
+
+                  
+                    {sourceLines.map((line, index) => (
+                      
+                        {index + 1}
+                        {line || ' '}
+                      
+                    ))}
+                  
+                
+
+
+
+ ) : ( +
+
+
+ +
+
File Browser
+
+ {files.map((file) => ( + + ))} +
+
+
+
+
+
Prompt
+ +
+
+