From e7befcaaa0d4deb31d3c83e05fd6ee0c0197057f Mon Sep 17 00:00:00 2001 From: Tomas Tauber <2410580+tomtau@users.noreply.github.com> Date: Sat, 26 Feb 2022 22:35:43 +0800 Subject: [PATCH] feat(etherscan): account endpoints (#939) * feat(etherscan): account endpoints * a more restricted normal transaction response * doc fix * extended restricted types to other functions --- CHANGELOG.md | 1 + ethers-etherscan/src/account.rs | 729 ++++++++++++++++++++++++++++++++ ethers-etherscan/src/errors.rs | 2 + ethers-etherscan/src/lib.rs | 1 + 4 files changed, 733 insertions(+) create mode 100644 ethers-etherscan/src/account.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d5e4185..16db8093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Unreleased +- Add Etherscan account API endpoints [939](https://github.com/gakonst/ethers-rs/pull/939) - Add FTM Mainet and testnet to parse method "try_from" from Chain.rs and add cronos mainet and testnet to "from_str" - Add FTM mainnet and testnet Multicall addresses [927](https://github.com/gakonst/ethers-rs/pull/927) - Add Cronos mainnet beta and testnet to the list of known chains diff --git a/ethers-etherscan/src/account.rs b/ethers-etherscan/src/account.rs new file mode 100644 index 00000000..5aa9932a --- /dev/null +++ b/ethers-etherscan/src/account.rs @@ -0,0 +1,729 @@ +use std::{ + borrow::Cow, + collections::HashMap, + fmt::{Display, Error, Formatter}, +}; + +use ethers_core::{ + abi::Address, + types::{BlockNumber, Bytes, H256, U256, U64}, +}; +use serde::{Deserialize, Serialize}; + +use crate::{Client, EtherscanError, Query, Response, Result}; + +/// The raw response from the balance-related API endpoints +#[derive(Debug, Serialize, Deserialize)] +pub struct AccountBalance { + pub account: Address, + pub balance: String, +} + +mod jsonstring { + use super::*; + use serde::{ + de::{DeserializeOwned, Error as _}, + ser::Error as _, + Deserializer, Serializer, + }; + + pub fn serialize( + value: &GenesisOption, + serializer: S, + ) -> std::result::Result + where + T: Serialize, + S: Serializer, + { + let json = match value { + GenesisOption::None => Cow::from(""), + GenesisOption::Genesis => Cow::from("GENESIS"), + GenesisOption::Some(value) => { + serde_json::to_string(value).map_err(S::Error::custom)?.into() + } + }; + serializer.serialize_str(&json) + } + + pub fn deserialize<'de, T, D>( + deserializer: D, + ) -> std::result::Result, D::Error> + where + T: DeserializeOwned, + D: Deserializer<'de>, + { + let json = Cow::<'de, str>::deserialize(deserializer)?; + if !json.is_empty() && !json.starts_with("GENESIS") { + let value = + serde_json::from_str(&format!("\"{}\"", &json)).map_err(D::Error::custom)?; + Ok(GenesisOption::Some(value)) + } else if json.starts_with("GENESIS") { + Ok(GenesisOption::Genesis) + } else { + Ok(GenesisOption::None) + } + } +} + +/// Possible values for some field responses +#[derive(Debug)] +pub enum GenesisOption { + None, + Genesis, + Some(T), +} + +impl From> for Option { + fn from(value: GenesisOption) -> Self { + match value { + GenesisOption::Some(value) => Some(value), + _ => None, + } + } +} + +impl GenesisOption { + pub fn is_genesis(&self) -> bool { + matches!(self, GenesisOption::Genesis) + } + + pub fn value(&self) -> Option<&T> { + match self { + GenesisOption::Some(value) => Some(value), + _ => None, + } + } +} + +/// The raw response from the transaction list API endpoint +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NormalTransaction { + pub is_error: String, + pub block_number: BlockNumber, + pub time_stamp: String, + #[serde(with = "jsonstring")] + pub hash: GenesisOption, + #[serde(with = "jsonstring")] + pub nonce: GenesisOption, + #[serde(with = "jsonstring")] + pub block_hash: GenesisOption, + pub transaction_index: Option, + #[serde(with = "jsonstring")] + pub from: GenesisOption
, + pub to: Option
, + pub value: U256, + pub gas: U256, + pub gas_price: Option, + #[serde(rename = "txreceipt_status")] + pub tx_receipt_status: String, + #[serde(with = "jsonstring")] + pub input: GenesisOption, + #[serde(with = "jsonstring")] + pub contract_address: GenesisOption
, + pub gas_used: U256, + pub cumulative_gas_used: U256, + pub confirmations: U64, +} + +/// The raw response from the internal transaction list API endpoint +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InternalTransaction { + pub block_number: BlockNumber, + pub time_stamp: String, + pub hash: H256, + pub from: Address, + #[serde(with = "jsonstring")] + pub to: GenesisOption
, + pub value: U256, + #[serde(with = "jsonstring")] + pub contract_address: GenesisOption
, + #[serde(with = "jsonstring")] + pub input: GenesisOption, + #[serde(rename = "type")] + pub result_type: String, + pub gas: U256, + pub gas_used: U256, + pub trace_id: String, + pub is_error: String, + pub err_code: String, +} + +/// The raw response from the ERC20 transfer list API endpoint +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ERC20TokenTransferEvent { + pub block_number: BlockNumber, + pub time_stamp: String, + pub hash: H256, + pub nonce: U256, + pub block_hash: H256, + pub from: Address, + pub contract_address: Address, + pub to: Option
, + pub value: U256, + pub token_name: String, + pub token_symbol: String, + pub token_decimal: String, + pub transaction_index: U64, + pub gas: U256, + pub gas_price: Option, + pub gas_used: U256, + pub cumulative_gas_used: U256, + /// deprecated + pub input: String, + pub confirmations: U64, +} + +/// The raw response from the ERC721 transfer list API endpoint +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ERC721TokenTransferEvent { + pub block_number: BlockNumber, + pub time_stamp: String, + pub hash: H256, + pub nonce: U256, + pub block_hash: H256, + pub from: Address, + pub contract_address: Address, + pub to: Option
, + #[serde(rename = "tokenID")] + pub token_id: String, + pub token_name: String, + pub token_symbol: String, + pub token_decimal: String, + pub transaction_index: U64, + pub gas: U256, + pub gas_price: Option, + pub gas_used: U256, + pub cumulative_gas_used: U256, + /// deprecated + pub input: String, + pub confirmations: U64, +} + +/// The raw response from the mined blocks API endpoint +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MinedBlock { + pub block_number: BlockNumber, + pub time_stamp: String, + pub block_reward: String, +} + +/// The pre-defined block parameter for balance API endpoints +pub enum Tag { + Earliest, + Pending, + Latest, +} + +impl Display for Tag { + fn fmt(&self, f: &mut Formatter<'_>) -> std::result::Result<(), Error> { + match self { + Tag::Earliest => write!(f, "earliest"), + Tag::Pending => write!(f, "pending"), + Tag::Latest => write!(f, "latest"), + } + } +} + +impl Default for Tag { + fn default() -> Self { + Tag::Latest + } +} + +/// The list sorting preference +pub enum Sort { + Asc, + Desc, +} + +impl Display for Sort { + fn fmt(&self, f: &mut Formatter<'_>) -> std::result::Result<(), Error> { + match self { + Sort::Asc => write!(f, "asc"), + Sort::Desc => write!(f, "desc"), + } + } +} + +/// Common optional arguments for the transaction or event list API endpoints +pub struct TxListParams { + start_block: u64, + end_block: u64, + page: u64, + offset: u64, + sort: Sort, +} + +impl TxListParams { + pub fn new(start_block: u64, end_block: u64, page: u64, offset: u64, sort: Sort) -> Self { + Self { start_block, end_block, page, offset, sort } + } +} + +impl Default for TxListParams { + fn default() -> Self { + Self { start_block: 0, end_block: 99999999, page: 0, offset: 10000, sort: Sort::Asc } + } +} + +impl From for HashMap<&'static str, String> { + fn from(tx_params: TxListParams) -> Self { + let mut params = HashMap::new(); + params.insert("startBlock", tx_params.start_block.to_string()); + params.insert("endBlock", tx_params.end_block.to_string()); + params.insert("page", tx_params.page.to_string()); + params.insert("offset", tx_params.offset.to_string()); + params.insert("sort", tx_params.sort.to_string()); + params + } +} + +/// Options for querying internal transactions +pub enum InternalTxQueryOption { + ByAddress(Address), + ByTransactionHash(H256), + ByBlockRange, +} + +/// Options for querying ERC20 or ERC721 token transfers +pub enum TokenQueryOption { + ByAddress(Address), + ByContract(Address), + ByAddressAndContract(Address, Address), +} + +impl TokenQueryOption { + pub fn into_params(self, list_params: TxListParams) -> HashMap<&'static str, String> { + let mut params: HashMap<&'static str, String> = list_params.into(); + match self { + TokenQueryOption::ByAddress(address) => { + params.insert("address", format!("{:?}", address)); + params + } + TokenQueryOption::ByContract(contract) => { + params.insert("contractaddress", format!("{:?}", contract)); + params + } + TokenQueryOption::ByAddressAndContract(address, contract) => { + params.insert("address", format!("{:?}", address)); + params.insert("contractaddress", format!("{:?}", contract)); + params + } + } + } +} + +/// The pre-defined block type for retrieving mined blocks +pub enum BlockType { + CanonicalBlocks, + Uncles, +} + +impl Display for BlockType { + fn fmt(&self, f: &mut Formatter<'_>) -> std::result::Result<(), Error> { + match self { + BlockType::CanonicalBlocks => write!(f, "blocks"), + BlockType::Uncles => write!(f, "uncles"), + } + } +} + +impl Default for BlockType { + fn default() -> Self { + BlockType::CanonicalBlocks + } +} + +impl Client { + /// Returns the Ether balance of a given address. + /// + /// ```no_run + /// # use ethers_etherscan::Client; + /// # use ethers_core::types::Chain; + /// + /// # #[tokio::main] + /// # async fn main() { + /// let client = Client::new(Chain::Mainnet, "API_KEY").unwrap(); + /// let balance = client + /// .get_ether_balance_single(&"0x58eB28A67731c570Ef827C365c89B5751F9E6b0a".parse().unwrap(), + /// None).await.unwrap(); + /// # } + /// ``` + pub async fn get_ether_balance_single( + &self, + address: &Address, + tag: Option, + ) -> Result { + let tag_str = tag.unwrap_or_default().to_string(); + let addr_str = format!("{:?}", address); + let query = self.create_query( + "account", + "balance", + HashMap::from([("address", &addr_str), ("tag", &tag_str)]), + ); + let response: Response = self.get_json(&query).await?; + + match response.status.as_str() { + "0" => Err(EtherscanError::BalanceFailed), + "1" => Ok(AccountBalance { account: *address, balance: response.result }), + err => Err(EtherscanError::BadStatusCode(err.to_string())), + } + } + + /// Returns the balance of the accounts from a list of addresses. + /// + /// ```no_run + /// # use ethers_etherscan::Client; + /// # use ethers_core::types::Chain; + /// + /// # #[tokio::main] + /// # async fn main() { + /// let client = Client::new(Chain::Mainnet, "API_KEY").unwrap(); + /// let balances = client + /// .get_ether_balance_multi(&vec![&"0x58eB28A67731c570Ef827C365c89B5751F9E6b0a".parse().unwrap()], + /// None).await.unwrap(); + /// # } + /// ``` + pub async fn get_ether_balance_multi( + &self, + addresses: &[&Address], + tag: Option, + ) -> Result> { + let tag_str = tag.unwrap_or_default().to_string(); + let addrs = addresses.iter().map(|x| format!("{:?}", x)).collect::>().join(","); + let query: Query> = self.create_query( + "account", + "balancemulti", + HashMap::from([("address", addrs.as_ref()), ("tag", tag_str.as_ref())]), + ); + let response: Response> = self.get_json(&query).await?; + + match response.status.as_str() { + "0" => Err(EtherscanError::BalanceFailed), + "1" => Ok(response.result), + err => Err(EtherscanError::BadStatusCode(err.to_string())), + } + } + + /// Returns the list of transactions performed by an address, with optional pagination. + /// + /// ```no_run + /// # use ethers_etherscan::Client; + /// # use ethers_core::types::Chain; + /// + /// # #[tokio::main] + /// # async fn main() { + /// let client = Client::new(Chain::Mainnet, "API_KEY").unwrap(); + /// let txs = client + /// .get_transactions(&"0x58eB28A67731c570Ef827C365c89B5751F9E6b0a".parse().unwrap(), + /// None).await.unwrap(); + /// # } + /// ``` + pub async fn get_transactions( + &self, + address: &Address, + params: Option, + ) -> Result> { + let mut tx_params: HashMap<&str, String> = params.unwrap_or_default().into(); + tx_params.insert("address", format!("{:?}", address)); + let query = self.create_query("account", "txlist", tx_params); + let response: Response> = self.get_json(&query).await?; + + Ok(response.result) + } + + /// Returns the list of internal transactions performed by an address or within a transaction, + /// with optional pagination. + /// + /// ```no_run + /// # use ethers_etherscan::{Client, account::InternalTxQueryOption}; + /// # use ethers_core::types::Chain; + /// + /// # #[tokio::main] + /// # async fn main() { + /// let client = Client::new(Chain::Mainnet, "API_KEY").unwrap(); + /// let txs = client + /// .get_internal_transactions( + /// InternalTxQueryOption::ByAddress( + /// "0x2c1ba59d6f58433fb1eaee7d20b26ed83bda51a3".parse().unwrap()), None).await.unwrap(); + /// # } + /// ``` + pub async fn get_internal_transactions( + &self, + tx_query_option: InternalTxQueryOption, + params: Option, + ) -> Result> { + let mut tx_params: HashMap<&str, String> = params.unwrap_or_default().into(); + match tx_query_option { + InternalTxQueryOption::ByAddress(address) => { + tx_params.insert("address", format!("{:?}", address)); + } + InternalTxQueryOption::ByTransactionHash(tx_hash) => { + tx_params.insert("txhash", format!("{:?}", tx_hash)); + } + _ => {} + } + let query = self.create_query("account", "txlistinternal", tx_params); + let response: Response> = self.get_json(&query).await?; + + Ok(response.result) + } + + /// Returns the list of ERC-20 tokens transferred by an address, with optional filtering by + /// token contract. + /// + /// ```no_run + /// # use ethers_etherscan::{Client, account::TokenQueryOption}; + /// # use ethers_core::types::Chain; + /// + /// # #[tokio::main] + /// # async fn main() { + /// let client = Client::new(Chain::Mainnet, "API_KEY").unwrap(); + /// let txs = client + /// .get_erc20_token_transfer_events( + /// TokenQueryOption::ByAddress( + /// "0x4e83362442b8d1bec281594cea3050c8eb01311c".parse().unwrap()), None).await.unwrap(); + /// # } + /// ``` + pub async fn get_erc20_token_transfer_events( + &self, + event_query_option: TokenQueryOption, + params: Option, + ) -> Result> { + let params = event_query_option.into_params(params.unwrap_or_default()); + let query = self.create_query("account", "tokentx", params); + let response: Response> = self.get_json(&query).await?; + + Ok(response.result) + } + + /// Returns the list of ERC-721 ( NFT ) tokens transferred by an address, with optional + /// filtering by token contract. + /// + /// ```no_run + /// # use ethers_etherscan::{Client, account::TokenQueryOption}; + /// # use ethers_core::types::Chain; + /// + /// # #[tokio::main] + /// # async fn main() { + /// let client = Client::new(Chain::Mainnet, "API_KEY").unwrap(); + /// let txs = client + /// .get_erc721_token_transfer_events( + /// TokenQueryOption::ByAddressAndContract( + /// "0x6975be450864c02b4613023c2152ee0743572325".parse().unwrap(), + /// "0x06012c8cf97bead5deae237070f9587f8e7a266d".parse().unwrap(), + /// ), None).await.unwrap(); + /// # } + /// ``` + pub async fn get_erc721_token_transfer_events( + &self, + event_query_option: TokenQueryOption, + params: Option, + ) -> Result> { + let params = event_query_option.into_params(params.unwrap_or_default()); + let query = self.create_query("account", "tokennfttx", params); + let response: Response> = self.get_json(&query).await?; + + Ok(response.result) + } + + /// Returns the list of blocks mined by an address. + /// + /// ```no_run + /// # use ethers_etherscan::Client; + /// # use ethers_core::types::Chain; + /// + /// # #[tokio::main] + /// # async fn main() { + /// let client = Client::new(Chain::Mainnet, "API_KEY").unwrap(); + /// let blocks = client + /// .get_mined_blocks(&"0x9dd134d14d1e65f84b706d6f205cd5b1cd03a46b".parse().unwrap(), None, None) + /// .await.unwrap(); + /// # } + /// ``` + pub async fn get_mined_blocks( + &self, + address: &Address, + block_type: Option, + page_and_offset: Option<(u64, u64)>, + ) -> Result> { + let mut params = HashMap::new(); + params.insert("address", format!("{:?}", address)); + params.insert("blocktype", block_type.unwrap_or_default().to_string()); + if let Some((page, offset)) = page_and_offset { + params.insert("page", page.to_string()); + params.insert("offset", offset.to_string()); + } + let query = self.create_query("account", "getminedblocks", params); + let response: Response> = self.get_json(&query).await?; + + Ok(response.result) + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use serial_test::serial; + + use crate::{tests::run_at_least_duration, Chain}; + + use super::*; + + #[tokio::test] + #[serial] + async fn get_ether_balance_single_success() { + run_at_least_duration(Duration::from_millis(250), async { + let client = Client::new_from_env(Chain::Mainnet).unwrap(); + + let balance = client + .get_ether_balance_single( + &"0x58eB28A67731c570Ef827C365c89B5751F9E6b0a".parse().unwrap(), + None, + ) + .await; + assert!(balance.is_ok()); + }) + .await + } + + #[tokio::test] + #[serial] + async fn get_ether_balance_multi_success() { + run_at_least_duration(Duration::from_millis(250), async { + let client = Client::new_from_env(Chain::Mainnet).unwrap(); + + let balances = client + .get_ether_balance_multi( + &vec![&"0x58eB28A67731c570Ef827C365c89B5751F9E6b0a".parse().unwrap()], + None, + ) + .await; + assert!(balances.is_ok()); + let balances = balances.unwrap(); + assert!(balances.len() == 1); + }) + .await + } + + #[tokio::test] + #[serial] + async fn get_transactions_success() { + run_at_least_duration(Duration::from_millis(250), async { + let client = Client::new_from_env(Chain::Mainnet).unwrap(); + + let txs = client + .get_transactions( + &"0x58eB28A67731c570Ef827C365c89B5751F9E6b0a".parse().unwrap(), + None, + ) + .await; + dbg!(&txs); + assert!(txs.is_ok()); + }) + .await + } + + #[tokio::test] + #[serial] + async fn get_internal_transactions_success() { + run_at_least_duration(Duration::from_millis(250), async { + let client = Client::new_from_env(Chain::Mainnet).unwrap(); + + let txs = client + .get_internal_transactions( + InternalTxQueryOption::ByAddress( + "0x2c1ba59d6f58433fb1eaee7d20b26ed83bda51a3".parse().unwrap(), + ), + None, + ) + .await; + assert!(txs.is_ok()); + }) + .await + } + + #[tokio::test] + #[serial] + async fn get_internal_transactions_by_tx_hash_success() { + run_at_least_duration(Duration::from_millis(250), async { + let client = Client::new_from_env(Chain::Mainnet).unwrap(); + + let txs = client + .get_internal_transactions( + InternalTxQueryOption::ByTransactionHash( + "0x40eb908387324f2b575b4879cd9d7188f69c8fc9d87c901b9e2daaea4b442170" + .parse() + .unwrap(), + ), + None, + ) + .await; + assert!(txs.is_ok()); + }) + .await + } + + #[tokio::test] + #[serial] + async fn get_erc20_transfer_events_success() { + run_at_least_duration(Duration::from_millis(250), async { + let client = Client::new_from_env(Chain::Mainnet).unwrap(); + + let txs = client + .get_erc20_token_transfer_events( + TokenQueryOption::ByAddress( + "0x4e83362442b8d1bec281594cea3050c8eb01311c".parse().unwrap(), + ), + None, + ) + .await; + assert!(txs.is_ok()); + }) + .await + } + + #[tokio::test] + #[serial] + async fn get_erc721_transfer_events_success() { + run_at_least_duration(Duration::from_millis(250), async { + let client = Client::new_from_env(Chain::Mainnet).unwrap(); + + let txs = client + .get_erc721_token_transfer_events( + TokenQueryOption::ByAddressAndContract( + "0x6975be450864c02b4613023c2152ee0743572325".parse().unwrap(), + "0x06012c8cf97bead5deae237070f9587f8e7a266d".parse().unwrap(), + ), + None, + ) + .await; + assert!(txs.is_ok()); + }) + .await + } + + #[tokio::test] + #[serial] + async fn get_mined_blocks_success() { + run_at_least_duration(Duration::from_millis(250), async { + let client = Client::new_from_env(Chain::Mainnet).unwrap(); + + let blocks = client + .get_mined_blocks( + &"0x9dd134d14d1e65f84b706d6f205cd5b1cd03a46b".parse().unwrap(), + None, + None, + ) + .await; + assert!(blocks.is_ok()); + }) + .await + } +} diff --git a/ethers-etherscan/src/errors.rs b/ethers-etherscan/src/errors.rs index 01b552ad..ab6b6b0d 100644 --- a/ethers-etherscan/src/errors.rs +++ b/ethers-etherscan/src/errors.rs @@ -7,6 +7,8 @@ pub enum EtherscanError { ChainNotSupported(Chain), #[error("contract execution call failed: {0}")] ExecutionFailed(String), + #[error("balance failed")] + BalanceFailed, #[error("tx receipt failed")] TransactionReceiptFailed, #[error("gas estimation failed")] diff --git a/ethers-etherscan/src/lib.rs b/ethers-etherscan/src/lib.rs index 9d7b2615..6f2ebb9f 100644 --- a/ethers-etherscan/src/lib.rs +++ b/ethers-etherscan/src/lib.rs @@ -8,6 +8,7 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize}; use errors::EtherscanError; use ethers_core::{abi::Address, types::Chain}; +pub mod account; pub mod contract; pub mod errors; pub mod gas;