refactor: rename SolaceScanScout to Solace and update related configurations
- Updated branding from "SolaceScanScout" to "Solace" across various files including deployment scripts, API responses, and documentation. - Changed default base URL for Playwright tests and updated security headers to reflect the new branding. - Enhanced README and API documentation to include new authentication endpoints and product access details. This refactor aligns the project branding and improves clarity in the API documentation.
This commit is contained in:
406
frontend/src/services/api/contracts.ts
Normal file
406
frontend/src/services/api/contracts.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
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 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
|
||||
source_code_preview?: string
|
||||
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
|
||||
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 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 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),
|
||||
source_code_preview: truncateText(sourceCode, 1200),
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user