Compare commits
4 Commits
feature/ex
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac40184d6b | ||
|
|
7a16ddccf7 | ||
|
|
1f5167aded | ||
|
|
f5eb874210 |
@@ -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
|
||||
|
||||
357
frontend/src/components/explorer/ContractCodeWorkspace.tsx
Normal file
357
frontend/src/components/explorer/ContractCodeWorkspace.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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}/}")"
|
||||
|
||||
Reference in New Issue
Block a user