From 62beb6cf53caf9c98f1ce0c793d9992d778cee1f Mon Sep 17 00:00:00 2001 From: DaniPopes <57450786+DaniPopes@users.noreply.github.com> Date: Thu, 29 Sep 2022 17:07:50 +0200 Subject: [PATCH] feat(etherscan): parse SourceCode field (#1747) * wip * feat(etherscan): parse SourceCode field * feat: add project builder method * chore: dependencies * docs * refactor: impls * refactor: create verify submodule * refactor: use untagged enum * test: add more assertions * docs * feat: add more utility methods * feat: deserialize ABI and improve SourceCode deserialization * fix: lookup_compiler_version * refactor: source tree methods * docs * chore: add solc feature * fix: test * chore: use only optional dependency * chore: re-add dev-dependency --- ethers-etherscan/Cargo.toml | 1 + ethers-etherscan/src/contract.rs | 579 ++++++++++++++++--------------- ethers-etherscan/src/lib.rs | 68 ++-- ethers-etherscan/src/utils.rs | 115 +++++- ethers-etherscan/src/verify.rs | 207 +++++++++++ 5 files changed, 645 insertions(+), 325 deletions(-) create mode 100644 ethers-etherscan/src/verify.rs diff --git a/ethers-etherscan/Cargo.toml b/ethers-etherscan/Cargo.toml index 362bcb51..702ae577 100644 --- a/ethers-etherscan/Cargo.toml +++ b/ethers-etherscan/Cargo.toml @@ -19,6 +19,7 @@ keywords = ["ethereum", "web3", "etherscan", "ethers"] [dependencies] ethers-core = { version = "^0.17.0", path = "../ethers-core", default-features = false } +ethers-solc = { version = "^0.17.0", path = "../ethers-solc", default-features = false, optional = true } reqwest = { version = "0.11.12", default-features = false, features = ["json"] } serde = { version = "1.0.124", default-features = false, features = ["derive"] } serde_json = { version = "1.0.64", default-features = false } diff --git a/ethers-etherscan/src/contract.rs b/ethers-etherscan/src/contract.rs index ad6f768a..86427cec 100644 --- a/ethers-etherscan/src/contract.rs +++ b/ethers-etherscan/src/contract.rs @@ -1,141 +1,259 @@ -use std::{collections::HashMap, path::Path}; - -use serde::{Deserialize, Serialize}; - -use ethers_core::abi::{Abi, Address}; - use crate::{ source_tree::{SourceTree, SourceTreeEntry}, + utils::{ + deserialize_address_opt, deserialize_stringified_abi, deserialize_stringified_source_code, + }, Client, EtherscanError, Response, Result, }; +use ethers_core::{ + abi::{Abi, Address}, + types::{serde_helpers::deserialize_stringified_u64, Bytes}, +}; +use semver::Version; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, path::Path}; -/// Arguments for verifying contracts -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct VerifyContract { - #[serde(rename = "contractaddress")] - pub address: Address, - #[serde(rename = "sourceCode")] - pub source: String, - #[serde(rename = "codeformat")] - pub code_format: CodeFormat, - /// if codeformat=solidity-standard-json-input, then expected as - /// `erc20.sol:erc20` - #[serde(rename = "contractname")] - pub contract_name: String, - #[serde(rename = "compilerversion")] - pub compiler_version: String, - /// applicable when codeformat=solidity-single-file - #[serde(rename = "optimizationUsed", skip_serializing_if = "Option::is_none")] - pub optimization_used: Option, - /// applicable when codeformat=solidity-single-file - #[serde(skip_serializing_if = "Option::is_none")] - pub runs: Option, - /// NOTE: there is a typo in the etherscan API `constructorArguements` - #[serde(rename = "constructorArguements", skip_serializing_if = "Option::is_none")] - pub constructor_arguments: Option, - /// applicable when codeformat=solidity-single-file - #[serde(rename = "evmversion", skip_serializing_if = "Option::is_none")] - pub evm_version: Option, - #[serde(flatten)] - pub other: HashMap, +#[cfg(feature = "ethers-solc")] +use ethers_solc::{artifacts::Settings, EvmVersion, Project, ProjectBuilder, SolcConfig}; + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub enum SourceCodeLanguage { + #[default] + Solidity, + Vyper, } -impl VerifyContract { - pub fn new( - address: Address, - contract_name: String, - source: String, - compiler_version: String, - ) -> Self { - Self { - address, - source, - code_format: Default::default(), - contract_name, - compiler_version, - optimization_used: None, - runs: None, - constructor_arguments: None, - evm_version: None, - other: Default::default(), - } - } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SourceCodeEntry { + pub content: String, +} - #[must_use] - pub fn runs(mut self, runs: u32) -> Self { - self.runs = Some(format!("{}", runs)); - self - } - - #[must_use] - pub fn optimization(self, optimization: bool) -> Self { - if optimization { - self.optimized() - } else { - self.not_optimized() - } - } - - #[must_use] - pub fn optimized(mut self) -> Self { - self.optimization_used = Some("1".to_string()); - self - } - - #[must_use] - pub fn not_optimized(mut self) -> Self { - self.optimization_used = Some("0".to_string()); - self - } - - #[must_use] - pub fn code_format(mut self, code_format: CodeFormat) -> Self { - self.code_format = code_format; - self - } - - #[must_use] - pub fn evm_version(mut self, evm_version: impl Into) -> Self { - self.evm_version = Some(evm_version.into()); - self - } - - #[must_use] - pub fn constructor_arguments( - mut self, - constructor_arguments: Option>, - ) -> Self { - self.constructor_arguments = constructor_arguments.map(|s| { - s.into() - .trim() - // TODO is this correct? - .trim_start_matches("0x") - .to_string() - }); - self +impl> From for SourceCodeEntry { + fn from(s: T) -> Self { + Self { content: s.into() } } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum CodeFormat { - #[serde(rename = "solidity-single-file")] - SingleFile, - #[serde(rename = "solidity-standard-json-input")] - StandardJsonInput, +/// The contract metadata's SourceCode field. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum SourceCodeMetadata { + /// Contains metadata and path mapped source code. + Metadata { + /// Programming language of the sources. + #[serde(default, skip_serializing_if = "Option::is_none")] + language: Option, + /// Source path => source code + #[serde(default)] + sources: HashMap, + /// Compiler settings, None if the language is not Solidity. + #[serde(default, skip_serializing_if = "Option::is_none")] + settings: Option, + }, + /// Contains only the source code. + SourceCode(String), } -impl AsRef for CodeFormat { - fn as_ref(&self) -> &str { +impl SourceCodeMetadata { + pub fn source_code(&self) -> String { match self { - CodeFormat::SingleFile => "solidity-single-file", - CodeFormat::StandardJsonInput => "solidity-standard-json-input", + Self::Metadata { sources, .. } => { + sources.values().map(|s| s.content.clone()).collect::>().join("\n") + } + Self::SourceCode(s) => s.clone(), + } + } + + pub fn language(&self) -> Option { + match self { + Self::Metadata { language, .. } => language.clone(), + Self::SourceCode(_) => None, + } + } + + pub fn sources(&self) -> HashMap { + match self { + Self::Metadata { sources, .. } => sources.clone(), + Self::SourceCode(s) => HashMap::from([("Contract".into(), s.into())]), + } + } + + #[cfg(feature = "ethers-solc")] + pub fn settings(&self) -> Result> { + match self { + Self::Metadata { settings, .. } => match settings { + Some(value) => { + if value.is_null() { + Ok(None) + } else { + Ok(Some(serde_json::from_value(value.to_owned())?)) + } + } + None => Ok(None), + }, + Self::SourceCode(_) => Ok(None), + } + } + + #[cfg(not(feature = "ethers-solc"))] + pub fn settings(&self) -> Option<&serde_json::Value> { + match self { + Self::Metadata { settings, .. } => settings.as_ref(), + Self::SourceCode(_) => None, } } } -impl Default for CodeFormat { - fn default() -> Self { - CodeFormat::StandardJsonInput +/// Etherscan contract metadata. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct Metadata { + /// Includes metadata for compiler settings and language. + #[serde(deserialize_with = "deserialize_stringified_source_code")] + pub source_code: SourceCodeMetadata, + + /// The ABI of the contract. + #[serde(rename = "ABI", deserialize_with = "deserialize_stringified_abi")] + pub abi: Abi, + + /// The name of the contract. + pub contract_name: String, + + /// The version that this contract was compiled with. If it is a Vyper contract, it will start + /// with "vyper:". + pub compiler_version: String, + + /// Whether the optimizer was used. This value should only be 0 or 1. + #[serde(deserialize_with = "deserialize_stringified_u64")] + pub optimization_used: u64, + + /// The number of optimizations performed. + #[serde(deserialize_with = "deserialize_stringified_u64")] + pub runs: u64, + + /// The constructor arguments the contract was deployed with. + pub constructor_arguments: Bytes, + + /// The version of the EVM the contract was deployed in. Can be either a variant of + /// [EvmVersion] or "Default" which indicates the compiler's default. + #[serde(rename = "EVMVersion")] + pub evm_version: String, + + // ? + pub library: String, + + /// The license of the contract. + pub license_type: String, + + /// Whether this contract is a proxy. This value should only be 0 or 1. + #[serde(deserialize_with = "deserialize_stringified_u64")] + pub proxy: u64, + + /// If this contract is a proxy, the address of its implementation. + #[serde(deserialize_with = "deserialize_address_opt")] + pub implementation: Option
, + + /// The swarm source of the contract. + pub swarm_source: String, +} + +impl Metadata { + /// Returns the contract's source code. + pub fn source_code(&self) -> String { + self.source_code.source_code() + } + + /// Returns the contract's programming language. + pub fn language(&self) -> SourceCodeLanguage { + self.source_code.language().unwrap_or_else(|| { + if self.is_vyper() { + SourceCodeLanguage::Vyper + } else { + SourceCodeLanguage::Solidity + } + }) + } + + /// Returns the contract's path mapped source code. + pub fn sources(&self) -> HashMap { + self.source_code.sources() + } + + /// Parses the compiler version. + pub fn compiler_version(&self) -> Result { + let v = &self.compiler_version; + let v = v.strip_prefix("vyper:").unwrap_or(v); + let v = v.strip_prefix('v').unwrap_or(v); + match v.parse() { + Err(e) => { + let v = v.replace('a', "-alpha."); + let v = v.replace('b', "-beta."); + v.parse().map_err(|_| EtherscanError::Unknown(format!("bad compiler version: {e}"))) + } + Ok(v) => Ok(v), + } + } + + /// Returns whether this contract is a Vyper or a Solidity contract. + pub fn is_vyper(&self) -> bool { + self.compiler_version.starts_with("vyper:") + } + + /// Maps this contract's sources to a [SourceTreeEntry] vector. + pub fn source_entries(&self) -> Vec { + let root = Path::new(&self.contract_name); + self.sources() + .into_iter() + .map(|(path, entry)| { + let path = root.join(path); + SourceTreeEntry { path, contents: entry.content } + }) + .collect() + } + + /// Returns the source tree of this contract's sources. + pub fn source_tree(&self) -> SourceTree { + SourceTree { entries: self.source_entries() } + } + + /// Returns the contract's compiler settings. + #[cfg(feature = "ethers-solc")] + pub fn settings(&self) -> Result { + let mut settings = self.source_code.settings()?.unwrap_or_default(); + + if self.optimization_used == 1 && !settings.optimizer.enabled.unwrap_or_default() { + settings.optimizer.enable(); + settings.optimizer.runs(self.runs as usize); + } + + settings.evm_version = self.evm_version()?; + + Ok(settings) + } + + /// Creates a Solc [ProjectBuilder] with this contract's settings. + #[cfg(feature = "ethers-solc")] + pub fn project_builder(&self) -> Result { + let solc_config = SolcConfig::builder().settings(self.settings()?).build(); + + Ok(Project::builder().solc_config(solc_config)) + } + + /// Parses the EVM version. + #[cfg(feature = "ethers-solc")] + pub fn evm_version(&self) -> Result> { + match self.evm_version.as_str() { + "" | "Default" => { + Ok(EvmVersion::default().normalize_version(&self.compiler_version()?)) + } + _ => { + let evm_version = self + .evm_version + .parse() + .map_err(|e| EtherscanError::Unknown(format!("bad evm version: {e}")))?; + Ok(Some(evm_version)) + } + } } } @@ -154,124 +272,27 @@ impl IntoIterator for ContractMetadata { } } -#[derive(Deserialize, Clone, Debug)] -struct EtherscanSourceEntry { - content: String, -} - -#[derive(Deserialize, Clone, Debug)] -struct EtherscanSourceJsonMetadata { - sources: HashMap, -} - impl ContractMetadata { - /// All ABI from all contracts in the source file - pub fn abis(&self) -> Result> { - let mut abis = Vec::with_capacity(self.items.len()); - for item in &self.items { - abis.push(serde_json::from_str(&item.abi)?); - } - Ok(abis) + /// Returns the ABI of all contracts. + pub fn abis(&self) -> Vec<&Abi> { + self.items.iter().map(|c| &c.abi).collect() } - /// Combined source code of all contracts + /// Returns the combined source code of all contracts. pub fn source_code(&self) -> String { - self.items.iter().map(|c| c.source_code.as_str()).collect::>().join("\n") + self.items.iter().map(|c| c.source_code()).collect::>().join("\n") } - /// Etherscan can either return one raw string that includes all of the solidity for a verified - /// contract or a json struct surrounded in an extra set of {} that includes a directory - /// structure with paths and source code. - fn get_sources_from_etherscan_source_value( - contract_name: &str, - etherscan_source: &str, - ) -> Result> { - if etherscan_source.starts_with("{{") && etherscan_source.ends_with("}}") { - let json = ðerscan_source[1..etherscan_source.len() - 1]; - let parsed: EtherscanSourceJsonMetadata = serde_json::from_str(json)?; - Ok(parsed - .sources - .into_iter() - .map(|(path, source_struct)| (path, source_struct.content)) - .collect()) - } else { - Ok(vec![(contract_name.to_string(), etherscan_source.to_string())]) - } + /// Returns the combined [SourceTree] of all contracts. + pub fn source_tree(&self) -> SourceTree { + SourceTree { entries: self.items.iter().flat_map(|item| item.source_entries()).collect() } } - - pub fn source_tree(&self) -> Result { - let mut entries = vec![]; - for item in &self.items { - let contract_root = Path::new(&item.contract_name); - let source_paths = Self::get_sources_from_etherscan_source_value( - &item.contract_name, - &item.source_code, - )?; - for (path, contents) in source_paths { - let joined = contract_root.join(&path); - entries.push(SourceTreeEntry { path: joined, contents }); - } - } - Ok(SourceTree { entries }) - } -} - -/// Etherscan contract metadata -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Metadata { - #[serde(rename = "SourceCode")] - pub source_code: String, - #[serde(rename = "ABI")] - pub abi: String, - #[serde(rename = "ContractName")] - pub contract_name: String, - #[serde(rename = "CompilerVersion")] - pub compiler_version: String, - #[serde(rename = "OptimizationUsed")] - pub optimization_used: String, - #[serde(rename = "Runs")] - pub runs: String, - #[serde(rename = "ConstructorArguments")] - pub constructor_arguments: String, - #[serde(rename = "EVMVersion")] - pub evm_version: String, - #[serde(rename = "Library")] - pub library: String, - #[serde(rename = "LicenseType")] - pub license_type: String, - #[serde(rename = "Proxy")] - pub proxy: String, - #[serde(rename = "Implementation")] - pub implementation: String, - #[serde(rename = "SwarmSource")] - pub swarm_source: String, } impl Client { - /// Submit Source Code for Verification - pub async fn submit_contract_verification( - &self, - contract: &VerifyContract, - ) -> Result> { - let body = self.create_query("contract", "verifysourcecode", contract); - self.post_form(&body).await - } - - /// Check Source Code Verification Status with receipt received from - /// `[Self::submit_contract_verification]` - pub async fn check_contract_verification_status( - &self, - guid: impl AsRef, - ) -> Result> { - let body = self.create_query( - "contract", - "checkverifystatus", - HashMap::from([("guid", guid.as_ref())]), - ); - self.post_form(&body).await - } - - /// Returns the contract ABI of a verified contract + /// Fetches a verified contract's ABI. + /// + /// # Example /// /// ```no_run /// # use ethers_etherscan::Client; @@ -318,7 +339,10 @@ impl Client { Ok(abi) } - /// Get Contract Source Code for Verified Contract Source Codes + /// Fetches a contract's verified source code and its metadata. + /// + /// # Example + /// /// ```no_run /// # use ethers_etherscan::Client; /// # use ethers_core::types::Chain; @@ -347,30 +371,37 @@ impl Client { let query = self.create_query("contract", "getsourcecode", HashMap::from([("address", address)])); - let response: Response> = self.get_json(&query).await?; - if response.result.iter().any(|item| item.abi == "Contract source code not verified") { + let response = self.get(&query).await?; + + // Source code is not verified + if response.contains("Contract source code not verified") { if let Some(ref cache) = self.cache { cache.set_source(address, None); } return Err(EtherscanError::ContractCodeNotVerified(address)) } - let res = ContractMetadata { items: response.result }; + + let response: Response = self.sanitize_response(response)?; + let result = response.result; if let Some(ref cache) = self.cache { - cache.set_source(address, Some(&res)); + cache.set_source(address, Some(&result)); } - Ok(res) + Ok(result) } } #[cfg(test)] mod tests { - use crate::{contract::VerifyContract, tests::run_at_least_duration, Client, EtherscanError}; + use super::*; + use crate::tests::run_at_least_duration; use ethers_core::types::Chain; - use ethers_solc::{Project, ProjectPathsConfig}; use serial_test::serial; - use std::{path::PathBuf, time::Duration}; + use std::time::Duration; + + /// Abi of [0xBB9bc244D798123fDe783fCc1C72d3Bb8C189413](https://api.etherscan.io/api?module=contract&action=getsourcecode&address=0xBB9bc244D798123fDe783fCc1C72d3Bb8C189413). + const DAO_ABI: &str = "[{\"constant\":true,\"inputs\":[{\"name\":\"\",\"type\":\"uint256\"}],\"name\":\"proposals\",\"outputs\":[{\"name\":\"recipient\",\"type\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\"},{\"name\":\"description\",\"type\":\"string\"},{\"name\":\"votingDeadline\",\"type\":\"uint256\"},{\"name\":\"open\",\"type\":\"bool\"},{\"name\":\"proposalPassed\",\"type\":\"bool\"},{\"name\":\"proposalHash\",\"type\":\"bytes32\"},{\"name\":\"proposalDeposit\",\"type\":\"uint256\"},{\"name\":\"newCurator\",\"type\":\"bool\"},{\"name\":\"yea\",\"type\":\"uint256\"},{\"name\":\"nay\",\"type\":\"uint256\"},{\"name\":\"creator\",\"type\":\"address\"}],\"type\":\"function\"},{\"constant\":false,\"inputs\":[{\"name\":\"_spender\",\"type\":\"address\"},{\"name\":\"_amount\",\"type\":\"uint256\"}],\"name\":\"approve\",\"outputs\":[{\"name\":\"success\",\"type\":\"bool\"}],\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"minTokensToCreate\",\"outputs\":[{\"name\":\"\",\"type\":\"uint256\"}],\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"rewardAccount\",\"outputs\":[{\"name\":\"\",\"type\":\"address\"}],\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"daoCreator\",\"outputs\":[{\"name\":\"\",\"type\":\"address\"}],\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"totalSupply\",\"outputs\":[{\"name\":\"\",\"type\":\"uint256\"}],\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"divisor\",\"outputs\":[{\"name\":\"divisor\",\"type\":\"uint256\"}],\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"extraBalance\",\"outputs\":[{\"name\":\"\",\"type\":\"address\"}],\"type\":\"function\"},{\"constant\":false,\"inputs\":[{\"name\":\"_proposalID\",\"type\":\"uint256\"},{\"name\":\"_transactionData\",\"type\":\"bytes\"}],\"name\":\"executeProposal\",\"outputs\":[{\"name\":\"_success\",\"type\":\"bool\"}],\"type\":\"function\"},{\"constant\":false,\"inputs\":[{\"name\":\"_from\",\"type\":\"address\"},{\"name\":\"_to\",\"type\":\"address\"},{\"name\":\"_value\",\"type\":\"uint256\"}],\"name\":\"transferFrom\",\"outputs\":[{\"name\":\"success\",\"type\":\"bool\"}],\"type\":\"function\"},{\"constant\":false,\"inputs\":[],\"name\":\"unblockMe\",\"outputs\":[{\"name\":\"\",\"type\":\"bool\"}],\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"totalRewardToken\",\"outputs\":[{\"name\":\"\",\"type\":\"uint256\"}],\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"actualBalance\",\"outputs\":[{\"name\":\"_actualBalance\",\"type\":\"uint256\"}],\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"closingTime\",\"outputs\":[{\"name\":\"\",\"type\":\"uint256\"}],\"type\":\"function\"},{\"constant\":true,\"inputs\":[{\"name\":\"\",\"type\":\"address\"}],\"name\":\"allowedRecipients\",\"outputs\":[{\"name\":\"\",\"type\":\"bool\"}],\"type\":\"function\"},{\"constant\":false,\"inputs\":[{\"name\":\"_to\",\"type\":\"address\"},{\"name\":\"_value\",\"type\":\"uint256\"}],\"name\":\"transferWithoutReward\",\"outputs\":[{\"name\":\"success\",\"type\":\"bool\"}],\"type\":\"function\"},{\"constant\":false,\"inputs\":[],\"name\":\"refund\",\"outputs\":[],\"type\":\"function\"},{\"constant\":false,\"inputs\":[{\"name\":\"_recipient\",\"type\":\"address\"},{\"name\":\"_amount\",\"type\":\"uint256\"},{\"name\":\"_description\",\"type\":\"string\"},{\"name\":\"_transactionData\",\"type\":\"bytes\"},{\"name\":\"_debatingPeriod\",\"type\":\"uint256\"},{\"name\":\"_newCurator\",\"type\":\"bool\"}],\"name\":\"newProposal\",\"outputs\":[{\"name\":\"_proposalID\",\"type\":\"uint256\"}],\"type\":\"function\"},{\"constant\":true,\"inputs\":[{\"name\":\"\",\"type\":\"address\"}],\"name\":\"DAOpaidOut\",\"outputs\":[{\"name\":\"\",\"type\":\"uint256\"}],\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"minQuorumDivisor\",\"outputs\":[{\"name\":\"\",\"type\":\"uint256\"}],\"type\":\"function\"},{\"constant\":false,\"inputs\":[{\"name\":\"_newContract\",\"type\":\"address\"}],\"name\":\"newContract\",\"outputs\":[],\"type\":\"function\"},{\"constant\":true,\"inputs\":[{\"name\":\"_owner\",\"type\":\"address\"}],\"name\":\"balanceOf\",\"outputs\":[{\"name\":\"balance\",\"type\":\"uint256\"}],\"type\":\"function\"},{\"constant\":false,\"inputs\":[{\"name\":\"_recipient\",\"type\":\"address\"},{\"name\":\"_allowed\",\"type\":\"bool\"}],\"name\":\"changeAllowedRecipients\",\"outputs\":[{\"name\":\"_success\",\"type\":\"bool\"}],\"type\":\"function\"},{\"constant\":false,\"inputs\":[],\"name\":\"halveMinQuorum\",\"outputs\":[{\"name\":\"_success\",\"type\":\"bool\"}],\"type\":\"function\"},{\"constant\":true,\"inputs\":[{\"name\":\"\",\"type\":\"address\"}],\"name\":\"paidOut\",\"outputs\":[{\"name\":\"\",\"type\":\"uint256\"}],\"type\":\"function\"},{\"constant\":false,\"inputs\":[{\"name\":\"_proposalID\",\"type\":\"uint256\"},{\"name\":\"_newCurator\",\"type\":\"address\"}],\"name\":\"splitDAO\",\"outputs\":[{\"name\":\"_success\",\"type\":\"bool\"}],\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"DAOrewardAccount\",\"outputs\":[{\"name\":\"\",\"type\":\"address\"}],\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"proposalDeposit\",\"outputs\":[{\"name\":\"\",\"type\":\"uint256\"}],\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"numberOfProposals\",\"outputs\":[{\"name\":\"_numberOfProposals\",\"type\":\"uint256\"}],\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"lastTimeMinQuorumMet\",\"outputs\":[{\"name\":\"\",\"type\":\"uint256\"}],\"type\":\"function\"},{\"constant\":false,\"inputs\":[{\"name\":\"_toMembers\",\"type\":\"bool\"}],\"name\":\"retrieveDAOReward\",\"outputs\":[{\"name\":\"_success\",\"type\":\"bool\"}],\"type\":\"function\"},{\"constant\":false,\"inputs\":[],\"name\":\"receiveEther\",\"outputs\":[{\"name\":\"\",\"type\":\"bool\"}],\"type\":\"function\"},{\"constant\":false,\"inputs\":[{\"name\":\"_to\",\"type\":\"address\"},{\"name\":\"_value\",\"type\":\"uint256\"}],\"name\":\"transfer\",\"outputs\":[{\"name\":\"success\",\"type\":\"bool\"}],\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"isFueled\",\"outputs\":[{\"name\":\"\",\"type\":\"bool\"}],\"type\":\"function\"},{\"constant\":false,\"inputs\":[{\"name\":\"_tokenHolder\",\"type\":\"address\"}],\"name\":\"createTokenProxy\",\"outputs\":[{\"name\":\"success\",\"type\":\"bool\"}],\"type\":\"function\"},{\"constant\":true,\"inputs\":[{\"name\":\"_proposalID\",\"type\":\"uint256\"}],\"name\":\"getNewDAOAddress\",\"outputs\":[{\"name\":\"_newDAO\",\"type\":\"address\"}],\"type\":\"function\"},{\"constant\":false,\"inputs\":[{\"name\":\"_proposalID\",\"type\":\"uint256\"},{\"name\":\"_supportsProposal\",\"type\":\"bool\"}],\"name\":\"vote\",\"outputs\":[{\"name\":\"_voteID\",\"type\":\"uint256\"}],\"type\":\"function\"},{\"constant\":false,\"inputs\":[],\"name\":\"getMyReward\",\"outputs\":[{\"name\":\"_success\",\"type\":\"bool\"}],\"type\":\"function\"},{\"constant\":true,\"inputs\":[{\"name\":\"\",\"type\":\"address\"}],\"name\":\"rewardToken\",\"outputs\":[{\"name\":\"\",\"type\":\"uint256\"}],\"type\":\"function\"},{\"constant\":false,\"inputs\":[{\"name\":\"_from\",\"type\":\"address\"},{\"name\":\"_to\",\"type\":\"address\"},{\"name\":\"_value\",\"type\":\"uint256\"}],\"name\":\"transferFromWithoutReward\",\"outputs\":[{\"name\":\"success\",\"type\":\"bool\"}],\"type\":\"function\"},{\"constant\":true,\"inputs\":[{\"name\":\"_owner\",\"type\":\"address\"},{\"name\":\"_spender\",\"type\":\"address\"}],\"name\":\"allowance\",\"outputs\":[{\"name\":\"remaining\",\"type\":\"uint256\"}],\"type\":\"function\"},{\"constant\":false,\"inputs\":[{\"name\":\"_proposalDeposit\",\"type\":\"uint256\"}],\"name\":\"changeProposalDeposit\",\"outputs\":[],\"type\":\"function\"},{\"constant\":true,\"inputs\":[{\"name\":\"\",\"type\":\"address\"}],\"name\":\"blocked\",\"outputs\":[{\"name\":\"\",\"type\":\"uint256\"}],\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"curator\",\"outputs\":[{\"name\":\"\",\"type\":\"address\"}],\"type\":\"function\"},{\"constant\":true,\"inputs\":[{\"name\":\"_proposalID\",\"type\":\"uint256\"},{\"name\":\"_recipient\",\"type\":\"address\"},{\"name\":\"_amount\",\"type\":\"uint256\"},{\"name\":\"_transactionData\",\"type\":\"bytes\"}],\"name\":\"checkProposalCode\",\"outputs\":[{\"name\":\"_codeChecksOut\",\"type\":\"bool\"}],\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"privateCreation\",\"outputs\":[{\"name\":\"\",\"type\":\"address\"}],\"type\":\"function\"},{\"inputs\":[{\"name\":\"_curator\",\"type\":\"address\"},{\"name\":\"_daoCreator\",\"type\":\"address\"},{\"name\":\"_proposalDeposit\",\"type\":\"uint256\"},{\"name\":\"_minTokensToCreate\",\"type\":\"uint256\"},{\"name\":\"_closingTime\",\"type\":\"uint256\"},{\"name\":\"_privateCreation\",\"type\":\"address\"}],\"type\":\"constructor\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"name\":\"_from\",\"type\":\"address\"},{\"indexed\":true,\"name\":\"_to\",\"type\":\"address\"},{\"indexed\":false,\"name\":\"_amount\",\"type\":\"uint256\"}],\"name\":\"Transfer\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"name\":\"_owner\",\"type\":\"address\"},{\"indexed\":true,\"name\":\"_spender\",\"type\":\"address\"},{\"indexed\":false,\"name\":\"_amount\",\"type\":\"uint256\"}],\"name\":\"Approval\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"name\":\"value\",\"type\":\"uint256\"}],\"name\":\"FuelingToDate\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"name\":\"to\",\"type\":\"address\"},{\"indexed\":false,\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"CreatedToken\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"name\":\"to\",\"type\":\"address\"},{\"indexed\":false,\"name\":\"value\",\"type\":\"uint256\"}],\"name\":\"Refund\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"name\":\"proposalID\",\"type\":\"uint256\"},{\"indexed\":false,\"name\":\"recipient\",\"type\":\"address\"},{\"indexed\":false,\"name\":\"amount\",\"type\":\"uint256\"},{\"indexed\":false,\"name\":\"newCurator\",\"type\":\"bool\"},{\"indexed\":false,\"name\":\"description\",\"type\":\"string\"}],\"name\":\"ProposalAdded\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"name\":\"proposalID\",\"type\":\"uint256\"},{\"indexed\":false,\"name\":\"position\",\"type\":\"bool\"},{\"indexed\":true,\"name\":\"voter\",\"type\":\"address\"}],\"name\":\"Voted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"name\":\"proposalID\",\"type\":\"uint256\"},{\"indexed\":false,\"name\":\"result\",\"type\":\"bool\"},{\"indexed\":false,\"name\":\"quorum\",\"type\":\"uint256\"}],\"name\":\"ProposalTallied\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"name\":\"_newCurator\",\"type\":\"address\"}],\"name\":\"NewCurator\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"name\":\"_recipient\",\"type\":\"address\"},{\"indexed\":false,\"name\":\"_allowed\",\"type\":\"bool\"}],\"name\":\"AllowedRecipientChanged\",\"type\":\"event\"}]"; #[allow(unused)] fn init_tracing() { @@ -399,13 +430,15 @@ mod tests { #[serial] #[ignore] async fn can_fetch_contract_abi() { + init_tracing(); run_at_least_duration(Duration::from_millis(250), async { let client = Client::new_from_env(Chain::Mainnet).unwrap(); - let _abi = client + let abi = client .contract_abi("0xBB9bc244D798123fDe783fCc1C72d3Bb8C189413".parse().unwrap()) .await .unwrap(); + assert_eq!(abi, serde_json::from_str(DAO_ABI).unwrap()); }) .await; } @@ -414,13 +447,20 @@ mod tests { #[serial] #[ignore] async fn can_fetch_contract_source_code() { + init_tracing(); run_at_least_duration(Duration::from_millis(250), async { let client = Client::new_from_env(Chain::Mainnet).unwrap(); - let _meta = client + let meta = client .contract_source_code("0xBB9bc244D798123fDe783fCc1C72d3Bb8C189413".parse().unwrap()) .await .unwrap(); + + assert_eq!(meta.items.len(), 1); + let item = &meta.items[0]; + assert!(matches!(item.source_code, SourceCodeMetadata::SourceCode(_))); + assert_eq!(item.source_code.sources().len(), 1); + assert_eq!(item.abi, serde_json::from_str(DAO_ABI).unwrap()); }) .await } @@ -429,19 +469,12 @@ mod tests { #[serial] #[ignore] async fn can_get_error_on_unverified_contract() { + init_tracing(); run_at_least_duration(Duration::from_millis(250), async { let client = Client::new_from_env(Chain::Mainnet).unwrap(); - let unverified_addr = "0xb5c31a0e22cae98ac08233e512bd627885aa24e5".parse().unwrap(); - let result = client.contract_source_code(unverified_addr).await; - match result.err() { - Some(error) => match error { - EtherscanError::ContractCodeNotVerified(addr) => { - assert_eq!(addr, unverified_addr); - } - _ => panic!("Invalid EtherscanError type"), - }, - None => panic!("Result should contain ContractCodeNotVerified error"), - } + let addr = "0xb5c31a0e22cae98ac08233e512bd627885aa24e5".parse().unwrap(); + let err = client.contract_source_code(addr).await.unwrap_err(); + assert!(matches!(err, EtherscanError::ContractCodeNotVerified(_))); }) .await } @@ -451,6 +484,7 @@ mod tests { #[serial] #[ignore] async fn can_fetch_contract_source_tree_for_singleton_contract() { + init_tracing(); run_at_least_duration(Duration::from_millis(250), async { let client = Client::new_from_env(Chain::Mainnet).unwrap(); @@ -459,8 +493,11 @@ mod tests { .await .unwrap(); - let source_tree = meta.source_tree().unwrap(); - assert_eq!(source_tree.entries.len(), 1); + assert_eq!(meta.items.len(), 1); + let item = &meta.items[0]; + assert!(matches!(item.source_code, SourceCodeMetadata::SourceCode(_))); + assert_eq!(item.source_code.sources().len(), 1); + assert_eq!(item.abi, serde_json::from_str(DAO_ABI).unwrap()); }) .await } @@ -471,6 +508,7 @@ mod tests { #[serial] #[ignore] async fn can_fetch_contract_source_tree_for_multi_entry_contract() { + init_tracing(); run_at_least_duration(Duration::from_millis(250), async { let client = Client::new_from_env(Chain::Mainnet).unwrap(); @@ -479,42 +517,11 @@ mod tests { .await .unwrap(); - let source_tree = meta.source_tree().unwrap(); + assert_eq!(meta.items.len(), 1); + assert!(matches!(meta.items[0].source_code, SourceCodeMetadata::Metadata { .. })); + let source_tree = meta.source_tree(); assert_eq!(source_tree.entries.len(), 15); }) .await } - - #[tokio::test] - #[serial] - async fn can_flatten_and_verify_contract() { - run_at_least_duration(Duration::from_millis(250), async { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources"); - let paths = ProjectPathsConfig::builder() - .sources(&root) - .build() - .expect("failed to resolve project paths"); - let project = Project::builder() - .paths(paths) - .build() - .expect("failed to build the project"); - - let address = "0x9e744c9115b74834c0f33f4097f40c02a9ac5c33".parse().unwrap(); - let compiler_version = "v0.5.17+commit.d19bba13"; - let constructor_args = "0x000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000005f5e1000000000000000000000000000000000000000000000000000000000000000007596179537761700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000035941590000000000000000000000000000000000000000000000000000000000"; - let contract = project.flatten(&root.join("UniswapExchange.sol")).expect("failed to flatten contract"); - let contract_name = "UniswapExchange".to_owned(); - - let client = Client::new_from_env(Chain::Mainnet).unwrap(); - - let contract = - VerifyContract::new(address, contract_name, contract, compiler_version.to_string()) - .constructor_arguments(Some(constructor_args)) - .optimization(true) - .runs(200); - let resp = client.submit_contract_verification(&contract).await.expect("failed to send the request"); - assert_ne!(resp.result, "Error!"); // `Error!` result means that request was malformatted - }) - .await - } } diff --git a/ethers-etherscan/src/lib.rs b/ethers-etherscan/src/lib.rs index dcab1fc0..25c940a4 100644 --- a/ethers-etherscan/src/lib.rs +++ b/ethers-etherscan/src/lib.rs @@ -24,6 +24,7 @@ pub mod gas; pub mod source_tree; pub mod transaction; pub mod utils; +pub mod verify; pub(crate) type Result = std::result::Result; @@ -155,35 +156,15 @@ impl Client { format!("{}token/{:?}", self.etherscan_url, token_hash) } - /// Execute an API POST request with a form - async fn post_form( - &self, - form: &Form, - ) -> Result> { - trace!(target: "etherscan", "POST FORM {}", self.etherscan_api_url); - let response = self - .client - .post(self.etherscan_api_url.clone()) - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .form(form) - .send() - .await? - .text() - .await?; - - serde_json::from_str(&response).map_err(|err| { - error!(target: "etherscan", ?response, "Failed to deserialize response: {}", err); - if is_blocked_by_cloudflare_response(&response) { - EtherscanError::BlockedByCloudflare - } else { - EtherscanError::Serde(err) - } - }) + /// Execute an GET request with parameters. + async fn get_json(&self, query: &Q) -> Result> { + let res = self.get(query).await?; + self.sanitize_response(res) } - /// Execute an API GET request with parameters - async fn get_json(&self, query: &Q) -> Result> { - trace!(target: "etherscan", "GET JSON {}", self.etherscan_api_url); + /// Execute a GET request with parameters, without sanity checking the response. + async fn get(&self, query: &Q) -> Result { + trace!(target: "etherscan", "GET {}", self.etherscan_api_url); let response = self .client .get(self.etherscan_api_url.clone()) @@ -193,17 +174,42 @@ impl Client { .await? .text() .await?; + Ok(response) + } - let response: ResponseData = serde_json::from_str(&response).map_err(|err| { - error!(target: "etherscan", ?response, "Failed to deserialize response: {}", err); - if is_blocked_by_cloudflare_response(&response) { + /// Execute a POST request with a form. + async fn post_form(&self, form: &F) -> Result> { + let res = self.post(form).await?; + self.sanitize_response(res) + } + + /// Execute a POST request with a form, without sanity checking the response. + async fn post(&self, form: &F) -> Result { + trace!(target: "etherscan", "POST {}", self.etherscan_api_url); + let response = self + .client + .post(self.etherscan_api_url.clone()) + .form(form) + .send() + .await? + .text() + .await?; + Ok(response) + } + + /// Perform sanity checks on a response and deserialize it into a [Response]. + fn sanitize_response(&self, res: impl AsRef) -> Result> { + let res = res.as_ref(); + let res: ResponseData = serde_json::from_str(res).map_err(|err| { + error!(target: "etherscan", ?res, "Failed to deserialize response: {}", err); + if is_blocked_by_cloudflare_response(res) { EtherscanError::BlockedByCloudflare } else { EtherscanError::Serde(err) } })?; - match response { + match res { ResponseData::Error { result, .. } => { if result.starts_with("Max rate limit reached") { Err(EtherscanError::RateLimitExceeded) diff --git a/ethers-etherscan/src/utils.rs b/ethers-etherscan/src/utils.rs index dc78f8b9..8a808dbb 100644 --- a/ethers-etherscan/src/utils.rs +++ b/ethers-etherscan/src/utils.rs @@ -1,29 +1,70 @@ +use crate::{contract::SourceCodeMetadata, EtherscanError, Result}; +use ethers_core::{abi::Abi, types::Address}; use semver::Version; - -use crate::{EtherscanError, Result}; +use serde::{Deserialize, Deserializer}; static SOLC_BIN_LIST_URL: &str = "https://raw.githubusercontent.com/ethereum/solc-bin/gh-pages/bin/list.txt"; -/// Given the compiler version lookup the build metadata -/// and return full semver -/// i.e. `0.8.13` -> `0.8.13+commit.abaa5c0e` +/// Given a Solc [Version], lookup the build metadata and return the full SemVer. +/// e.g. `0.8.13` -> `0.8.13+commit.abaa5c0e` pub async fn lookup_compiler_version(version: &Version) -> Result { let response = reqwest::get(SOLC_BIN_LIST_URL).await?.text().await?; - let version = format!("{}", version); + // Ignore extra metadata (`pre` or `build`) + let version = format!("{}.{}.{}", version.major, version.minor, version.patch); let v = response .lines() .find(|l| !l.contains("nightly") && l.contains(&version)) - .map(|l| l.trim_start_matches("soljson-v").trim_end_matches(".js").to_owned()) + .map(|l| l.trim_start_matches("soljson-v").trim_end_matches(".js")) .ok_or(EtherscanError::MissingSolcVersion(version))?; Ok(v.parse().expect("failed to parse semver")) } +/// Return None if empty, otherwise parse as [Address]. +pub fn deserialize_address_opt<'de, D: Deserializer<'de>>( + deserializer: D, +) -> std::result::Result, D::Error> { + let s = String::deserialize(deserializer)?; + if s.is_empty() { + Ok(None) + } else { + let addr: Address = s.parse().map_err(serde::de::Error::custom)?; + Ok(Some(addr)) + } +} + +/// Deserializes as JSON: +/// +/// `{ "SourceCode": "{{ .. }}", ..}` +/// +/// or +/// +/// `{ "SourceCode": "..", .. }` +pub fn deserialize_stringified_source_code<'de, D: Deserializer<'de>>( + deserializer: D, +) -> std::result::Result { + let s = String::deserialize(deserializer)?; + if s.starts_with("{{") && s.ends_with("}}") { + let s = &s[1..s.len() - 1]; + serde_json::from_str(s).map_err(serde::de::Error::custom) + } else { + Ok(SourceCodeMetadata::SourceCode(s)) + } +} + +/// Deserializes as JSON: "\[...\]" +pub fn deserialize_stringified_abi<'de, D: Deserializer<'de>>( + deserializer: D, +) -> std::result::Result { + let s = String::deserialize(deserializer)?; + serde_json::from_str(&s).map_err(serde::de::Error::custom) +} + #[cfg(test)] mod tests { use super::*; - use crate::tests::run_at_least_duration; + use crate::{contract::SourceCodeLanguage, tests::run_at_least_duration}; use semver::{BuildMetadata, Prerelease}; use serial_test::serial; use std::time::Duration; @@ -53,4 +94,62 @@ mod tests { }) .await } + + #[test] + fn can_deserialize_address_opt() { + #[derive(Deserialize)] + struct Test { + #[serde(deserialize_with = "deserialize_address_opt")] + address: Option
, + } + + // https://api.etherscan.io/api?module=contract&action=getsourcecode&address=0xBB9bc244D798123fDe783fCc1C72d3Bb8C189413 + let json = r#"{"address":""}"#; + let de: Test = serde_json::from_str(json).unwrap(); + assert_eq!(de.address, None); + + // https://api.etherscan.io/api?module=contract&action=getsourcecode&address=0xDef1C0ded9bec7F1a1670819833240f027b25EfF + let json = r#"{"address":"0x4af649ffde640ceb34b1afaba3e0bb8e9698cb01"}"#; + let de: Test = serde_json::from_str(json).unwrap(); + let expected = "0x4af649ffde640ceb34b1afaba3e0bb8e9698cb01".parse().unwrap(); + assert_eq!(de.address, Some(expected)); + } + + #[test] + fn can_deserialize_stringified_abi() { + #[derive(Deserialize)] + struct Test { + #[serde(deserialize_with = "deserialize_stringified_abi")] + abi: Abi, + } + + let json = r#"{"abi": "[]"}"#; + let de: Test = serde_json::from_str(json).unwrap(); + assert_eq!(de.abi, Abi::default()); + } + + #[test] + fn can_deserialize_stringified_source_code() { + #[derive(Deserialize)] + struct Test { + #[serde(deserialize_with = "deserialize_stringified_source_code")] + source_code: SourceCodeMetadata, + } + + let src = "source code text"; + + let json = r#"{ + "source_code": "{{ \"language\": \"Solidity\", \"sources\": {\"Contract\": { \"content\": \"source code text\" } } }}" + }"#; + let de: Test = serde_json::from_str(json).unwrap(); + assert!(matches!(de.source_code.language().unwrap(), SourceCodeLanguage::Solidity)); + assert_eq!(de.source_code.sources().len(), 1); + assert_eq!(de.source_code.sources().get("Contract").unwrap().content, src); + #[cfg(feature = "ethers-solc")] + assert!(matches!(de.source_code.settings().unwrap(), None)); + + let json = r#"{"source_code": "source code text"}"#; + let de: Test = serde_json::from_str(json).unwrap(); + assert_eq!(de.source_code.source_code(), src); + } } diff --git a/ethers-etherscan/src/verify.rs b/ethers-etherscan/src/verify.rs new file mode 100644 index 00000000..9b63d6bd --- /dev/null +++ b/ethers-etherscan/src/verify.rs @@ -0,0 +1,207 @@ +use crate::{Client, Response, Result}; +use ethers_core::types::Address; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Arguments for verifying contracts +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerifyContract { + #[serde(rename = "contractaddress")] + pub address: Address, + #[serde(rename = "sourceCode")] + pub source: String, + #[serde(rename = "codeformat")] + pub code_format: CodeFormat, + /// if codeformat=solidity-standard-json-input, then expected as + /// `erc20.sol:erc20` + #[serde(rename = "contractname")] + pub contract_name: String, + #[serde(rename = "compilerversion")] + pub compiler_version: String, + /// applicable when codeformat=solidity-single-file + #[serde(rename = "optimizationUsed", skip_serializing_if = "Option::is_none")] + pub optimization_used: Option, + /// applicable when codeformat=solidity-single-file + #[serde(skip_serializing_if = "Option::is_none")] + pub runs: Option, + /// NOTE: there is a typo in the etherscan API `constructorArguements` + #[serde(rename = "constructorArguements", skip_serializing_if = "Option::is_none")] + pub constructor_arguments: Option, + /// applicable when codeformat=solidity-single-file + #[serde(rename = "evmversion", skip_serializing_if = "Option::is_none")] + pub evm_version: Option, + #[serde(flatten)] + pub other: HashMap, +} + +impl VerifyContract { + pub fn new( + address: Address, + contract_name: String, + source: String, + compiler_version: String, + ) -> Self { + Self { + address, + source, + code_format: Default::default(), + contract_name, + compiler_version, + optimization_used: None, + runs: None, + constructor_arguments: None, + evm_version: None, + other: Default::default(), + } + } + + #[must_use] + pub fn runs(mut self, runs: u32) -> Self { + self.runs = Some(format!("{}", runs)); + self + } + + #[must_use] + pub fn optimization(self, optimization: bool) -> Self { + if optimization { + self.optimized() + } else { + self.not_optimized() + } + } + + #[must_use] + pub fn optimized(mut self) -> Self { + self.optimization_used = Some("1".to_string()); + self + } + + #[must_use] + pub fn not_optimized(mut self) -> Self { + self.optimization_used = Some("0".to_string()); + self + } + + #[must_use] + pub fn code_format(mut self, code_format: CodeFormat) -> Self { + self.code_format = code_format; + self + } + + #[must_use] + pub fn evm_version(mut self, evm_version: impl Into) -> Self { + self.evm_version = Some(evm_version.into()); + self + } + + #[must_use] + pub fn constructor_arguments( + mut self, + constructor_arguments: Option>, + ) -> Self { + self.constructor_arguments = constructor_arguments.map(|s| { + s.into() + .trim() + // TODO is this correct? + .trim_start_matches("0x") + .to_string() + }); + self + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CodeFormat { + #[serde(rename = "solidity-single-file")] + SingleFile, + + #[default] + #[serde(rename = "solidity-standard-json-input")] + StandardJsonInput, +} + +impl AsRef for CodeFormat { + fn as_ref(&self) -> &str { + match self { + CodeFormat::SingleFile => "solidity-single-file", + CodeFormat::StandardJsonInput => "solidity-standard-json-input", + } + } +} + +impl Client { + /// Submit Source Code for Verification + pub async fn submit_contract_verification( + &self, + contract: &VerifyContract, + ) -> Result> { + let body = self.create_query("contract", "verifysourcecode", contract); + self.post_form(&body).await + } + + /// Check Source Code Verification Status with receipt received from + /// `[Self::submit_contract_verification]` + pub async fn check_contract_verification_status( + &self, + guid: impl AsRef, + ) -> Result> { + let body = self.create_query( + "contract", + "checkverifystatus", + HashMap::from([("guid", guid.as_ref())]), + ); + self.post_form(&body).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{tests::run_at_least_duration, Client}; + use ethers_core::types::Chain; + use ethers_solc::{Project, ProjectPathsConfig}; + use serial_test::serial; + use std::{path::PathBuf, time::Duration}; + + #[allow(unused)] + fn init_tracing() { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + } + + #[tokio::test] + #[serial] + #[ignore] + async fn can_flatten_and_verify_contract() { + init_tracing(); + run_at_least_duration(Duration::from_millis(250), async { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources"); + let paths = ProjectPathsConfig::builder() + .sources(&root) + .build() + .expect("failed to resolve project paths"); + let project = Project::builder() + .paths(paths) + .build() + .expect("failed to build the project"); + + let address = "0x9e744c9115b74834c0f33f4097f40c02a9ac5c33".parse().unwrap(); + let compiler_version = "v0.5.17+commit.d19bba13"; + let constructor_args = "0x000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000005f5e1000000000000000000000000000000000000000000000000000000000000000007596179537761700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000035941590000000000000000000000000000000000000000000000000000000000"; + let contract = project.flatten(&root.join("UniswapExchange.sol")).expect("failed to flatten contract"); + let contract_name = "UniswapExchange".to_owned(); + + let client = Client::new_from_env(Chain::Mainnet).unwrap(); + + let contract = + VerifyContract::new(address, contract_name, contract, compiler_version.to_string()) + .constructor_arguments(Some(constructor_args)) + .optimization(true) + .runs(200); + let resp = client.submit_contract_verification(&contract).await.expect("failed to send the request"); + assert_ne!(resp.result, "Error!"); // `Error!` result means that request was malformatted + }) + .await + } +}