@openzeppelin/contracts-ui-builder-adapter-evm
Version:
EVM Adapter for Contracts UI Builder
422 lines (379 loc) • 14.6 kB
text/typescript
import { isAddress } from 'viem';
import type {
ContractDefinitionMetadata,
ContractSchema,
EvmNetworkConfig,
ProxyInfo,
} from '@openzeppelin/contracts-ui-builder-types';
import {
appConfigService,
logger,
simpleHash,
userExplorerConfigService,
withTimeout,
} from '@openzeppelin/contracts-ui-builder-utils';
import { getEvmExplorerAddressUrl } from '../configuration/explorer';
import { detectProxyFromAbi, getAdminAddress, getImplementationAddress } from '../proxy/detection';
import type { AbiItem, TypedEvmNetworkConfig } from '../types';
import type { EvmContractArtifacts } from '../types/artifacts';
import {
EvmProviderKeys,
isEvmProviderKey,
type EvmContractDefinitionProviderKey,
} from '../types/providers';
import { loadAbiFromEtherscan } from './etherscan';
import { getSourcifyRepoContractUrl, loadAbiFromSourcify } from './sourcify';
import { transformAbiToSchema } from './transformer';
/**
* Type guard to check if user config has defaultProvider field
*/
function hasDefaultProvider(
config: unknown
): config is { defaultProvider?: EvmContractDefinitionProviderKey } {
return config !== null && typeof config === 'object' && 'defaultProvider' in config;
}
/**
* Loads and parses an ABI directly from a JSON string.
*/
async function loadAbiFromJson(abiJsonString: string): Promise<ContractSchema> {
let abi: AbiItem[];
try {
abi = JSON.parse(abiJsonString);
if (!Array.isArray(abi)) {
throw new Error('Parsed JSON is not an array.');
}
} catch (error) {
logger.error('loadAbiFromJson', 'Failed to parse source string as JSON ABI:', error);
throw new Error(`Invalid JSON ABI provided: ${(error as Error).message}`);
}
logger.info('loadAbiFromJson', `Successfully parsed JSON ABI with ${abi.length} items.`);
const contractName = 'ContractFromABI'; // Default name for direct ABI
return transformAbiToSchema(abi, contractName, undefined);
}
/**
* Enhanced result type for ABI loading with metadata and proxy information
*/
export interface EvmContractLoadResult {
schema: ContractSchema;
source: 'fetched' | 'manual';
contractDefinitionOriginal?: string;
metadata?: ContractDefinitionMetadata;
proxyInfo?: ProxyInfo;
}
/**
* Options for contract loading behavior
*/
export interface ContractLoadOptions {
/** Skip proxy detection and load the contract ABI as-is */
skipProxyDetection?: boolean;
/** Force treating the address as an implementation contract */
treatAsImplementation?: boolean;
}
const PER_PROVIDER_TIMEOUT_MS = 4000;
const OVERALL_BUDGET_MS = 10000;
/**
* Loads contract schema from artifacts provided by the UI, prioritizing manual ABI input.
* Returns enhanced result with schema source information and automatic proxy detection.
*/
export async function loadEvmContract(
artifacts: EvmContractArtifacts,
networkConfig: EvmNetworkConfig,
options: ContractLoadOptions = {}
): Promise<EvmContractLoadResult> {
const { contractAddress, contractDefinition, __proxyDetectionOptions } = artifacts;
// Extract proxy detection options from form data if present
const proxyOptions = __proxyDetectionOptions as { skipProxyDetection?: boolean } | undefined;
if (proxyOptions?.skipProxyDetection) {
options.skipProxyDetection = true;
}
if (!contractAddress || typeof contractAddress !== 'string' || !isAddress(contractAddress)) {
throw new Error('A valid contract address is required.');
}
// 1. Prioritize manual contract definition input if provided.
if (
contractDefinition &&
typeof contractDefinition === 'string' &&
contractDefinition.trim().length > 0
) {
// Try to detect if this looks like JSON
const trimmed = contractDefinition.trim();
const hasJsonContent = trimmed.includes('[') && trimmed.includes(']') && trimmed.includes('{');
if (hasJsonContent) {
logger.info('loadEvmContract', 'Manual contract definition provided. Attempting to parse...');
try {
const schema = await loadAbiFromJson(contractDefinition);
// Attach the address to the schema from the separate address field.
return {
schema: { ...schema, address: contractAddress },
source: 'manual' as const,
contractDefinitionOriginal: contractDefinition,
metadata: {
contractName: schema.name,
fetchTimestamp: new Date(),
verificationStatus: 'unknown', // Manual ABI - verification status unknown
},
// Note: No proxy detection for manual ABIs - user provides what they want
};
} catch (error) {
logger.error('loadEvmContract', 'Failed to parse manually provided ABI:', error);
// If manual ABI is provided but invalid, it's a hard error.
throw new Error(`The provided ABI JSON is invalid: ${(error as Error).message}`);
}
}
}
// Extract optional forced provider (from adapter-specific field or generic 'service')
const forcedRaw =
(artifacts as unknown as { __forcedProvider?: string }).__forcedProvider ||
(artifacts as unknown as { service?: string }).service;
const forcedProvider: EvmContractDefinitionProviderKey | null = isEvmProviderKey(forcedRaw)
? (forcedRaw as EvmContractDefinitionProviderKey)
: null;
// 2. If no manual ABI, fall back to fetching from provider(s) with proxy detection.
logger.info(
'loadEvmContract',
`No manual ABI detected. Attempting Etherscan fetch for address: ${contractAddress}...`
);
return await loadContractWithProxyDetection(
contractAddress,
networkConfig as TypedEvmNetworkConfig,
options,
forcedProvider
);
}
/**
* Builds a standard contract result with metadata
*/
function buildContractResult(
contractAddress: string,
abiResult: { schema: ContractSchema; originalAbi: string },
networkConfig: TypedEvmNetworkConfig,
sourceProvider: EvmContractDefinitionProviderKey | null,
proxyInfo?: ProxyInfo
): EvmContractLoadResult {
// Determine provenance URL based on the provider that supplied the ABI
let fetchedFrom: string | undefined = undefined;
if (sourceProvider === EvmProviderKeys.Etherscan) {
fetchedFrom = getEvmExplorerAddressUrl(contractAddress, networkConfig) || undefined;
} else if (sourceProvider === EvmProviderKeys.Sourcify) {
fetchedFrom = getSourcifyRepoContractUrl(networkConfig.chainId, contractAddress);
} else {
// Fallback to resolved explorer URL when provider is unknown
fetchedFrom = getEvmExplorerAddressUrl(contractAddress, networkConfig) || undefined;
}
return {
schema: { ...abiResult.schema, address: contractAddress },
source: 'fetched',
contractDefinitionOriginal: abiResult.originalAbi,
metadata: {
fetchedFrom,
contractName: abiResult.schema.name,
verificationStatus: 'verified',
fetchTimestamp: new Date(),
definitionHash: simpleHash(abiResult.originalAbi),
},
proxyInfo,
};
}
/**
* Attempts to load implementation ABI for a detected proxy
*/
async function loadImplementationAbi(
_contractAddress: string,
implementationAddress: string,
networkConfig: TypedEvmNetworkConfig,
_proxyType: string
): Promise<{ schema: ContractSchema; originalAbi: string } | null> {
try {
const implementationResult = await loadAbiFromEtherscan(implementationAddress, networkConfig);
logger.info(
'loadImplementationAbi',
`Successfully fetched implementation ABI with ${implementationResult.schema.functions.length} functions`
);
return implementationResult;
} catch (implementationError) {
logger.warn(
'loadImplementationAbi',
`Failed to load implementation ABI: ${implementationError}`
);
return null;
}
}
/**
* Handles the proxy detection flow and returns appropriate result
*/
async function handleProxyDetection(
contractAddress: string,
initialResult: { schema: ContractSchema; originalAbi: string },
networkConfig: TypedEvmNetworkConfig,
initialProvider: EvmContractDefinitionProviderKey | null
): Promise<EvmContractLoadResult | null> {
// Parse the ABI to check for proxy patterns
const abi: AbiItem[] = JSON.parse(initialResult.originalAbi);
const proxyDetection = detectProxyFromAbi(abi);
if (!proxyDetection.isProxy) {
return null; // Not a proxy, let caller handle normal flow
}
logger.info(
'handleProxyDetection',
`Proxy detected: ${proxyDetection.proxyType} (confidence: ${proxyDetection.confidence})`
);
const proxyType = proxyDetection.proxyType || 'unknown';
const implementationAddress = await getImplementationAddress(
contractAddress,
networkConfig,
proxyType
);
// Attempt to resolve admin address as well for display purposes
const adminAddress = await getAdminAddress(contractAddress, networkConfig);
if (!implementationAddress) {
logger.info('handleProxyDetection', 'Proxy detected but implementation address not found');
// Return proxy ABI with proxy info indicating detection failure
return buildContractResult(contractAddress, initialResult, networkConfig, initialProvider, {
isProxy: true,
proxyType,
proxyAddress: contractAddress,
detectionMethod: 'automatic',
});
}
logger.info('handleProxyDetection', `Found implementation at: ${implementationAddress}`);
// Try to load implementation ABI
const implementationResult = await loadImplementationAbi(
contractAddress,
implementationAddress,
networkConfig,
proxyType
);
const baseProxyInfo = {
isProxy: true,
proxyType,
implementationAddress,
proxyAddress: contractAddress,
detectionMethod: 'automatic',
...(adminAddress ? { adminAddress } : {}),
};
if (implementationResult) {
// Use implementation ABI with proxy metadata
// Implementation ABI was fetched from Etherscan
return buildContractResult(
contractAddress,
implementationResult,
networkConfig,
EvmProviderKeys.Etherscan,
baseProxyInfo
);
} else {
// Fall back to proxy ABI with proxy info (provenance from initial provider)
return buildContractResult(
contractAddress,
initialResult,
networkConfig,
initialProvider,
baseProxyInfo
);
}
}
/**
* Loads contract with automatic proxy detection and implementation resolution
*/
async function loadContractWithProxyDetection(
contractAddress: string,
networkConfig: TypedEvmNetworkConfig,
options: ContractLoadOptions = {},
forcedProvider: EvmContractDefinitionProviderKey | null = null
): Promise<EvmContractLoadResult> {
try {
// Determine provider precedence based on forced provider and user config
const userConfig = userExplorerConfigService.getUserExplorerConfig(networkConfig.id);
const uiDefault: EvmContractDefinitionProviderKey | null = hasDefaultProvider(userConfig)
? userConfig.defaultProvider || null
: null;
// App-config default provider (optional)
const appDefaultRaw = appConfigService.getGlobalServiceParam(
'contractdefinition',
'defaultProvider'
);
const appDefault: EvmContractDefinitionProviderKey | null =
typeof appDefaultRaw === 'string' && isEvmProviderKey(appDefaultRaw)
? (appDefaultRaw as EvmContractDefinitionProviderKey)
: null;
// Helper function to build provider array from primary provider
const buildProviderArray = (
primary: EvmContractDefinitionProviderKey
): Array<EvmContractDefinitionProviderKey> => [
primary,
primary === EvmProviderKeys.Etherscan ? EvmProviderKeys.Sourcify : EvmProviderKeys.Etherscan,
];
const providers: Array<EvmContractDefinitionProviderKey> = forcedProvider
? [forcedProvider]
: uiDefault
? buildProviderArray(uiDefault)
: appDefault
? buildProviderArray(appDefault)
: [EvmProviderKeys.Etherscan, EvmProviderKeys.Sourcify];
const overallDeadline = Date.now() + OVERALL_BUDGET_MS;
let initialResult: { schema: ContractSchema; originalAbi: string } | null = null;
let lastError: unknown = null;
let usedProvider: EvmContractDefinitionProviderKey | null = null;
for (const provider of providers) {
try {
const remainingOverall = Math.max(100, overallDeadline - Date.now());
const attemptTimeout = Math.min(PER_PROVIDER_TIMEOUT_MS, remainingOverall);
if (provider === EvmProviderKeys.Etherscan) {
initialResult = await withTimeout(
loadAbiFromEtherscan(contractAddress, networkConfig),
attemptTimeout,
'etherscan'
);
} else if (provider === EvmProviderKeys.Sourcify) {
initialResult = await withTimeout(
loadAbiFromSourcify(contractAddress, networkConfig, attemptTimeout),
attemptTimeout,
'sourcify'
);
}
if (initialResult) {
usedProvider = provider;
break;
}
} catch (err) {
lastError = err;
continue;
}
}
if (!initialResult) throw lastError ?? new Error('No provider succeeded');
logger.info(
'loadContractWithProxyDetection',
`Successfully fetched initial ABI for ${contractAddress} with ${initialResult.schema.functions.length} functions`
);
// Step 2: Handle proxy detection if enabled
if (!options.skipProxyDetection && !options.treatAsImplementation) {
const proxyResult = await handleProxyDetection(
contractAddress,
initialResult,
networkConfig,
usedProvider
);
if (proxyResult) {
return proxyResult;
}
}
// Step 3: Not a proxy or proxy detection skipped - return original ABI
return buildContractResult(contractAddress, initialResult, networkConfig, usedProvider);
} catch (error) {
logger.warn('loadContractWithProxyDetection', `Contract loading failed: ${error}`);
// If a forced provider was specified, honor it and do NOT fallback automatically
if (forcedProvider) {
throw error;
}
// Check if this is a "contract not verified" error
const errorMessage = (error as Error).message || '';
if (errorMessage.includes('Contract not verified')) {
throw new Error(
`Contract at ${contractAddress} is not verified on the block explorer. ` +
`Verification status: unverified. Please provide the contract ABI manually.`
);
}
// Otherwise, rethrow the last error from provider attempts
throw error;
}
}