@hyperlane-xyz/sdk
Version:
The official SDK for the Hyperlane Network
300 lines • 13.5 kB
JavaScript
import fetch from 'cross-fetch';
import { ethers } from 'ethers';
import { rootLogger, sleep, strip0x } from '@hyperlane-xyz/utils';
import { ExplorerFamily } from '../../metadata/chainMetadataTypes.js';
import { EXPLORER_GET_ACTIONS, ExplorerApiActions, ExplorerApiErrors, } from './types.js';
export class ContractVerifier {
multiProvider;
apiKeys;
logger = rootLogger.child({ module: 'ContractVerifier' });
contractSourceMap = {};
standardInputJson;
compilerOptions;
constructor(multiProvider, apiKeys, buildArtifact, licenseType) {
this.multiProvider = multiProvider;
this.apiKeys = apiKeys;
this.standardInputJson = buildArtifact.input;
const compilerversion = `v${buildArtifact.solcLongVersion}`;
// double check compiler version matches expected format
const versionRegex = /v(\d.\d.\d+)\+commit.\w+/;
const matches = versionRegex.exec(compilerversion);
if (!matches) {
throw new Error(`Invalid compiler version ${compilerversion}`);
}
// set compiler options
// only license type is configurable, empty if not provided
this.compilerOptions = {
codeformat: 'solidity-standard-json-input',
compilerversion,
licenseType,
};
// process input to create mapping of contract names to source names
// this is required to construct the fully qualified contract name
const contractRegex = /contract\s+([A-Z][a-zA-Z0-9]*)/g;
Object.entries(buildArtifact.input.sources).forEach(([sourceName, { content }]) => {
const matches = content.matchAll(contractRegex);
for (const match of matches) {
const contractName = match[1];
if (contractName) {
this.contractSourceMap[contractName] = sourceName;
}
}
});
}
async verifyContract(chain, input, logger = this.logger) {
const verificationLogger = logger.child({
chain,
name: input.name,
address: input.address,
});
const metadata = this.multiProvider.tryGetChainMetadata(chain);
const rpcUrl = metadata?.rpcUrls[0].http ?? '';
if (rpcUrl.includes('localhost') || rpcUrl.includes('127.0.0.1')) {
verificationLogger.debug('Skipping verification for local endpoints');
return;
}
const explorerApi = this.multiProvider.tryGetExplorerApi(chain);
if (!explorerApi) {
verificationLogger.debug('No explorer API set, skipping');
return;
}
if (!explorerApi.family) {
verificationLogger.debug(`No explorer family set, skipping`);
return;
}
if (explorerApi.family === ExplorerFamily.Other) {
verificationLogger.debug(`Unsupported explorer family, skipping`);
return;
}
if (input.address === ethers.constants.AddressZero)
return;
if (Array.isArray(input.constructorArguments)) {
verificationLogger.debug('Constructor arguments in legacy format, skipping');
return;
}
await this.verify(chain, input, verificationLogger);
}
async submitForm(chain, action, verificationLogger, options) {
const { apiUrl, family, apiKey = this.apiKeys[chain], } = this.multiProvider.getExplorerApi(chain);
const params = new URLSearchParams();
params.set('module', 'contract');
params.set('action', action);
if (apiKey)
params.set('apikey', apiKey);
for (const [key, value] of Object.entries(options ?? {})) {
params.set(key, value);
}
let timeout = 1000;
const url = new URL(apiUrl);
const isGetRequest = EXPLORER_GET_ACTIONS.includes(action);
if (isGetRequest)
url.search = params.toString();
switch (family) {
case ExplorerFamily.Etherscan:
timeout = 5000;
break;
case ExplorerFamily.Blockscout:
timeout = 1000;
url.searchParams.set('module', 'contract');
url.searchParams.set('action', action);
break;
case ExplorerFamily.Routescan:
timeout = 500;
break;
case ExplorerFamily.Other:
default:
throw new Error(`Unsupported explorer family: ${family}, ${chain}, ${apiUrl}`);
}
verificationLogger.trace({ apiUrl, chain }, 'Sending request to explorer...');
let response;
if (isGetRequest) {
response = await fetch(url.toString(), {
method: 'GET',
});
}
else {
const init = {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
};
response = await fetch(url.toString(), init);
}
let responseJson;
try {
const responseTextString = await response.text();
verificationLogger.trace({ apiUrl, chain }, 'Parsing response from explorer...');
responseJson = JSON.parse(responseTextString);
}
catch {
verificationLogger.trace({
failure: response.statusText,
status: response.status,
chain,
apiUrl,
family,
}, 'Failed to parse response from explorer.');
throw new Error(`Failed to parse response from explorer (${apiUrl}, ${chain}): ${response.statusText || 'UNKNOWN STATUS TEXT'} (${response.status || 'UNKNOWN STATUS'})`);
}
if (responseJson.message !== 'OK') {
let errorMessage;
switch (responseJson.result) {
case ExplorerApiErrors.VERIFICATION_PENDING:
verificationLogger.trace({
result: responseJson.result,
}, 'Verification still pending');
await sleep(timeout);
return this.submitForm(chain, action, verificationLogger, options);
case ExplorerApiErrors.ALREADY_VERIFIED:
case ExplorerApiErrors.ALREADY_VERIFIED_ALT:
break;
case ExplorerApiErrors.NOT_VERIFIED:
case ExplorerApiErrors.PROXY_FAILED:
case ExplorerApiErrors.BYTECODE_MISMATCH:
errorMessage = `${responseJson.message}: ${responseJson.result}`;
break;
default:
errorMessage = `Verification failed: ${JSON.stringify(responseJson.result) ?? response.statusText}`;
break;
}
if (errorMessage) {
verificationLogger.debug(errorMessage);
throw new Error(`[${chain}] ${errorMessage}`);
}
}
if (responseJson.result === ExplorerApiErrors.UNKNOWN_UID) {
await sleep(timeout);
return this.submitForm(chain, action, verificationLogger, options);
}
if (responseJson.result === ExplorerApiErrors.UNABLE_TO_VERIFY) {
const errorMessage = `Verification failed. ${JSON.stringify(responseJson.result) ?? response.statusText}`;
verificationLogger.debug(errorMessage);
throw new Error(`[${chain}] ${errorMessage}`);
}
verificationLogger.trace({ apiUrl, chain, result: responseJson.result }, 'Returning result from explorer.');
await sleep(timeout);
return responseJson.result;
}
async verify(chain, input, verificationLogger) {
const contractType = input.isProxy ? 'proxy' : 'implementation';
verificationLogger.debug(`📝 Verifying ${contractType}...`);
const data = input.isProxy
? this.getProxyData(input)
: this.getImplementationData(chain, input, verificationLogger);
try {
const guid = await this.submitForm(chain, input.isProxy
? ExplorerApiActions.VERIFY_PROXY
: ExplorerApiActions.VERIFY_IMPLEMENTATION, verificationLogger, data);
verificationLogger.trace({ guid }, `Retrieved guid from verified ${contractType}.`);
await this.checkStatus(chain, input, verificationLogger, guid, contractType);
const addressUrl = await this.multiProvider.tryGetExplorerAddressUrl(chain, input.address);
verificationLogger.debug({
addressUrl: addressUrl
? `${addressUrl}#code`
: `Could not retrieve ${contractType} explorer URL.`,
}, `✅ Successfully verified ${contractType}.`);
}
catch (error) {
verificationLogger.debug({ error }, `Verification of ${contractType} failed`);
throw error;
}
}
async checkStatus(chain, input, verificationLogger, guid, contractType) {
verificationLogger.trace({ guid }, `Checking ${contractType} status...`);
await this.submitForm(chain, input.isProxy
? ExplorerApiActions.CHECK_PROXY_STATUS
: ExplorerApiActions.CHECK_IMPLEMENTATION_STATUS, verificationLogger, {
guid: guid,
});
}
getProxyData(input) {
return {
address: input.address,
expectedimplementation: input.expectedimplementation,
};
}
getImplementationData(chain, input, verificationLogger) {
const sourceName = this.contractSourceMap[input.name];
if (!sourceName) {
const errorMessage = `Contract '${input.name}' not found in provided build artifact`;
verificationLogger.error(errorMessage);
throw new Error(`[${chain}] ${errorMessage}`);
}
const filteredStandardInputJson = this.filterStandardInputJsonByContractName(input.name, this.standardInputJson, verificationLogger);
return {
sourceCode: JSON.stringify(filteredStandardInputJson),
contractname: `${sourceName}:${input.name}`,
contractaddress: input.address,
/* TYPO IS ENFORCED BY API */
constructorArguements: strip0x(input.constructorArguments ?? ''),
...this.compilerOptions,
};
}
/**
* Filters the solidity standard input for a specific contract name.
*
* This is a BFS impl to traverse the source input dependency graph.
* 1. Named contract file is set as root node.
* 2. The next level is formed by the direct imports of the contract file.
* 3. Each subsequent level's dependencies form the next level, etc.
* 4. The queue tracks the next files to process, and ensures the dependency graph explorered level by level.
*/
filterStandardInputJsonByContractName(contractName, input, verificationLogger) {
verificationLogger.trace({ contractName }, 'Filtering unused contracts from solidity standard input JSON....');
const filteredSources = {};
const sourceFiles = Object.keys(input.sources);
const contractFile = this.getContractFile(contractName, sourceFiles);
const queue = [contractFile];
const processed = new Set();
while (queue.length > 0) {
const file = queue.shift();
if (processed.has(file))
continue;
processed.add(file);
filteredSources[file] = input.sources[file];
const content = input.sources[file].content;
const importStatements = this.getAllImportStatements(content);
importStatements.forEach((importStatement) => {
const importPath = importStatement.match(/["']([^"']+)["']/)?.[1];
if (importPath) {
const resolvedPath = this.resolveImportPath(file, importPath);
if (sourceFiles.includes(resolvedPath))
queue.push(resolvedPath);
}
});
}
return {
...input,
sources: filteredSources,
};
}
getContractFile(contractName, sourceFiles) {
const contractFile = sourceFiles.find((file) => file.endsWith(`/${contractName}.sol`));
if (!contractFile) {
throw new Error(`Contract ${contractName} not found in sources.`);
}
return contractFile;
}
getAllImportStatements(content) {
const importRegex = /import\s+(?:(?:(?:"[^"]+"|'[^']+')\s*;)|(?:{[^}]+}\s+from\s+(?:"[^"]+"|'[^']+')\s*;)|(?:\s*(?:"[^"]+"|'[^']+')\s*;))/g;
return content.match(importRegex) || [];
}
resolveImportPath(currentFile, importPath) {
/* Use as-is for external dependencies and absolute imports */
if (importPath.startsWith('@') || importPath.startsWith('http')) {
return importPath;
}
const currentDir = currentFile.split('/').slice(0, -1).join('/');
const resolvedPath = importPath.split('/').reduce((acc, part) => {
if (part === '..') {
acc.pop();
}
else if (part !== '.') {
acc.push(part);
}
return acc;
}, currentDir.split('/'));
return resolvedPath.join('/');
}
}
//# sourceMappingURL=ContractVerifier.js.map