Skip to content

Commit

Permalink
Vyper import from etherscan (#1795)
Browse files Browse the repository at this point in the history
* Updated the Etherscan verification handler to process Vyper contracts alongside Solidity contracts

* Refactor common functions, use correct typing, and fix errors

* Add tests

* handle "*" cases in etherscan vyper json input type

---------

Co-authored-by: Marco Castignoli <[email protected]>
  • Loading branch information
manuelwedler and marcocastignoli authored Jan 2, 2025
1 parent 2fc2008 commit 619dcc7
Show file tree
Hide file tree
Showing 7 changed files with 381 additions and 50 deletions.
2 changes: 1 addition & 1 deletion packages/lib-sourcify/src/lib/IVyperCompiler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export interface VyperSettings {
/** EVM version to compile for */
evmVersion: 'london' | 'paris' | 'shanghai' | 'cancun' | 'istanbul';
evmVersion?: 'london' | 'paris' | 'shanghai' | 'cancun' | 'istanbul';
/** Optimization mode */
optimize?: 'gas' | 'codesize' | 'none' | boolean;
/** Whether the bytecode should include Vyper's signature */
Expand Down
2 changes: 1 addition & 1 deletion packages/lib-sourcify/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export interface Metadata {
compilationTarget: {
[sourceName: string]: string;
};
evmVersion: string;
evmVersion?: string;
libraries?: {
[index: string]: string;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,74 @@ import {
JsonInput,
Metadata,
SourcifyChain,
VyperJsonInput,
findContractPathFromContractName,
} from "@ethereum-sourcify/lib-sourcify";
import { TooManyRequests } from "../../../../common/errors/TooManyRequests";
import { BadGatewayError } from "../../../../common/errors/BadGatewayError";
import logger from "../../../../common/logger";

interface VyperVersion {
compiler_version: string;
tag: string;
}

interface VyperVersionCache {
versions: VyperVersion[];
lastFetch: number;
}

let vyperVersionCache: VyperVersionCache | null = null;
const CACHE_DURATION_MS = 60 * 60 * 1000; // 1 hour default

export const getVyperCompilerVersion = async (
compilerString: string,
cacheDurationMs: number = CACHE_DURATION_MS,
): Promise<string | undefined> => {
const now = Date.now();

// Check if cache needs refresh
if (
!vyperVersionCache ||
now - vyperVersionCache.lastFetch > cacheDurationMs
) {
try {
const response = await fetch(
"https://vyper-releases-mirror.hardhat.org/list.json",
);
const versions = await response.json();
vyperVersionCache = {
versions: versions.map((version: any) => ({
compiler_version: version.assets[0]?.name
.replace("vyper.", "")
.replace(".darwin", "")
.replace(".linux", "")
.replace(".windows.exe", ""),
tag: version.tag_name.substring(1),
})),
lastFetch: now,
};
} catch (error) {
logger.error("Failed to fetch Vyper versions", { error });
// If cache exists but is stale, use it rather than failing
if (vyperVersionCache) {
logger.warn("Using stale Vyper versions cache");
} else {
throw error;
}
}
}

if (!vyperVersionCache) {
return undefined;
}

const versionNumber = compilerString.split(":")[1];
return vyperVersionCache.versions.find(
(version) => version.tag === versionNumber,
)?.compiler_version;
};

export type EtherscanResult = {
SourceCode: string;
ABI: string;
Expand All @@ -26,7 +88,7 @@ export type EtherscanResult = {
SwarmSource: string;
};

export const parseSolcJsonInput = (sourceCodeObject: string) => {
export const parseJsonInput = (sourceCodeObject: string) => {
return JSON.parse(sourceCodeObject.slice(1, -1));
};

Expand All @@ -38,7 +100,7 @@ export const isEtherscanMultipleFilesObject = (sourceCodeObject: string) => {
}
};

export const isEtherscanSolcJsonInput = (sourceCodeObject: string) => {
export const isEtherscanJsonInput = (sourceCodeObject: string) => {
if (sourceCodeObject.startsWith("{{")) {
return true;
}
Expand Down Expand Up @@ -73,11 +135,50 @@ export const getSolcJsonInputFromEtherscanResult = (
return solcJsonInput;
};

export const getVyperJsonInputFromEtherscanResult = (
etherscanResult: EtherscanResult,
sources: VyperJsonInput["sources"],
): VyperJsonInput => {
const generatedSettings = {
outputSelection: {
"*": ["evm.deployedBytecode.object"],
},
evmVersion:
etherscanResult.EVMVersion !== "Default"
? (etherscanResult.EVMVersion as any)
: undefined,
search_paths: ["."],
};
return {
language: "Vyper",
sources,
settings: generatedSettings,
};
};

export interface ProcessedEtherscanSolidityResult {
compilerVersion: string;
solcJsonInput: JsonInput;
contractName: string;
}

export interface ProcessedEtherscanVyperResult {
compilerVersion: string;
vyperJsonInput: VyperJsonInput;
contractPath: string;
contractName: string;
}

export interface ProcessedEtherscanResult {
vyperResult?: ProcessedEtherscanVyperResult;
solidityResult?: ProcessedEtherscanSolidityResult;
}

export const processRequestFromEtherscan = async (
sourcifyChain: SourcifyChain,
address: string,
apiKey?: string,
): Promise<any> => {
): Promise<ProcessedEtherscanResult> => {
if (!sourcifyChain.etherscanApi) {
throw new BadRequestError(
`Requested chain ${sourcifyChain.chainId} is not supported for importing from Etherscan`,
Expand Down Expand Up @@ -153,29 +254,37 @@ export const processRequestFromEtherscan = async (
});
throw new NotFoundError("This contract is not verified on Etherscan");
}
const contractResultJson = resultJson.result[0];
const sourceCodeObject = contractResultJson.SourceCode;

const contractResultJson = resultJson.result[0] as EtherscanResult;

if (contractResultJson.CompilerVersion.startsWith("vyper")) {
throw new Error("Sourcify currently cannot verify Vyper contracts");
return {
vyperResult: await processVyperResultFromEtherscan(contractResultJson),
};
} else {
return {
solidityResult: processSolidityResultFromEtherscan(contractResultJson),
};
}
};

const processSolidityResultFromEtherscan = (
contractResultJson: EtherscanResult,
): ProcessedEtherscanSolidityResult => {
const sourceCodeObject = contractResultJson.SourceCode;
// TODO: this is not used by lib-sourcify's useSolidityCompiler
const contractName = contractResultJson.ContractName;

const compilerVersion =
contractResultJson.CompilerVersion.charAt(0) === "v"
? contractResultJson.CompilerVersion.slice(1)
: contractResultJson.CompilerVersion;
// TODO: this is not used by lib-sourcify's useSolidityCompiler
const contractName = contractResultJson.ContractName;

let solcJsonInput: JsonInput;
// SourceCode can be the Solidity code if there is only one contract file, or the json object if there are multiple files
if (isEtherscanSolcJsonInput(sourceCodeObject)) {
logger.debug("Etherscan solcJsonInput contract found", {
chainId: sourcifyChain.chainId,
address,
secretUrl,
});
solcJsonInput = parseSolcJsonInput(sourceCodeObject);
if (isEtherscanJsonInput(sourceCodeObject)) {
logger.debug("Etherscan solcJsonInput contract found");
solcJsonInput = parseJsonInput(sourceCodeObject);

if (solcJsonInput?.settings) {
// Tell compiler to output metadata and bytecode
Expand All @@ -185,21 +294,13 @@ export const processRequestFromEtherscan = async (
];
}
} else if (isEtherscanMultipleFilesObject(sourceCodeObject)) {
logger.debug("Etherscan multiple file contract found", {
chainId: sourcifyChain.chainId,
address,
secretUrl,
});
logger.debug("Etherscan Solidity multiple file contract found");
solcJsonInput = getSolcJsonInputFromEtherscanResult(
contractResultJson,
JSON.parse(sourceCodeObject),
);
} else {
logger.debug("Etherscan single file contract found", {
chainId: sourcifyChain.chainId,
address,
secretUrl,
});
logger.debug("Etherscan Solidity single file contract found");
const contractPath = contractResultJson.ContractName + ".sol";
const sources = {
[contractPath]: {
Expand All @@ -213,11 +314,7 @@ export const processRequestFromEtherscan = async (
}

if (!solcJsonInput) {
logger.info("Etherscan API - no solcJsonInput", {
chainId: sourcifyChain.chainId,
address,
secretUrl,
});
logger.info("Etherscan API - no solcJsonInput");
throw new BadRequestError(
"Sourcify cannot generate the solcJsonInput from Etherscan result",
);
Expand All @@ -230,6 +327,83 @@ export const processRequestFromEtherscan = async (
};
};

const processVyperResultFromEtherscan = async (
contractResultJson: EtherscanResult,
): Promise<ProcessedEtherscanVyperResult> => {
const sourceCodeProperty = contractResultJson.SourceCode;

const compilerVersion = await getVyperCompilerVersion(
contractResultJson.CompilerVersion,
);
if (!compilerVersion) {
throw new BadRequestError(
"Could not map the Vyper version from Etherscan to a valid compiler version",
);
}

let contractName: string;
let contractPath: string;
let vyperJsonInput: VyperJsonInput;
if (isEtherscanJsonInput(sourceCodeProperty)) {
logger.debug("Etherscan vyperJsonInput contract found");

const parsedJsonInput = parseJsonInput(sourceCodeProperty);

// Etherscan derives the ContractName from the @title natspec. Therefore, we cannot use the ContractName to find the contract path.
contractPath = Object.keys(parsedJsonInput.settings.outputSelection)[0];

// contractPath can be also be "*" or "<unknown>", in the case of "<unknown>" both contractPath and contractName will be "<unknown>"
if (contractPath === "*") {
// in the case of "*", we extract the contract path from the sources using `ContractName`
contractPath = Object.keys(parsedJsonInput.sources).find((source) =>
source.includes(contractResultJson.ContractName),
)!;
if (!contractPath) {
throw new BadRequestError(
"This Vyper contracts is not verifiable by using Import From Etherscan",
);
}
}

// We need to use the name from the contractPath, because VyperCheckedContract uses it for selecting the compiler output.
contractName = contractPath.split("/").pop()!.split(".")[0];

vyperJsonInput = {
language: "Vyper",
sources: parsedJsonInput.sources,
settings: parsedJsonInput.settings,
};
} else {
logger.debug("Etherscan Vyper single file contract found");

// Since the ContractName from Etherscan is derived from the @title natspec, it can contain spaces.
// To be safe we also remove \n and \r characters
contractName = contractResultJson.ContractName.replace(/\s+/g, "")
.replace(/\n/g, "")
.replace(/\r/g, "");
contractPath = contractName + ".vy";

// The Vyper compiler has a bug where it throws if there are \r characters in the source code:
// https://github.com/vyperlang/vyper/issues/4297
const sourceCode = sourceCodeProperty.replace(/\r/g, "");
const sources = {
[contractPath]: { content: sourceCode },
};

vyperJsonInput = getVyperJsonInputFromEtherscanResult(
contractResultJson,
sources,
);
}

return {
compilerVersion,
vyperJsonInput,
contractPath,
contractName,
};
};

export const getMetadataFromCompiler = async (
solc: ISolidityCompiler,
compilerVersion: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,15 @@ export async function sessionVerifyFromEtherscan(req: Request, res: Response) {
const apiKey = req.body.apiKey;
const sourcifyChain = chainRepository.supportedChainMap[chain];

const { compilerVersion, solcJsonInput, contractName } =
await processRequestFromEtherscan(sourcifyChain, address, apiKey);
const { solidityResult } = await processRequestFromEtherscan(
sourcifyChain,
address,
apiKey,
);
if (!solidityResult) {
throw new BadRequestError("Received unsupported language from Etherscan");
}
const { compilerVersion, solcJsonInput, contractName } = solidityResult;

const metadata = await getMetadataFromCompiler(
solc,
Expand All @@ -52,7 +59,7 @@ export async function sessionVerifyFromEtherscan(req: Request, res: Response) {
(path) => {
return {
path: path,
content: stringToBase64(solcJsonInput.sources[path].content),
content: stringToBase64(solcJsonInput.sources[path].content!),
};
},
);
Expand Down
Loading

0 comments on commit 619dcc7

Please sign in to comment.