Add verified contract source workspace
Some checks failed
phoenix-deploy Deploy failed: Command failed: bash scripts/deployment/phoenix-deploy-explorer-live-from-workspace.sh nginx: the configuration file /et
Deploy Explorer Live / deploy (push) Failing after 3m47s

This commit is contained in:
defiQUG
2026-04-29 06:21:56 -07:00
parent 1f5167aded
commit 7a16ddccf7
3 changed files with 410 additions and 14 deletions

View File

@@ -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 (
<Card className="mb-6" title="Contract Source Code">
<section id="contract-source" className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => 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
</button>
<button
type="button"
onClick={() => 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
</button>
</div>
<div className="flex flex-wrap gap-2">
{profile.source_verified ? <EntityBadge label="verified source" tone="success" /> : null}
{profile.abi_available ? <EntityBadge label="abi available" tone="info" /> : null}
{profile.compiler_version ? <EntityBadge label={profile.compiler_version} tone="neutral" className="normal-case tracking-normal" /> : null}
</div>
</div>
{activeTab === 'source' ? (
<div className={clsx('overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700', expanded ? 'min-h-[46rem]' : '')}>
<div className="grid lg:grid-cols-[18rem_minmax(0,1fr)]">
<aside className="border-b border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900 lg:border-b-0 lg:border-r">
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3 text-xs font-semibold uppercase text-gray-500 dark:border-gray-700 dark:text-gray-400">
<span>Explorer</span>
<span>{files.length} file{files.length === 1 ? '' : 's'}</span>
</div>
<div className="max-h-72 overflow-auto p-2 lg:max-h-[34rem]">
{files.map((file) => (
<button
type="button"
key={file.path}
onClick={() => setActivePath(file.path)}
className={clsx(
'block w-full rounded-md px-3 py-2 text-left text-sm transition',
file.path === activeFile?.path
? 'bg-white font-semibold text-gray-950 shadow-sm dark:bg-gray-800 dark:text-white'
: 'text-gray-700 hover:bg-white dark:text-gray-300 dark:hover:bg-gray-800',
)}
>
<span className="block truncate">{file.path}</span>
</button>
))}
</div>
{outline.length > 0 ? (
<div className="border-t border-gray-200 p-2 dark:border-gray-700">
<div className="px-3 py-2 text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">Outline</div>
<div className="max-h-64 overflow-auto">
{outline.slice(0, 80).map((entry) => (
<button
key={`${entry.type}-${entry.name}-${entry.line}`}
type="button"
onClick={() => document.getElementById(`source-line-${entry.line}`)?.scrollIntoView({ block: 'center' })}
className="flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-left text-xs text-gray-700 hover:bg-white dark:text-gray-300 dark:hover:bg-gray-800"
>
<span className="w-16 uppercase text-gray-400">{entry.type}</span>
<span className="min-w-0 flex-1 truncate font-mono">{entry.name}</span>
<span className="text-gray-400">{entry.line}</span>
</button>
))}
</div>
</div>
) : null}
</aside>
<div className="min-w-0 bg-gray-950 text-gray-100">
<div className="flex flex-col gap-3 border-b border-gray-800 bg-gray-900 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="truncate font-mono text-sm text-white">{activeFile?.path || 'Source'}</div>
<div className="mt-1 text-xs text-gray-400">{sourceLines.length} lines</div>
</div>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={handleCopySource} className="rounded-md border border-gray-700 px-3 py-1.5 text-xs font-semibold text-gray-200 hover:bg-gray-800">
Copy
</button>
<button type="button" onClick={handleCopyLink} className="rounded-md border border-gray-700 px-3 py-1.5 text-xs font-semibold text-gray-200 hover:bg-gray-800">
Link
</button>
<button type="button" onClick={() => 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'}
</button>
</div>
</div>
<pre className={clsx('overflow-auto p-0 text-xs leading-5', expanded ? 'max-h-[52rem]' : 'max-h-[34rem]')}>
<code className="block min-w-max py-4">
{sourceLines.map((line, index) => (
<span id={`source-line-${index + 1}`} key={`${activeFile?.path}-${index}`} className="grid grid-cols-[4.5rem_minmax(0,1fr)] px-4 hover:bg-white/5">
<span className="select-none pr-4 text-right text-gray-500">{index + 1}</span>
<span className="whitespace-pre text-gray-100">{line || ' '}</span>
</span>
))}
</code>
</pre>
</div>
</div>
</div>
) : (
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
<form onSubmit={handleSubmit} className="grid gap-5 lg:grid-cols-[28rem_minmax(0,1fr)]">
<div className="space-y-4 lg:border-r lg:border-gray-200 lg:pr-5 lg:dark:border-gray-700">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-gray-900 dark:text-white">Choose Model</span>
<select
value={model}
onChange={(event) => 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"
>
<option>Explorer AI</option>
<option>Grok</option>
</select>
</label>
<div>
<div className="mb-2 text-sm font-semibold text-gray-900 dark:text-white">File Browser</div>
<div className="space-y-2 rounded-lg bg-gray-50 p-3 dark:bg-gray-900">
{files.map((file) => (
<label key={file.path} className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
<input type="checkbox" checked readOnly className="h-4 w-4 rounded border-gray-300 text-primary-600" />
<span className="truncate">{file.path}</span>
</label>
))}
</div>
</div>
</div>
<div className="min-w-0 space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Prompt</div>
<label className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
<input type="checkbox" checked={saveHistory} onChange={(event) => setSaveHistory(event.target.checked)} className="h-4 w-4 rounded border-gray-300 text-primary-600" />
Save History
</label>
</div>
<div className="flex gap-3">
<textarea
value={prompt}
onChange={(event) => 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"
/>
<button
type="submit"
disabled={submitting || !prompt.trim()}
className="h-12 rounded-lg bg-primary-600 px-4 text-sm font-semibold text-white transition hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting ? '...' : 'Send'}
</button>
</div>
<div className="flex flex-wrap gap-2">
{QUICK_PROMPTS.map((quickPrompt) => (
<button
key={quickPrompt}
type="button"
onClick={() => 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}
</button>
))}
</div>
{readerAnswer ? (
<div className="whitespace-pre-wrap rounded-lg bg-gray-50 p-4 text-sm text-gray-800 dark:bg-gray-900 dark:text-gray-100">
{readerAnswer}
</div>
) : null}
{readerError ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-200">
{readerError}
</div>
) : null}
</div>
</form>
</div>
)}
</section>
</Card>
)
}

View File

@@ -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() {
</code>
</DetailRow>
)}
{contractProfile?.source_code_preview && (
<DetailRow label="Source Preview">
<code className="block max-h-56 overflow-auto whitespace-pre-wrap break-words rounded bg-gray-50 p-2 text-xs dark:bg-gray-950">
{contractProfile.source_code_preview}
</code>
</DetailRow>
)}
{contractProfile?.abi && (
<DetailRow label="ABI Preview">
<code className="block max-h-56 overflow-auto whitespace-pre-wrap break-words rounded bg-gray-50 p-2 text-xs dark:bg-gray-950">
{contractProfile.abi}
</code>
</DetailRow>
)}
{contractProfile?.read_methods && contractProfile.read_methods.length > 0 && (
<DetailRow label="Read Methods">
<div className="space-y-3">
@@ -861,6 +848,10 @@ export default function AddressDetailPage() {
</Card>
)}
{addressInfo.is_contract && contractProfile ? (
<ContractCodeWorkspace address={addressInfo.address} profile={contractProfile} />
) : null}
{gruProfile ? <div className="mb-6"><GruStandardsCard profile={gruProfile} /></div> : null}
<Card title="Token Balances" className="mb-6">

View File

@@ -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',