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(params: URLSearchParams): Promise { const response = await fetch(`${getExplorerApiBase()}/api?${params.toString()}`) if (!response.ok) { throw new Error(`HTTP ${response.status}`) } return response.json() as Promise } 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 } 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 { 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( new URLSearchParams({ module: 'contract', action: 'getabi', address, }), ).catch(() => null), fetchCompatJson( 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, }