diff --git a/packages/lib-sourcify/src/lib/IVyperCompiler.ts b/packages/lib-sourcify/src/lib/IVyperCompiler.ts index 6ce05cc94..f1ba177cf 100644 --- a/packages/lib-sourcify/src/lib/IVyperCompiler.ts +++ b/packages/lib-sourcify/src/lib/IVyperCompiler.ts @@ -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 */ diff --git a/packages/lib-sourcify/src/lib/types.ts b/packages/lib-sourcify/src/lib/types.ts index 4256c30f0..d6cf1f0fd 100644 --- a/packages/lib-sourcify/src/lib/types.ts +++ b/packages/lib-sourcify/src/lib/types.ts @@ -107,7 +107,7 @@ export interface Metadata { compilationTarget: { [sourceName: string]: string; }; - evmVersion: string; + evmVersion?: string; libraries?: { [index: string]: string; }; diff --git a/services/server/src/server/controllers/verification/etherscan/etherscan.common.ts b/services/server/src/server/controllers/verification/etherscan/etherscan.common.ts index 6e5c46768..e7a598d6f 100644 --- a/services/server/src/server/controllers/verification/etherscan/etherscan.common.ts +++ b/services/server/src/server/controllers/verification/etherscan/etherscan.common.ts @@ -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 => { + 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; @@ -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)); }; @@ -38,7 +100,7 @@ export const isEtherscanMultipleFilesObject = (sourceCodeObject: string) => { } }; -export const isEtherscanSolcJsonInput = (sourceCodeObject: string) => { +export const isEtherscanJsonInput = (sourceCodeObject: string) => { if (sourceCodeObject.startsWith("{{")) { return true; } @@ -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 => { +): Promise => { if (!sourcifyChain.etherscanApi) { throw new BadRequestError( `Requested chain ${sourcifyChain.chainId} is not supported for importing from Etherscan`, @@ -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 @@ -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]: { @@ -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", ); @@ -230,6 +327,83 @@ export const processRequestFromEtherscan = async ( }; }; +const processVyperResultFromEtherscan = async ( + contractResultJson: EtherscanResult, +): Promise => { + 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 "", in the case of "" both contractPath and contractName will be "" + 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, diff --git a/services/server/src/server/controllers/verification/etherscan/session/etherscan.session.handlers.ts b/services/server/src/server/controllers/verification/etherscan/session/etherscan.session.handlers.ts index b001f0ba9..80c02044c 100644 --- a/services/server/src/server/controllers/verification/etherscan/session/etherscan.session.handlers.ts +++ b/services/server/src/server/controllers/verification/etherscan/session/etherscan.session.handlers.ts @@ -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, @@ -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!), }; }, ); diff --git a/services/server/src/server/controllers/verification/etherscan/stateless/etherscan.stateless.handlers.ts b/services/server/src/server/controllers/verification/etherscan/stateless/etherscan.stateless.handlers.ts index b86a2a82d..345b5a4fc 100644 --- a/services/server/src/server/controllers/verification/etherscan/stateless/etherscan.stateless.handlers.ts +++ b/services/server/src/server/controllers/verification/etherscan/stateless/etherscan.stateless.handlers.ts @@ -8,8 +8,61 @@ import { getResponseMatchFromMatch } from "../../../../common"; import { createSolidityCheckedContract } from "../../verification.common"; import logger from "../../../../../common/logger"; import { ChainRepository } from "../../../../../sourcify-chain-repository"; -import { ISolidityCompiler } from "@ethereum-sourcify/lib-sourcify"; +import { + ISolidityCompiler, + IVyperCompiler, + JsonInput, + VyperCheckedContract, + VyperJsonInput, +} from "@ethereum-sourcify/lib-sourcify"; import { Services } from "../../../../services/services"; +import { BadRequestError } from "../../../../../common/errors"; + +async function processEtherscanSolidityContract( + solc: ISolidityCompiler, + compilerVersion: string, + solcJsonInput: JsonInput, + contractName: string, +) { + const metadata = await getMetadataFromCompiler( + solc, + compilerVersion, + solcJsonInput, + contractName, + ); + + const mappedSources = getMappedSourcesFromJsonInput(solcJsonInput); + return createSolidityCheckedContract(solc, metadata, mappedSources); +} + +async function processEtherscanVyperContract( + vyperCompiler: IVyperCompiler, + compilerVersion: string, + vyperJsonInput: VyperJsonInput, + contractPath: string, + contractName: string, +) { + if (!vyperJsonInput.settings) { + throw new BadRequestError( + "Couldn't get Vyper compiler settings from Etherscan", + ); + } + const sourceMap = Object.fromEntries( + Object.entries(vyperJsonInput.sources).map(([path, content]) => [ + path, + content.content, + ]), + ); + + return new VyperCheckedContract( + vyperCompiler, + compilerVersion, + contractPath, + contractName, + vyperJsonInput.settings, + sourceMap, + ); +} export async function verifyFromEtherscan(req: Request, res: Response) { const services = req.app.get("services") as Services; @@ -17,6 +70,7 @@ export async function verifyFromEtherscan(req: Request, res: Response) { chainRepository.checkSupportedChainId(req.body.chain); const solc = req.app.get("solc") as ISolidityCompiler; + const vyperCompiler = req.app.get("vyper") as IVyperCompiler; const chain = req.body.chain as string; const address = req.body.address; @@ -25,22 +79,32 @@ export async function verifyFromEtherscan(req: Request, res: Response) { logger.info("verifyFromEtherscan", { chain, address, apiKey }); - const { compilerVersion, solcJsonInput, contractName } = - await processRequestFromEtherscan(sourcifyChain, address, apiKey); - - const metadata = await getMetadataFromCompiler( - solc, - compilerVersion, - solcJsonInput, - contractName, + const { vyperResult, solidityResult } = await processRequestFromEtherscan( + sourcifyChain, + address, + apiKey, ); - const mappedSources = getMappedSourcesFromJsonInput(solcJsonInput); - const checkedContract = createSolidityCheckedContract( - solc, - metadata, - mappedSources, - ); + let checkedContract; + if (solidityResult) { + checkedContract = await processEtherscanSolidityContract( + solc, + solidityResult.compilerVersion, + solidityResult.solcJsonInput, + solidityResult.contractName, + ); + } else if (vyperResult) { + checkedContract = await processEtherscanVyperContract( + vyperCompiler, + vyperResult.compilerVersion, + vyperResult.vyperJsonInput, + vyperResult.contractPath, + vyperResult.contractName, + ); + } else { + logger.error("Import from Etherscan: unsupported language"); + throw new BadRequestError("Received unsupported language from Etherscan"); + } const match = await services.verification.verifyDeployed( checkedContract, diff --git a/services/server/test/helpers/etherscanResponseMocks.ts b/services/server/test/helpers/etherscanResponseMocks.ts index 0bea314b5..c4f2cb6cb 100644 --- a/services/server/test/helpers/etherscanResponseMocks.ts +++ b/services/server/test/helpers/etherscanResponseMocks.ts @@ -102,3 +102,51 @@ export const STANDARD_JSON_CONTRACT_RESPONSE = { }, ], }; + +export const VYPER_SINGLE_CONTRACT_RESPONSE = { + status: "1", + message: "OK", + result: [ + { + SourceCode: + '# @version 0.3.10\r\n"""\r\n@title XYZ Broadcaster\r\n@author CurveFi\r\n@license MIT\r\n@custom:version 0.0.2\r\n@custom:security security@curve.fi\r\n"""\r\n\r\nversion: public(constant(String[8])) = "0.0.2"\r\n\r\nevent Broadcast:\r\n agent: indexed(Agent)\r\n chain_id: indexed(uint256)\r\n nonce: uint256\r\n digest: bytes32\r\n deadline: uint256\r\n\r\nevent ApplyAdmins:\r\n admins: AdminSet\r\n\r\nevent CommitAdmins:\r\n future_admins: AdminSet\r\n\r\n\r\nenum Agent:\r\n OWNERSHIP\r\n PARAMETER\r\n EMERGENCY\r\n\r\n\r\nstruct AdminSet:\r\n ownership: address\r\n parameter: address\r\n emergency: address\r\n\r\nstruct Message:\r\n target: address\r\n data: Bytes[MAX_BYTES]\r\n\r\n\r\nMAX_BYTES: constant(uint256) = 1024\r\nMAX_MESSAGES: constant(uint256) = 8\r\n\r\nDAY: constant(uint256) = 86400\r\nWEEK: constant(uint256) = 7 * DAY\r\n\r\nadmins: public(AdminSet)\r\nfuture_admins: public(AdminSet)\r\n\r\nagent: HashMap[address, Agent]\r\n\r\nnonce: public(HashMap[Agent, HashMap[uint256, uint256]]) # agent -> chainId -> nonce\r\ndigest: public(HashMap[Agent, HashMap[uint256, HashMap[uint256, bytes32]]]) # agent -> chainId -> nonce -> messageDigest\r\ndeadline: public(HashMap[Agent, HashMap[uint256, HashMap[uint256, uint256]]]) # agent -> chainId -> nonce -> deadline\r\n\r\n\r\n@external\r\ndef __init__(_admins: AdminSet):\r\n assert _admins.ownership != _admins.parameter # a != b\r\n assert _admins.ownership != _admins.emergency # a != c\r\n assert _admins.parameter != _admins.emergency # b != c\r\n\r\n self.admins = _admins\r\n\r\n self.agent[_admins.ownership] = Agent.OWNERSHIP\r\n self.agent[_admins.parameter] = Agent.PARAMETER\r\n self.agent[_admins.emergency] = Agent.EMERGENCY\r\n\r\n log ApplyAdmins(_admins)\r\n\r\n\r\n@internal\r\n@pure\r\ndef _get_ttl(agent: Agent, ttl: uint256) -> uint256:\r\n if agent == Agent.EMERGENCY:\r\n # Emergency votes should be brisk\r\n if ttl == 0:\r\n ttl = DAY # default\r\n assert ttl <= WEEK\r\n else:\r\n if ttl == 0:\r\n ttl = WEEK # default\r\n assert DAY <= ttl and ttl <= 3 * WEEK\r\n return ttl\r\n\r\n\r\n@external\r\ndef broadcast(_chain_id: uint256, _messages: DynArray[Message, MAX_MESSAGES], _ttl: uint256=0):\r\n """\r\n @notice Broadcast a sequence of messages.\r\n @param _chain_id The chain id to have messages executed on.\r\n @param _messages The sequence of messages to broadcast.\r\n @param _ttl Time-to-leave for message if it\'s not executed. 0 will use default values.\r\n """\r\n agent: Agent = self.agent[msg.sender]\r\n assert agent != empty(Agent) and len(_messages) > 0\r\n ttl: uint256 = self._get_ttl(agent, _ttl)\r\n\r\n digest: bytes32 = keccak256(_abi_encode(_messages))\r\n nonce: uint256 = self.nonce[agent][_chain_id]\r\n\r\n self.digest[agent][_chain_id][nonce] = digest\r\n self.nonce[agent][_chain_id] = nonce + 1\r\n self.deadline[agent][_chain_id][nonce] = block.timestamp + ttl\r\n\r\n log Broadcast(agent, _chain_id, nonce, digest, block.timestamp + ttl)\r\n\r\n\r\n@external\r\ndef commit_admins(_future_admins: AdminSet):\r\n """\r\n @notice Commit an admin set to use in the future.\r\n """\r\n assert msg.sender == self.admins.ownership\r\n\r\n assert _future_admins.ownership != _future_admins.parameter # a != b\r\n assert _future_admins.ownership != _future_admins.emergency # a != c\r\n assert _future_admins.parameter != _future_admins.emergency # b != c\r\n\r\n self.future_admins = _future_admins\r\n log CommitAdmins(_future_admins)\r\n\r\n\r\n@external\r\ndef apply_admins():\r\n """\r\n @notice Apply the future admin set.\r\n """\r\n admins: AdminSet = self.admins\r\n assert msg.sender == admins.ownership\r\n\r\n # reset old admins\r\n self.agent[admins.ownership] = empty(Agent)\r\n self.agent[admins.parameter] = empty(Agent)\r\n self.agent[admins.emergency] = empty(Agent)\r\n\r\n # set new admins\r\n future_admins: AdminSet = self.future_admins\r\n self.agent[future_admins.ownership] = Agent.OWNERSHIP\r\n self.agent[future_admins.parameter] = Agent.PARAMETER\r\n self.agent[future_admins.emergency] = Agent.EMERGENCY\r\n\r\n self.admins = future_admins\r\n log ApplyAdmins(future_admins)', + ABI: '[{"name":"Broadcast","inputs":[{"name":"agent","type":"uint256","indexed":true},{"name":"chain_id","type":"uint256","indexed":true},{"name":"nonce","type":"uint256","indexed":false},{"name":"digest","type":"bytes32","indexed":false},{"name":"deadline","type":"uint256","indexed":false}],"anonymous":false,"type":"event"},{"name":"ApplyAdmins","inputs":[{"name":"admins","type":"tuple","components":[{"name":"ownership","type":"address"},{"name":"parameter","type":"address"},{"name":"emergency","type":"address"}],"indexed":false}],"anonymous":false,"type":"event"},{"name":"CommitAdmins","inputs":[{"name":"future_admins","type":"tuple","components":[{"name":"ownership","type":"address"},{"name":"parameter","type":"address"},{"name":"emergency","type":"address"}],"indexed":false}],"anonymous":false,"type":"event"},{"stateMutability":"nonpayable","type":"constructor","inputs":[{"name":"_admins","type":"tuple","components":[{"name":"ownership","type":"address"},{"name":"parameter","type":"address"},{"name":"emergency","type":"address"}]}],"outputs":[]},{"stateMutability":"nonpayable","type":"function","name":"broadcast","inputs":[{"name":"_chain_id","type":"uint256"},{"name":"_messages","type":"tuple[]","components":[{"name":"target","type":"address"},{"name":"data","type":"bytes"}]}],"outputs":[]},{"stateMutability":"nonpayable","type":"function","name":"broadcast","inputs":[{"name":"_chain_id","type":"uint256"},{"name":"_messages","type":"tuple[]","components":[{"name":"target","type":"address"},{"name":"data","type":"bytes"}]},{"name":"_ttl","type":"uint256"}],"outputs":[]},{"stateMutability":"nonpayable","type":"function","name":"commit_admins","inputs":[{"name":"_future_admins","type":"tuple","components":[{"name":"ownership","type":"address"},{"name":"parameter","type":"address"},{"name":"emergency","type":"address"}]}],"outputs":[]},{"stateMutability":"nonpayable","type":"function","name":"apply_admins","inputs":[],"outputs":[]},{"stateMutability":"view","type":"function","name":"version","inputs":[],"outputs":[{"name":"","type":"string"}]},{"stateMutability":"view","type":"function","name":"admins","inputs":[],"outputs":[{"name":"","type":"tuple","components":[{"name":"ownership","type":"address"},{"name":"parameter","type":"address"},{"name":"emergency","type":"address"}]}]},{"stateMutability":"view","type":"function","name":"future_admins","inputs":[],"outputs":[{"name":"","type":"tuple","components":[{"name":"ownership","type":"address"},{"name":"parameter","type":"address"},{"name":"emergency","type":"address"}]}]},{"stateMutability":"view","type":"function","name":"nonce","inputs":[{"name":"arg0","type":"uint256"},{"name":"arg1","type":"uint256"}],"outputs":[{"name":"","type":"uint256"}]},{"stateMutability":"view","type":"function","name":"digest","inputs":[{"name":"arg0","type":"uint256"},{"name":"arg1","type":"uint256"},{"name":"arg2","type":"uint256"}],"outputs":[{"name":"","type":"bytes32"}]},{"stateMutability":"view","type":"function","name":"deadline","inputs":[{"name":"arg0","type":"uint256"},{"name":"arg1","type":"uint256"},{"name":"arg2","type":"uint256"}],"outputs":[{"name":"","type":"uint256"}]}]', + ContractName: "XYZ Broadcaster", + CompilerVersion: "vyper:0.3.10", + OptimizationUsed: "0", + Runs: "0", + ConstructorArguments: + "00000000000000000000000071f718d3e4d1449d1502a6a7595eb84ebccb16830000000000000000000000004eeb3ba4f221ca16ed4a0cc7254e2e32df948c5f000000000000000000000000467947ee34af926cf1dcac093870f613c96b1e0c", + EVMVersion: "Default", + Library: "", + LicenseType: "MIT", + Proxy: "0", + Implementation: "", + SwarmSource: "", + }, + ], +}; + +export const VYPER_STANDARD_JSON_CONTRACT_RESPONSE = { + status: "1", + message: "OK", + result: [ + { + SourceCode: + '{{\r\n "language": "Vyper",\r\n "sources": {\r\n ".venv/lib/python3.12/site-packages/snekmate/auth/ownable.vy": {\r\n "content": "# pragma version ~=0.4.0\\n\\"\\"\\"\\n@title Owner-Based Access Control Functions\\n@custom:contract-name ownable\\n@license GNU Affero General Public License v3.0 only\\n@author pcaversaccio\\n@notice These functions can be used to implement a basic access\\n control mechanism, where there is an account (an owner)\\n that can be granted exclusive access to specific functions.\\n By default, the owner account will be the one that deploys\\n the contract. This can later be changed with `transfer_ownership`.\\n An exemplary integration can be found in the ERC-20 implementation here:\\n https://github.com/pcaversaccio/snekmate/blob/main/src/snekmate/tokens/erc20.vy.\\n The implementation is inspired by OpenZeppelin\'s implementation here:\\n https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/access/Ownable.sol.\\n\\"\\"\\"\\n\\n\\n# @dev Returns the address of the current owner.\\n# @notice If you declare a variable as `public`,\\n# Vyper automatically generates an `external`\\n# getter function for the variable.\\nowner: public(address)\\n\\n\\n# @dev Emitted when the ownership is transferred\\n# from `previous_owner` to `new_owner`.\\nevent OwnershipTransferred:\\n previous_owner: indexed(address)\\n new_owner: indexed(address)\\n\\n\\n@deploy\\n@payable\\ndef __init__():\\n \\"\\"\\"\\n @dev To omit the opcodes for checking the `msg.value`\\n in the creation-time EVM bytecode, the constructor\\n is declared as `payable`.\\n @notice The `owner` role will be assigned to\\n the `msg.sender`.\\n \\"\\"\\"\\n self._transfer_ownership(msg.sender)\\n\\n\\n@external\\ndef transfer_ownership(new_owner: address):\\n \\"\\"\\"\\n @dev Transfers the ownership of the contract\\n to a new account `new_owner`.\\n @notice Note that this function can only be\\n called by the current `owner`. Also,\\n the `new_owner` cannot be the zero address.\\n @param new_owner The 20-byte address of the new owner.\\n \\"\\"\\"\\n self._check_owner()\\n assert new_owner != empty(address), \\"ownable: new owner is the zero address\\"\\n self._transfer_ownership(new_owner)\\n\\n\\n@external\\ndef renounce_ownership():\\n \\"\\"\\"\\n @dev Leaves the contract without an owner.\\n @notice Renouncing ownership will leave the\\n contract without an owner, thereby\\n removing any functionality that is\\n only available to the owner.\\n \\"\\"\\"\\n self._check_owner()\\n self._transfer_ownership(empty(address))\\n\\n\\n@internal\\ndef _check_owner():\\n \\"\\"\\"\\n @dev Throws if the sender is not the owner.\\n \\"\\"\\"\\n assert msg.sender == self.owner, \\"ownable: caller is not the owner\\"\\n\\n\\n@internal\\ndef _transfer_ownership(new_owner: address):\\n \\"\\"\\"\\n @dev Transfers the ownership of the contract\\n to a new account `new_owner`.\\n @notice This is an `internal` function without\\n access restriction.\\n @param new_owner The 20-byte address of the new owner.\\n \\"\\"\\"\\n old_owner: address = self.owner\\n self.owner = new_owner\\n log OwnershipTransferred(old_owner, new_owner)\\n",\r\n "sha256sum": "88ae32cf8b3e4a332d6518256019193419150e7ff716dd006a8d471550c329fc"\r\n },\r\n "contracts/interfaces/IControllerFactory.vyi": {\r\n "content": "@external\\n@view\\ndef controllers(index: uint256) -> address:\\n ...\\n\\n\\n@external\\n@view\\ndef n_collaterals() -> uint256:\\n ...\\n",\r\n "sha256sum": "80ca3e3c4313fc157183a693b93433109589aaf1084dfc4d8ccf890fe737c216"\r\n },\r\n "contracts/interfaces/IController.vyi": {\r\n "content": "@external\\ndef collect_fees() -> uint256:\\n ...\\n",\r\n "sha256sum": "99e7b55be092ba692ed2a2732ea70792f965f65278729d3e368e8166696363c4"\r\n },\r\n "contracts/ControllerMulticlaim.vy": {\r\n "content": "# pragma version ~=0.4.0\\n\\n\\"\\"\\"\\n@title ControllerMulticlaim\\n@notice Helper module to claim fees from multiple\\ncontrollers at the same time.\\n@license Copyright (c) Curve.Fi, 2020-2024 - all rights reserved\\n@author curve.fi\\n@custom:security security@curve.fi\\n\\"\\"\\"\\n\\nfrom contracts.interfaces import IControllerFactory\\nfrom contracts.interfaces import IController\\n\\nfactory: immutable(IControllerFactory)\\n\\nallowed_controllers: public(HashMap[IController, bool])\\ncontrollers: public(DynArray[IController, MAX_CONTROLLERS])\\n\\n# maximum number of claims in a single transaction\\nMAX_CONTROLLERS: constant(uint256) = 50\\n\\n\\n@deploy\\ndef __init__(_factory: IControllerFactory):\\n assert _factory.address != empty(address), \\"zeroaddr: factory\\"\\n\\n factory = _factory\\n\\n\\ndef claim_controller_fees(controllers: DynArray[IController, MAX_CONTROLLERS]):\\n \\"\\"\\"\\n @notice Claims admin fees from a list of controllers.\\n @param controllers The list of controllers to claim fees from.\\n @dev For the claim to succeed, the controller must be in the list of\\n allowed controllers. If the list of controllers is empty, all\\n controllers in the factory are claimed from.\\n \\"\\"\\"\\n if len(controllers) == 0:\\n for c: IController in self.controllers:\\n extcall c.collect_fees()\\n else:\\n for c: IController in controllers:\\n if not self.allowed_controllers[c]:\\n raise \\"controller: not in factory\\"\\n extcall c.collect_fees()\\n\\n\\n@nonreentrant\\n@external\\ndef update_controllers():\\n \\"\\"\\"\\n @notice Update the list of controllers so that it corresponds to the\\n list of controllers in the factory.\\n @dev The list of controllers can only add new controllers from the\\n factory when updated.\\n \\"\\"\\"\\n old_len: uint256 = len(self.controllers)\\n new_len: uint256 = staticcall factory.n_collaterals()\\n for i: uint256 in range(old_len, new_len, bound=MAX_CONTROLLERS):\\n c: IController = IController(staticcall factory.controllers(i))\\n self.allowed_controllers[c] = True\\n self.controllers.append(c)\\n\\n\\n@view\\n@external\\ndef n_controllers() -> uint256:\\n return len(self.controllers)\\n",\r\n "sha256sum": "364aa68720820361a472d75c06d1bcaddeb815e79910564f6627238abada4fc1"\r\n },\r\n "contracts/FeeSplitter.vy": {\r\n "content": "# pragma version ~=0.4.0\\n\\n\\"\\"\\"\\n@title FeeSplitter\\n@notice A contract that collects fees from multiple crvUSD controllers\\nin a single transaction and distributes them according to some weights.\\n@license Copyright (c) Curve.Fi, 2020-2024 - all rights reserved\\n@author curve.fi\\n@custom:security security@curve.fi\\n\\"\\"\\"\\n\\nfrom ethereum.ercs import IERC20\\nfrom ethereum.ercs import IERC165\\n\\nfrom snekmate.auth import ownable\\ninitializes: ownable\\nexports: (\\n ownable.transfer_ownership,\\n ownable.renounce_ownership,\\n ownable.owner\\n)\\n\\nimport ControllerMulticlaim as multiclaim\\ninitializes: multiclaim\\nexports: (\\n multiclaim.update_controllers,\\n multiclaim.n_controllers,\\n multiclaim.allowed_controllers,\\n multiclaim.controllers\\n)\\n\\n\\nevent SetReceivers: pass\\nevent LivenessProtectionTriggered: pass\\n\\n\\nevent FeeDispatched:\\n receiver: indexed(address)\\n weight: uint256\\n\\n\\nstruct Receiver:\\n addr: address\\n weight: uint256\\n\\n\\nversion: public(constant(String[8])) = \\"0.1.0\\" # no guarantees on abi stability\\n\\n# maximum number of splits\\nMAX_RECEIVERS: constant(uint256) = 100\\n# maximum basis points (100%)\\nMAX_BPS: constant(uint256) = 10_000\\nDYNAMIC_WEIGHT_EIP165_ID: constant(bytes4) = 0xA1AAB33F\\n\\n# receiver logic\\nreceivers: public(DynArray[Receiver, MAX_RECEIVERS])\\n\\ncrvusd: immutable(IERC20)\\n\\n\\n@deploy\\ndef __init__(\\n _crvusd: IERC20,\\n _factory: multiclaim.IControllerFactory,\\n receivers: DynArray[Receiver, MAX_RECEIVERS],\\n owner: address,\\n):\\n \\"\\"\\"\\n @notice Contract constructor\\n @param _crvusd The address of the crvUSD token contract\\n @param _factory The address of the crvUSD controller factory\\n @param receivers The list of receivers (address, weight).\\n Last item in the list is the excess receiver by default.\\n @param owner The address of the contract owner\\n \\"\\"\\"\\n assert _crvusd.address != empty(address), \\"zeroaddr: crvusd\\"\\n assert owner != empty(address), \\"zeroaddr: owner\\"\\n\\n ownable.__init__()\\n ownable._transfer_ownership(owner)\\n multiclaim.__init__(_factory)\\n\\n # setting immutables\\n crvusd = _crvusd\\n\\n # set the receivers\\n self._set_receivers(receivers)\\n\\n\\ndef _is_dynamic(addr: address) -> bool:\\n \\"\\"\\"\\n This function covers the following cases without reverting:\\n 1. The address is an EIP-165 compliant contract that supports\\n the dynamic weight interface (returns True).\\n 2. The address is a contract that does not comply to EIP-165\\n (returns False).\\n 3. The address is an EIP-165 compliant contract that does not\\n support the dynamic weight interface (returns False).\\n 4. The address is an EOA (returns False).\\n \\"\\"\\"\\n success: bool = False\\n response: Bytes[32] = b\\"\\"\\n success, response = raw_call(\\n addr,\\n abi_encode(\\n DYNAMIC_WEIGHT_EIP165_ID,\\n method_id=method_id(\\"supportsInterface(bytes4)\\"),\\n ),\\n max_outsize=32,\\n is_static_call=True,\\n revert_on_failure=False,\\n )\\n return success and convert(response, bool)\\n\\n\\ndef _get_dynamic_weight(addr: address) -> uint256:\\n success: bool = False\\n response: Bytes[32] = b\\"\\"\\n success, response = raw_call(\\n addr,\\n method_id(\\"weight()\\"),\\n max_outsize=32,\\n is_static_call=True,\\n revert_on_failure=False,\\n )\\n\\n if success:\\n return convert(response, uint256)\\n else:\\n # ! DANGER !\\n # If we got here something went wrong. This condition\\n # is here to preserve liveness but it also means that\\n # a receiver is not getting any money.\\n # ! DANGER !\\n log LivenessProtectionTriggered()\\n\\n return 0\\n\\n\\n\\n\\ndef _set_receivers(receivers: DynArray[Receiver, MAX_RECEIVERS]):\\n assert len(receivers) > 0, \\"receivers: empty\\"\\n total_weight: uint256 = 0\\n for r: Receiver in receivers:\\n assert r.addr != empty(address), \\"zeroaddr: receivers\\"\\n assert r.weight > 0 and r.weight <= MAX_BPS, \\"receivers: invalid weight\\"\\n total_weight += r.weight\\n assert total_weight == MAX_BPS, \\"receivers: total weight != MAX_BPS\\"\\n\\n self.receivers = receivers\\n\\n log SetReceivers()\\n\\n\\n@nonreentrant\\n@external\\ndef dispatch_fees(\\n controllers: DynArray[\\n multiclaim.IController, multiclaim.MAX_CONTROLLERS\\n ] = []\\n):\\n \\"\\"\\"\\n @notice Claim fees from all controllers and distribute them\\n @param controllers The list of controllers to claim fees from (default: all)\\n @dev Splits and transfers the balance according to the receivers weights\\n \\"\\"\\"\\n\\n multiclaim.claim_controller_fees(controllers)\\n\\n balance: uint256 = staticcall crvusd.balanceOf(self)\\n\\n excess: uint256 = 0\\n\\n # by iterating over the receivers, rather than the indices,\\n # we avoid an oob check at every iteration.\\n i: uint256 = 0\\n for r: Receiver in self.receivers:\\n weight: uint256 = r.weight\\n\\n if self._is_dynamic(r.addr):\\n dynamic_weight: uint256 = self._get_dynamic_weight(r.addr)\\n\\n # `weight` acts as a cap to the dynamic weight, preventing\\n # receivers to ask for more than what they are allowed to.\\n if dynamic_weight < weight:\\n excess += weight - dynamic_weight\\n weight = dynamic_weight\\n\\n # if we\'re at the last iteration, it means `r` is the excess\\n # receiver, therefore we add the excess to its weight.\\n if i == len(self.receivers) - 1:\\n weight += excess\\n\\n # precision loss can lead to a negligible amount of\\n # dust to be left in the contract after this transfer\\n extcall crvusd.transfer(r.addr, balance * weight // MAX_BPS)\\n\\n log FeeDispatched(r.addr, weight)\\n i += 1\\n\\n\\n@external\\ndef set_receivers(receivers: DynArray[Receiver, MAX_RECEIVERS]):\\n \\"\\"\\"\\n @notice Set the receivers, the last one is the excess receiver.\\n @param receivers The new receivers\'s list.\\n @dev The excess receiver is always the last element in the\\n `self.receivers` array.\\n \\"\\"\\"\\n ownable._check_owner()\\n\\n self._set_receivers(receivers)\\n\\n\\n@view\\n@external\\ndef excess_receiver() -> address:\\n \\"\\"\\"\\n @notice Get the excess receiver, that is the receiver\\n that, on top of his weight, will receive an additional\\n weight if other receivers (with a dynamic weight) ask\\n for less than their cap.\\n @return The address of the excess receiver.\\n \\"\\"\\"\\n receivers_length: uint256 = len(self.receivers)\\n return self.receivers[receivers_length - 1].addr\\n\\n\\n@view\\n@external\\ndef n_receivers() -> uint256:\\n \\"\\"\\"\\n @notice Get the number of receivers\\n @return The number of receivers\\n \\"\\"\\"\\n return len(self.receivers)\\n",\r\n "sha256sum": "646c9551c27e35f60e45969329ff2ca4a467e6aac9065a679b9aeb715442f83d"\r\n }\r\n },\r\n "settings": {\r\n "outputSelection": {\r\n "contracts/FeeSplitter.vy": [\r\n "evm.bytecode",\r\n "evm.deployedBytecode",\r\n "abi"\r\n ]\r\n },\r\n "search_paths": [\r\n ".venv/lib/python3.12/site-packages",\r\n "."\r\n ]\r\n },\r\n "compiler_version": "v0.4.0+commit.e9db8d9",\r\n "integrity": "56c34c1e23241f2f1c8b3deb6f958c671e8d56dba0a56b244c842b76c1208e13"\r\n}}', + ABI: '[{"anonymous":false,"inputs":[],"name":"SetReceivers","type":"event"},{"anonymous":false,"inputs":[],"name":"LivenessProtectionTriggered","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"receiver","type":"address"},{"indexed":false,"name":"weight","type":"uint256"}],"name":"FeeDispatched","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"previous_owner","type":"address"},{"indexed":true,"name":"new_owner","type":"address"}],"name":"OwnershipTransferred","type":"event"},{"inputs":[{"name":"new_owner","type":"address"}],"name":"transfer_ownership","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"renounce_ownership","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"owner","outputs":[{"name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"update_controllers","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"n_controllers","outputs":[{"name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"name":"arg0","type":"address"}],"name":"allowed_controllers","outputs":[{"name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"name":"arg0","type":"uint256"}],"name":"controllers","outputs":[{"name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"dispatch_fees","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"name":"controllers","type":"address[]"}],"name":"dispatch_fees","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"components":[{"name":"addr","type":"address"},{"name":"weight","type":"uint256"}],"name":"receivers","type":"tuple[]"}],"name":"set_receivers","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"excess_receiver","outputs":[{"name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"n_receivers","outputs":[{"name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"version","outputs":[{"name":"","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[{"name":"arg0","type":"uint256"}],"name":"receivers","outputs":[{"components":[{"name":"addr","type":"address"},{"name":"weight","type":"uint256"}],"name":"","type":"tuple"}],"stateMutability":"view","type":"function"},{"inputs":[{"name":"_crvusd","type":"address"},{"name":"_factory","type":"address"},{"components":[{"name":"addr","type":"address"},{"name":"weight","type":"uint256"}],"name":"receivers","type":"tuple[]"},{"name":"owner","type":"address"}],"outputs":[],"stateMutability":"nonpayable","type":"constructor"}]', + ContractName: "FeeSplitter", + CompilerVersion: "vyper:0.4.0", + OptimizationUsed: "1", + Runs: "0", + ConstructorArguments: + "000000000000000000000000f939e0a03fb07f59a73314e73794be0e57ac1b4e000000000000000000000000c9332fdcb1c491dcc683bae86fe3cb70360738bc000000000000000000000000000000000000000000000000000000000000008000000000000000000000000040907540d8a6c65c637785e8f8b742ae6b0b99680000000000000000000000000000000000000000000000000000000000000001000000000000000000000000a2bcd1a4efbd04b63cd03f5aff2561106ebcce000000000000000000000000000000000000000000000000000000000000002710", + EVMVersion: "Default", + Library: "", + LicenseType: "MIT", + Proxy: "0", + Implementation: "", + SwarmSource: "", + }, + ], +}; diff --git a/services/server/test/integration/etherscan.spec.ts b/services/server/test/integration/etherscan.spec.ts index d811557cd..8aa70b9b7 100644 --- a/services/server/test/integration/etherscan.spec.ts +++ b/services/server/test/integration/etherscan.spec.ts @@ -25,6 +25,8 @@ import { SINGLE_CONTRACT_RESPONSE, STANDARD_JSON_CONTRACT_RESPONSE, UNVERIFIED_CONTRACT_RESPONSE, + VYPER_SINGLE_CONTRACT_RESPONSE, + VYPER_STANDARD_JSON_CONTRACT_RESPONSE, } from "../helpers/etherscanResponseMocks"; chai.use(chaiHttp); @@ -231,6 +233,42 @@ describe("Import From Etherscan and Verify", function () { ); }); + it(`Non-Session: Should import a Vyper single contract from ${sourcifyChainsMap[testChainId].name} (${sourcifyChainsMap[testChainId].etherscanApi?.apiURL}) and verify the contract, finding a partial match`, (done) => { + const nockScope = mockEtherscanApi( + testChainId, + "0x7BA33456EC00812C6B6BB6C1C3dfF579c34CC2cc", + VYPER_SINGLE_CONTRACT_RESPONSE, + ); + verifyAndAssertEtherscan( + serverFixture, + testChainId, + "0x7BA33456EC00812C6B6BB6C1C3dfF579c34CC2cc", + "partial", + () => { + chai.expect(nockScope.isDone()).to.equal(true); + done(); + }, + ); + }); + + it(`Non-Session: Should import a Vyper standard-json contract from ${sourcifyChainsMap[testChainId].name} (${sourcifyChainsMap[testChainId].etherscanApi?.apiURL}) and verify the contract, finding a partial match`, (done) => { + const nockScope = mockEtherscanApi( + testChainId, + "0x2dFd89449faff8a532790667baB21cF733C064f2", + VYPER_STANDARD_JSON_CONTRACT_RESPONSE, + ); + verifyAndAssertEtherscan( + serverFixture, + testChainId, + "0x2dFd89449faff8a532790667baB21cF733C064f2", + "partial", + () => { + chai.expect(nockScope.isDone()).to.equal(true); + done(); + }, + ); + }); + // Non-session's default is `chain` but should also work with `chainId` it("should also work with `chainId` instead of `chain`", (done) => { const contract = singleContract;