Files
explorer-monorepo/frontend/src/services/api/contracts.ts
2026-04-29 06:21:36 -07:00

461 lines
15 KiB
TypeScript

import { getExplorerApiBase, fetchBlockscoutJson } from './blockscout'
import { keccak_256 } from 'js-sha3'
export interface ContractMethodParam {
name: string
type: string
}
export interface ContractMethod {
name: string
signature: string
stateMutability: string
inputs: ContractMethodParam[]
outputs: ContractMethodParam[]
}
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
proxy_type?: string
is_self_destructed?: boolean
implementations: string[]
creation_bytecode?: string
deployed_bytecode?: string
source_verified: boolean
abi_available: boolean
contract_name?: string
compiler_version?: string
optimization_enabled?: boolean
optimization_runs?: number
evm_version?: string
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[]
}
function truncateHex(value?: string | null, maxLength = 66): string | undefined {
if (!value) return undefined
if (value.length <= maxLength) return value
return `${value.slice(0, maxLength)}...`
}
function truncateText(value?: string | null, maxLength = 400): string | undefined {
if (!value) return undefined
if (value.length <= maxLength) return value
return `${value.slice(0, maxLength)}...`
}
interface ContractCompatibilityAbiResponse {
status?: string | null
message?: string | null
result?: string | null
}
interface ContractCompatibilitySourceRecord {
Address?: string
ContractName?: string
FileName?: string
CompilerVersion?: string
OptimizationUsed?: string | number
Runs?: string | number
EVMVersion?: string
LicenseType?: string
ConstructorArguments?: string
SourceCode?: string
ABI?: string
}
interface ContractCompatibilitySourceResponse {
status?: string | null
message?: string | null
result?: ContractCompatibilitySourceRecord[] | null
}
interface ABIEntry {
type?: string
name?: string
stateMutability?: string
constant?: boolean
inputs?: Array<{ name?: string; type?: string }>
outputs?: Array<{ name?: string; type?: string }>
}
async function fetchCompatJson<T>(params: URLSearchParams): Promise<T> {
const response = await fetch(`${getExplorerApiBase()}/api?${params.toString()}`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return response.json() as Promise<T>
}
function normalizeBooleanFlag(value: string | number | null | undefined): boolean | undefined {
if (value == null || value === '') return undefined
if (typeof value === 'number') return value === 1
return value === '1' || value.toLowerCase() === 'true'
}
function normalizeNumber(value: string | number | null | undefined): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
}
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 {
const parsed = JSON.parse(abiString) as ABIEntry[]
if (!Array.isArray(parsed)) return []
return parsed
.filter((entry) => entry.type === 'function' && entry.name)
.map((entry) => {
const inputs = Array.isArray(entry.inputs)
? entry.inputs.map((input) => ({
name: input.name || '',
type: input.type || 'unknown',
}))
: []
const outputs = Array.isArray(entry.outputs)
? entry.outputs.map((output) => ({
name: output.name || '',
type: output.type || 'unknown',
}))
: []
return {
name: entry.name || 'unknown',
signature: `${entry.name || 'unknown'}(${inputs.map((input) => input.type).join(',')})`,
stateMutability:
entry.stateMutability ||
(entry.constant || outputs.length > 0 ? 'view' : 'nonpayable'),
inputs,
outputs,
}
})
} catch {
return []
}
}
function isReadMethod(method: ContractMethod): boolean {
return method.stateMutability === 'view' || method.stateMutability === 'pure'
}
function isSupportedInputType(type: string): boolean {
return ['address', 'bool', 'string', 'uint256', 'uint8', 'bytes32'].includes(type)
}
function isSupportedOutputType(type: string): boolean {
return ['address', 'bool', 'string', 'uint256', 'uint8', 'bytes32', 'bytes'].includes(type)
}
function supportsSimpleReadCall(method: ContractMethod): boolean {
return (
method.outputs.length === 1 &&
method.inputs.every((input) => isSupportedInputType(input.type)) &&
method.outputs.every((output) => isSupportedOutputType(output.type))
)
}
function supportsSimpleWriteCall(method: ContractMethod): boolean {
return !isReadMethod(method) && method.inputs.every((input) => isSupportedInputType(input.type))
}
function getPublicRpcUrl(): string {
return process.env.NEXT_PUBLIC_RPC_URL_138 || 'https://rpc-http-pub.d-bis.org'
}
function decodeDynamicString(wordData: string, offset: number): string {
const lengthHex = wordData.slice(offset, offset + 64)
const length = parseInt(lengthHex || '0', 16)
const start = offset + 64
const end = start + length * 2
const contentHex = wordData.slice(start, end)
if (!contentHex) return ''
const bytes = contentHex.match(/.{1,2}/g) || []
return bytes
.map((byte) => String.fromCharCode(parseInt(byte, 16)))
.join('')
.replace(/\u0000+$/g, '')
}
function validateAndEncodeInput(type: string, value: string): { head: string; tail?: string } {
const trimmed = value.trim()
switch (type) {
case 'address': {
if (!/^0x[a-fA-F0-9]{40}$/.test(trimmed)) {
throw new Error('Address inputs must be full 0x-prefixed addresses.')
}
return { head: trimmed.slice(2).toLowerCase().padStart(64, '0') }
}
case 'bool':
if (!['true', 'false', '1', '0'].includes(trimmed.toLowerCase())) {
throw new Error('Boolean inputs must be true/false or 1/0.')
}
return { head: (trimmed === 'true' || trimmed === '1' ? '1' : '0').padStart(64, '0') }
case 'uint256':
case 'uint8': {
if (!/^\d+$/.test(trimmed)) {
throw new Error('Unsigned integer inputs must be non-negative decimal numbers.')
}
return { head: BigInt(trimmed).toString(16).padStart(64, '0') }
}
case 'bytes32': {
if (!/^0x[a-fA-F0-9]{64}$/.test(trimmed)) {
throw new Error('bytes32 inputs must be 32-byte 0x-prefixed hex values.')
}
return { head: trimmed.slice(2).toLowerCase() }
}
case 'string': {
const contentHex = Array.from(new TextEncoder().encode(trimmed))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('')
const paddedContent = contentHex.padEnd(Math.ceil(contentHex.length / 64) * 64 || 64, '0')
const lengthHex = contentHex.length / 2
return {
head: '',
tail:
BigInt(lengthHex).toString(16).padStart(64, '0') +
paddedContent,
}
}
default:
throw new Error(`Unsupported input type ${type}`)
}
}
export function encodeMethodCalldata(method: ContractMethod, values: string[]): string {
if (values.length !== method.inputs.length) {
throw new Error('Method input count does not match the provided values.')
}
const selector = keccak_256(method.signature).slice(0, 8)
const encodedInputs = method.inputs.map((input, index) => validateAndEncodeInput(input.type, values[index] || ''))
let dynamicOffsetWords = method.inputs.length * 32
const heads = encodedInputs.map((encoded) => {
if (encoded.tail != null) {
const head = BigInt(dynamicOffsetWords).toString(16).padStart(64, '0')
dynamicOffsetWords += encoded.tail.length / 2
return head
}
return encoded.head
})
const tails = encodedInputs
.filter((encoded) => encoded.tail != null)
.map((encoded) => encoded.tail || '')
.join('')
return `0x${selector}${heads.join('')}${tails}`
}
function decodeSimpleOutput(outputType: string, data: string): string {
const normalized = data.replace(/^0x/i, '')
if (!normalized) return 'No data returned'
switch (outputType) {
case 'address':
return `0x${normalized.slice(24, 64)}`
case 'bool':
return BigInt(`0x${normalized.slice(0, 64)}`) === 0n ? 'false' : 'true'
case 'string': {
const offset = Number(BigInt(`0x${normalized.slice(0, 64)}`) * 2n)
return decodeDynamicString(normalized, offset)
}
case 'bytes32':
return `0x${normalized.slice(0, 64)}`
case 'bytes': {
const offset = Number(BigInt(`0x${normalized.slice(0, 64)}`) * 2n)
const length = parseInt(normalized.slice(offset + 64, offset + 128) || '0', 16)
const start = offset + 128
return `0x${normalized.slice(start, start + length * 2)}`
}
default:
if (outputType.startsWith('uint') || outputType.startsWith('int')) {
return BigInt(`0x${normalized.slice(0, 64)}`).toString()
}
return `0x${normalized}`
}
}
export async function callSimpleReadMethod(address: string, method: ContractMethod, values: string[] = []): Promise<string> {
if (!supportsSimpleReadCall(method)) {
throw new Error('Only simple read methods with supported input and output types are supported in this explorer surface.')
}
const data = encodeMethodCalldata(method, values)
const response = await fetch(getPublicRpcUrl(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'eth_call',
params: [
{
to: address,
data,
},
'latest',
],
}),
})
if (!response.ok) {
throw new Error(`RPC HTTP ${response.status}`)
}
const payload = (await response.json()) as { result?: string; error?: { message?: string } }
if (payload.error?.message) {
throw new Error(payload.error.message)
}
const result = payload.result || '0x'
return decodeSimpleOutput(method.outputs[0]?.type || 'bytes', result)
}
export const contractsApi = {
getProfileSafe: async (address: string): Promise<{ ok: boolean; data: ContractProfile | null }> => {
try {
const [raw, abiResponse, sourceResponse] = await Promise.all([
fetchBlockscoutJson<{
has_custom_methods_read?: boolean
has_custom_methods_write?: boolean
proxy_type?: string | null
is_self_destructed?: boolean | null
implementations?: Array<{ address?: string | null } | string>
creation_bytecode?: string | null
deployed_bytecode?: string | null
}>(`/api/v2/smart-contracts/${address}`),
fetchCompatJson<ContractCompatibilityAbiResponse>(
new URLSearchParams({
module: 'contract',
action: 'getabi',
address,
}),
).catch(() => null),
fetchCompatJson<ContractCompatibilitySourceResponse>(
new URLSearchParams({
module: 'contract',
action: 'getsourcecode',
address,
}),
).catch(() => null),
])
const sourceRecord = Array.isArray(sourceResponse?.result) ? sourceResponse?.result[0] : undefined
const abiString =
abiResponse?.status === '1' && abiResponse.result && abiResponse.result !== 'Contract source code not verified'
? abiResponse.result
: sourceRecord?.ABI && sourceRecord.ABI !== 'Contract source code not verified'
? sourceRecord.ABI
: undefined
const sourceCode = sourceRecord?.SourceCode
const sourceFiles = parseSourceFiles(sourceCode, sourceRecord)
const parsedMethods = parseABI(abiString)
const sourceVerified = Boolean(
abiString ||
(sourceCode && sourceCode.trim().length > 0) ||
(sourceRecord?.ContractName && sourceRecord.ContractName.trim().length > 0),
)
const sourceStatusText = abiResponse?.message || sourceResponse?.message || (sourceVerified ? 'Verified source available' : 'Contract source code not verified')
return {
ok: true,
data: {
has_custom_methods_read: !!raw.has_custom_methods_read,
has_custom_methods_write: !!raw.has_custom_methods_write,
proxy_type: raw.proxy_type || undefined,
is_self_destructed: raw.is_self_destructed ?? undefined,
implementations: Array.isArray(raw.implementations)
? raw.implementations
.map((entry) => typeof entry === 'string' ? entry : entry.address || '')
.filter(Boolean)
: [],
creation_bytecode: truncateHex(raw.creation_bytecode),
deployed_bytecode: truncateHex(raw.deployed_bytecode),
source_verified: sourceVerified,
abi_available: Boolean(abiString),
contract_name: sourceRecord?.ContractName || undefined,
compiler_version: sourceRecord?.CompilerVersion || undefined,
optimization_enabled: normalizeBooleanFlag(sourceRecord?.OptimizationUsed),
optimization_runs: normalizeNumber(sourceRecord?.Runs),
evm_version: sourceRecord?.EVMVersion || undefined,
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)),
},
}
} catch {
return { ok: false, data: null }
}
},
supportsSimpleReadCall,
supportsSimpleWriteCall,
}