461 lines
15 KiB
TypeScript
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,
|
|
}
|