Compare commits

..

4 Commits

Author SHA1 Message Date
defiQUG
ac40184d6b Fix SolaceScan frontend service release path
All checks were successful
phoenix-deploy Deployed to explorer-live
Deploy Explorer Live / deploy (push) Successful in 2m10s
2026-04-29 06:42:20 -07:00
defiQUG
7a16ddccf7 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
2026-04-29 06:21:56 -07:00
defiQUG
1f5167aded Expose full verified contract source payloads 2026-04-29 06:21:36 -07:00
defiQUG
f5eb874210 Harden VMID 5000 frontend deploy server discovery 2026-04-29 06:19:32 -07:00
6 changed files with 469 additions and 17 deletions

View File

@@ -7,11 +7,11 @@ Wants=network.target
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/solacescanscout/frontend/current/explorer-monorepo/frontend
WorkingDirectory=/opt/solacescanscout/frontend/current
Environment=NODE_ENV=production
Environment=HOSTNAME=127.0.0.1
Environment=PORT=3000
ExecStart=/usr/bin/node /opt/solacescanscout/frontend/current/explorer-monorepo/frontend/server.js
ExecStart=/usr/bin/node /opt/solacescanscout/frontend/current/server.js
Restart=always
RestartSec=5
StandardOutput=journal

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

View File

@@ -18,6 +18,11 @@ export interface ContractMethodExecutionResult {
value: string
}
export interface ContractSourceFile {
path: string
content: string
}
export interface ContractProfile {
has_custom_methods_read: boolean
has_custom_methods_write: boolean
@@ -36,7 +41,10 @@ export interface ContractProfile {
license_type?: string
constructor_arguments?: string
abi?: string
abi_full?: string
source_code_full?: string
source_code_preview?: string
source_files: ContractSourceFile[]
source_status_text?: string
read_methods: ContractMethod[]
write_methods: ContractMethod[]
@@ -63,6 +71,7 @@ interface ContractCompatibilityAbiResponse {
interface ContractCompatibilitySourceRecord {
Address?: string
ContractName?: string
FileName?: string
CompilerVersion?: string
OptimizationUsed?: string | number
Runs?: string | number
@@ -111,6 +120,47 @@ function normalizeNumber(value: string | number | null | undefined): number | un
return undefined
}
function displaySourcePath(record: ContractCompatibilitySourceRecord | undefined): string {
const fileName = record?.FileName?.trim()
if (fileName) return fileName
const contractName = record?.ContractName?.trim()
if (contractName) return contractName.endsWith('.sol') ? contractName : `${contractName}.sol`
return 'Contract.sol'
}
function parseSourceFiles(sourceCode: string | undefined, record?: ContractCompatibilitySourceRecord): ContractSourceFile[] {
const trimmed = sourceCode?.trim()
if (!trimmed) return []
const candidates = [trimmed]
if (trimmed.startsWith('{{') && trimmed.endsWith('}}')) {
candidates.push(trimmed.slice(1, -1))
}
for (const candidate of candidates) {
try {
const parsed = JSON.parse(candidate) as {
sources?: Record<string, { content?: string } | string>
}
if (parsed && typeof parsed === 'object' && parsed.sources && typeof parsed.sources === 'object') {
return Object.entries(parsed.sources)
.map(([path, value]) => ({
path,
content: typeof value === 'string' ? value : value?.content || '',
}))
.filter((file) => file.content.trim().length > 0)
}
} catch {}
}
return [
{
path: displaySourcePath(record),
content: trimmed,
},
]
}
function parseABI(abiString?: string): ContractMethod[] {
if (!abiString) return []
try {
@@ -359,6 +409,7 @@ export const contractsApi = {
? sourceRecord.ABI
: undefined
const sourceCode = sourceRecord?.SourceCode
const sourceFiles = parseSourceFiles(sourceCode, sourceRecord)
const parsedMethods = parseABI(abiString)
const sourceVerified = Boolean(
abiString ||
@@ -391,7 +442,10 @@ export const contractsApi = {
license_type: sourceRecord?.LicenseType || undefined,
constructor_arguments: truncateHex(sourceRecord?.ConstructorArguments, 90),
abi: truncateText(abiString, 1200),
abi_full: abiString,
source_code_full: sourceCode,
source_code_preview: truncateText(sourceCode, 1200),
source_files: sourceFiles,
source_status_text: sourceStatusText || undefined,
read_methods: parsedMethods.filter(isReadMethod),
write_methods: parsedMethods.filter((method) => !isReadMethod(method)),

View File

@@ -114,7 +114,9 @@ fi
APP_RELATIVE_DIR="."
APP_SERVER_PATH="${STANDALONE_ROOT}/server.js"
if [[ ! -f "${APP_SERVER_PATH}" ]]; then
ALT_SERVER_PATH="$(find "${STANDALONE_ROOT}" -path '*/server.js' -print | head -n 1 || true)"
ALT_SERVER_PATH="$(find "${STANDALONE_ROOT}" \
-path '*/node_modules' -prune -o \
-path '*/server.js' -print | head -n 1 || true)"
if [[ -n "${ALT_SERVER_PATH}" ]]; then
APP_SERVER_PATH="${ALT_SERVER_PATH}"
APP_RELATIVE_DIR="$(dirname "${ALT_SERVER_PATH#${STANDALONE_ROOT}/}")"