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 ( + + + + + setActiveTab('source')} + className={clsx( + 'rounded-lg px-3 py-2 text-sm font-semibold transition', + activeTab === 'source' + ? 'bg-primary-600 text-white' + : 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700', + )} + > + Source + + setActiveTab('reader')} + className={clsx( + 'rounded-lg px-3 py-2 text-sm font-semibold transition', + activeTab === 'reader' + ? 'bg-primary-600 text-white' + : 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700', + )} + > + Code Reader + + + + {profile.source_verified ? : null} + {profile.abi_available ? : null} + {profile.compiler_version ? : null} + + + + {activeTab === 'source' ? ( + + + + + + + + {activeFile?.path || 'Source'} + {sourceLines.length} lines + + + + Copy + + + Link + + setExpanded((value) => !value)} className="rounded-md border border-gray-700 px-3 py-1.5 text-xs font-semibold text-gray-200 hover:bg-gray-800"> + {expanded ? 'Collapse' : 'Expand'} + + + + + + {sourceLines.map((line, index) => ( + + {index + 1} + {line || ' '} + + ))} + + + + + + ) : ( + + + + + Choose Model + setModel(event.target.value)} + className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-950 dark:text-white" + > + Explorer AI + Grok + + + + File Browser + + {files.map((file) => ( + + + {file.path} + + ))} + + + + + + Prompt + + setSaveHistory(event.target.checked)} className="h-4 w-4 rounded border-gray-300 text-primary-600" /> + Save History + + + + setPrompt(event.target.value)} + rows={3} + className="min-h-24 flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-950 dark:text-white" + /> + + {submitting ? '...' : 'Send'} + + + + {QUICK_PROMPTS.map((quickPrompt) => ( + void askReader(quickPrompt)} + className="rounded-full border border-gray-300 px-3 py-1.5 text-xs font-semibold text-gray-700 hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300" + > + {quickPrompt} + + ))} + + {readerAnswer ? ( + + {readerAnswer} + + ) : null} + {readerError ? ( + + {readerError} + + ) : null} + + + + )} + + + ) +} diff --git a/frontend/src/pages/addresses/[address].tsx b/frontend/src/pages/addresses/[address].tsx index 02d9f62..4818071 100644 --- a/frontend/src/pages/addresses/[address].tsx +++ b/frontend/src/pages/addresses/[address].tsx @@ -29,6 +29,7 @@ import { } from '@/utils/watchlist' import PageIntro from '@/components/common/PageIntro' import GruStandardsCard from '@/components/common/GruStandardsCard' +import ContractCodeWorkspace from '@/components/explorer/ContractCodeWorkspace' import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru' import { getGruExplorerMetadata } from '@/services/api/gruExplorerData' import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation' @@ -702,20 +703,6 @@ export default function AddressDetailPage() { )} - {contractProfile?.source_code_preview && ( - - - {contractProfile.source_code_preview} - - - )} - {contractProfile?.abi && ( - - - {contractProfile.abi} - - - )} {contractProfile?.read_methods && contractProfile.read_methods.length > 0 && ( @@ -861,6 +848,10 @@ export default function AddressDetailPage() { )} + {addressInfo.is_contract && contractProfile ? ( + + ) : null} + {gruProfile ? : null} diff --git a/frontend/src/services/api/contracts.test.ts b/frontend/src/services/api/contracts.test.ts index 870adb8..29eddf7 100644 --- a/frontend/src/services/api/contracts.test.ts +++ b/frontend/src/services/api/contracts.test.ts @@ -67,9 +67,57 @@ describe('contractsApi', () => { expect(result.data?.optimization_runs).toBe(200) expect(result.data?.constructor_arguments?.endsWith('...')).toBe(true) expect(result.data?.abi).toContain('"symbol"') + expect(result.data?.abi_full).toContain('"symbol"') + expect(result.data?.source_files).toEqual([{ path: 'MockToken.sol', content: 'contract MockToken {}' }]) expect(result.data?.read_methods.map((method) => method.signature)).toContain('symbol()') }) + it('extracts Etherscan-style multi-file verified sources', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + has_custom_methods_read: true, + has_custom_methods_write: true, + implementations: [], + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + status: '1', + message: 'OK', + result: '[]', + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + status: '1', + message: 'OK', + result: [ + { + ContractName: 'RWAToken', + SourceCode: + '{{"language":"Solidity","sources":{"contracts/RWAToken.sol":{"content":"contract RWAToken {}"},"contracts/interfaces/IRWA.sol":{"content":"interface IRWA {}"}},"settings":{}}}', + }, + ], + }), + }), + ) + + const result = await contractsApi.getProfileSafe('0xcontract') + + expect(result.ok).toBe(true) + expect(result.data?.source_files).toEqual([ + { path: 'contracts/RWAToken.sol', content: 'contract RWAToken {}' }, + { path: 'contracts/interfaces/IRWA.sol', content: 'interface IRWA {}' }, + ]) + }) + it('calls a simple zero-arg read method through public RPC', async () => { vi.stubGlobal( 'fetch',
+ + {sourceLines.map((line, index) => ( + + {index + 1} + {line || ' '} + + ))} + +
+ {sourceLines.map((line, index) => ( + + {index + 1} + {line || ' '} + + ))} +
- {contractProfile.source_code_preview} -
- {contractProfile.abi} -