diff --git a/CHANGELOG.md b/CHANGELOG.md index 708d9d3d..26cbba18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ so that the receipt can be returned to the called when deploying a contract [#865](https://github.com/gakonst/ethers-rs/pull/865) - Add Arbitrum mainnet and testnet to the list of known chains +- Add ENS avatar and TXT records resolution + [#889](https://github.com/gakonst/ethers-rs/pull/889) - Add a getter to `ProjectCompileOutput` that returns a mapping of compiler versions to a vector of name + contract struct tuples [#908](https://github.com/gakonst/ethers-rs/pull/908) diff --git a/ethers-providers/README.md b/ethers-providers/README.md index 39393314..be18a8c7 100644 --- a/ethers-providers/README.md +++ b/ethers-providers/README.md @@ -62,6 +62,16 @@ let address = provider.resolve_name(name).await?; // Lookup ENS name given Address let resolved_name = provider.lookup_address(address).await?; assert_eq!(name, resolved_name); + +/// Lookup ENS field +let url = "https://vitalik.ca".to_string(); +let resolved_url = provider.resolve_field(name, "url").await?; +assert_eq!(url, resolved_url); + +/// Lookup and resolve ENS avatar +let avatar = "https://ipfs.io/ipfs/QmSP4nq9fnN9dAiCj42ug9Wa79rqmQerZXZch82VqpiH7U/image.gif".to_string(); +let resolved_avatar = provider.resolve_avatar(name).await?; +assert_eq!(avatar, resolved_avatar.to_string()); # Ok(()) # } ``` diff --git a/ethers-providers/src/ens.rs b/ethers-providers/src/ens.rs index f56c2c90..4db47d53 100644 --- a/ethers-providers/src/ens.rs +++ b/ethers-providers/src/ens.rs @@ -5,6 +5,8 @@ use ethers_core::{ utils::keccak256, }; +use std::convert::TryInto; + /// ENS registry address (`0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e`) pub const ENS_ADDRESS: Address = H160([ // cannot set type aliases as constructors @@ -23,6 +25,9 @@ pub const ADDR_SELECTOR: Selector = [59, 59, 87, 222]; /// name(bytes32) pub const NAME_SELECTOR: Selector = [105, 31, 52, 49]; +/// text(bytes32, string) +pub const FIELD_SELECTOR: Selector = [89, 209, 212, 60]; + /// Returns a transaction request for calling the `resolver` method on the ENS server pub fn get_resolver>(ens_address: T, name: &str) -> TransactionRequest { // keccak256('resolver(bytes32)') @@ -39,8 +44,9 @@ pub fn resolve>( resolver_address: T, selector: Selector, name: &str, + parameters: Option<&[u8]>, ) -> TransactionRequest { - let data = [&selector[..], &namehash(name).0].concat(); + let data = [&selector[..], &namehash(name).0, parameters.unwrap_or_default()].concat(); TransactionRequest { data: Some(data.into()), to: Some(NameOrAddress::Address(resolver_address.into())), @@ -65,6 +71,23 @@ pub fn namehash(name: &str) -> H256 { .into() } +/// Returns a number in bytes form with padding to fit in 32 bytes. +pub fn bytes_32ify(n: u64) -> Vec { + let b = n.to_be_bytes(); + [[0; 32][b.len()..].to_vec(), b.to_vec()].concat() +} + +/// Returns the ENS record key hash [EIP-634](https://eips.ethereum.org/EIPS/eip-634) +pub fn parameterhash(name: &str) -> Vec { + let bytes = name.as_bytes(); + let key_bytes = + [&bytes_32ify(64), &bytes_32ify(bytes.len().try_into().unwrap()), bytes].concat(); + match key_bytes.len() % 32 { + 0 => key_bytes, + n => [key_bytes, [0; 32][n..].to_vec()].concat(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -86,4 +109,17 @@ mod tests { assert_hex(namehash(name), expected); } } + + #[test] + fn test_parametershash() { + assert_eq!( + parameterhash("avatar").to_vec(), + vec![ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 6, 97, 118, 97, 116, 97, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ] + ); + } } diff --git a/ethers-providers/src/erc.rs b/ethers-providers/src/erc.rs new file mode 100644 index 00000000..22e2b624 --- /dev/null +++ b/ethers-providers/src/erc.rs @@ -0,0 +1,97 @@ +//! ERC related utilities. Only supporting NFTs for now. +use ethers_core::types::{Address, Selector, U256}; + +use serde::Deserialize; +use std::str::FromStr; +use url::Url; + +/// ownerOf(uint256 tokenId) +pub const ERC721_OWNER_SELECTOR: Selector = [0x63, 0x52, 0x21, 0x1e]; + +/// balanceOf(address owner, uint256 tokenId) +pub const ERC1155_BALANCE_SELECTOR: Selector = [0x00, 0xfd, 0xd5, 0x8e]; + +const IPFS_GATEWAY: &str = "https://ipfs.io/ipfs/"; + +/// An ERC 721 or 1155 token +pub struct ERCNFT { + pub type_: ERCNFTType, + pub contract: Address, + pub id: [u8; 32], +} + +impl FromStr for ERCNFT { + type Err = String; + fn from_str(input: &str) -> Result { + let split: Vec<&str> = + input.trim_start_matches("eip155:").trim_start_matches("1/").split(':').collect(); + let (token_type, inner_path) = if split.len() == 2 { + ( + ERCNFTType::from_str(split[0]) + .map_err(|_| "Unsupported ERC token type".to_string())?, + split[1], + ) + } else { + return Err("Unsupported ERC link".to_string()) + }; + + let token_split: Vec<&str> = inner_path.split('/').collect(); + let (contract_addr, token_id) = if token_split.len() == 2 { + let token_id = U256::from_dec_str(token_split[1]) + .map_err(|e| format!("Unsupported token id type: {} {}", token_split[1], e))?; + let mut token_id_bytes = [0x0; 32]; + token_id.to_big_endian(&mut token_id_bytes); + ( + Address::from_str(token_split[0].trim_start_matches("0x")) + .map_err(|e| format!("Invalid contract address: {} {}", token_split[0], e))?, + token_id_bytes, + ) + } else { + return Err("Unsupported ERC link path".to_string()) + }; + Ok(ERCNFT { id: token_id, type_: token_type, contract: contract_addr }) + } +} + +/// Supported ERCs +#[derive(PartialEq)] +pub enum ERCNFTType { + ERC721, + ERC1155, +} + +impl FromStr for ERCNFTType { + type Err = (); + fn from_str(input: &str) -> Result { + match input { + "erc721" => Ok(ERCNFTType::ERC721), + "erc1155" => Ok(ERCNFTType::ERC1155), + _ => Err(()), + } + } +} + +impl ERCNFTType { + pub fn resolution_selector(&self) -> Selector { + match self { + // tokenURI(uint256) + ERCNFTType::ERC721 => [0xc8, 0x7b, 0x56, 0xdd], + // url(uint256) + ERCNFTType::ERC1155 => [0x0e, 0x89, 0x34, 0x1c], + } + } +} + +/// ERC-1155 and ERC-721 metadata document. +#[derive(Deserialize)] +pub struct Metadata { + pub image: String, +} + +/// Returns a HTTP url for an IPFS object. +pub fn http_link_ipfs(url: Url) -> Result { + Url::parse(IPFS_GATEWAY) + .unwrap() + .join(url.to_string().trim_start_matches("ipfs://").trim_start_matches("ipfs/")) + .map_err(|e| e.to_string()) +} diff --git a/ethers-providers/src/lib.rs b/ethers-providers/src/lib.rs index 632d1a43..d169bcc2 100644 --- a/ethers-providers/src/lib.rs +++ b/ethers-providers/src/lib.rs @@ -24,11 +24,14 @@ pub use stream::{interval, FilterWatcher, TransactionStream, DEFAULT_POLL_INTERV mod pubsub; pub use pubsub::{PubsubClient, SubscriptionStream}; +pub mod erc; + use async_trait::async_trait; use auto_impl::auto_impl; use ethers_core::types::transaction::{eip2718::TypedTransaction, eip2930::AccessListWithGasUsed}; use serde::{de::DeserializeOwned, Serialize}; use std::{error::Error, fmt::Debug, future::Future, pin::Pin}; +use url::Url; pub use provider::{FilterKind, Provider, ProviderError}; @@ -263,6 +266,18 @@ pub trait Middleware: Sync + Send + Debug { self.inner().lookup_address(address).await.map_err(FromErr::from) } + async fn resolve_avatar(&self, ens_name: &str) -> Result { + self.inner().resolve_avatar(ens_name).await.map_err(FromErr::from) + } + + async fn resolve_nft(&self, token: erc::ERCNFT) -> Result { + self.inner().resolve_nft(token).await.map_err(FromErr::from) + } + + async fn resolve_field(&self, ens_name: &str, field: &str) -> Result { + self.inner().resolve_field(ens_name, field).await.map_err(FromErr::from) + } + async fn get_block + Send + Sync>( &self, block_hash_or_number: T, diff --git a/ethers-providers/src/provider.rs b/ethers-providers/src/provider.rs index ec32b303..13002168 100644 --- a/ethers-providers/src/provider.rs +++ b/ethers-providers/src/provider.rs @@ -1,5 +1,5 @@ use crate::{ - ens, maybe, + ens, erc, maybe, pubsub::{PubsubClient, SubscriptionStream}, stream::{FilterWatcher, DEFAULT_POLL_INTERVAL}, FromErr, Http as HttpProvider, JsonRpcClient, JsonRpcClientWrapper, MockProvider, @@ -17,8 +17,8 @@ use ethers_core::{ transaction::{eip2718::TypedTransaction, eip2930::AccessListWithGasUsed}, Address, Block, BlockId, BlockNumber, BlockTrace, Bytes, EIP1186ProofResponse, FeeHistory, Filter, Log, NameOrAddress, Selector, Signature, Trace, TraceFilter, TraceType, - Transaction, TransactionReceipt, TxHash, TxpoolContent, TxpoolInspect, TxpoolStatus, H256, - U256, U64, + Transaction, TransactionReceipt, TransactionRequest, TxHash, TxpoolContent, TxpoolInspect, + TxpoolStatus, H256, U256, U64, }, utils, }; @@ -27,7 +27,7 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize}; use thiserror::Error; use url::{ParseError, Url}; -use futures_util::lock::Mutex; +use futures_util::{lock::Mutex, try_join}; use std::{convert::TryFrom, fmt::Debug, str::FromStr, sync::Arc, time::Duration}; use tracing::trace; use tracing_futures::Instrument; @@ -118,6 +118,9 @@ pub enum ProviderError { #[error(transparent)] HexError(#[from] hex::FromHexError), + #[error(transparent)] + HTTPError(#[from] reqwest::Error), + #[error("custom error: {0}")] CustomError(String), @@ -786,6 +789,143 @@ impl Middleware for Provider

{ self.query_resolver(ParamType::String, &ens_name, ens::NAME_SELECTOR).await } + /// Returns the avatar HTTP link of the avatar that the `ens_name` resolves to (or None + /// if not configured) + /// + /// # Example + /// ```no_run + /// # use ethers_providers::{Provider, Http as HttpProvider, Middleware}; + /// # use std::convert::TryFrom; + /// # #[tokio::main(flavor = "current_thread")] + /// # async fn main() { + /// # let provider = Provider::::try_from("https://mainnet.infura.io/v3/c60b0bb42f8a4c6481ecd229eddaca27").unwrap(); + /// let avatar = provider.resolve_avatar("parishilton.eth").await.unwrap(); + /// assert_eq!(avatar.to_string(), "https://i.imgur.com/YW3Hzph.jpg"); + /// # } + /// ``` + /// + /// # Panics + /// + /// If the bytes returned from the ENS registrar/resolver cannot be interpreted as + /// a string. This should theoretically never happen. + async fn resolve_avatar(&self, ens_name: &str) -> Result { + let (field, owner) = + try_join!(self.resolve_field(ens_name, "avatar"), self.resolve_name(ens_name))?; + let url = Url::from_str(&field).map_err(|e| ProviderError::CustomError(e.to_string()))?; + match url.scheme() { + "https" | "data" => Ok(url), + "ipfs" => erc::http_link_ipfs(url).map_err(ProviderError::CustomError), + "eip155" => { + let token = + erc::ERCNFT::from_str(url.path()).map_err(ProviderError::CustomError)?; + match token.type_ { + erc::ERCNFTType::ERC721 => { + let tx = TransactionRequest { + data: Some( + [&erc::ERC721_OWNER_SELECTOR[..], &token.id].concat().into(), + ), + to: Some(NameOrAddress::Address(token.contract)), + ..Default::default() + }; + let data = self.call(&tx.into(), None).await?; + if decode_bytes::

(ParamType::Address, data) != owner { + return Err(ProviderError::CustomError("Incorrect owner.".to_string())) + } + } + erc::ERCNFTType::ERC1155 => { + let tx = TransactionRequest { + data: Some( + [ + &erc::ERC1155_BALANCE_SELECTOR[..], + &[0x0; 12], + &owner.0, + &token.id, + ] + .concat() + .into(), + ), + to: Some(NameOrAddress::Address(token.contract)), + ..Default::default() + }; + let data = self.call(&tx.into(), None).await?; + if decode_bytes::(ParamType::Uint(64), data) == 0 { + return Err(ProviderError::CustomError("Incorrect balance.".to_string())) + } + } + } + + let image_url = self.resolve_nft(token).await?; + match image_url.scheme() { + "https" | "data" => Ok(image_url), + "ipfs" => erc::http_link_ipfs(image_url).map_err(ProviderError::CustomError), + _ => Err(ProviderError::CustomError( + "Unsupported scheme for the image".to_string(), + )), + } + } + _ => Err(ProviderError::CustomError("Unsupported scheme".to_string())), + } + } + + /// Returns the URL (not necesserily HTTP) of the image behind a token. + /// + /// # Example + /// ```no_run + /// # use ethers_providers::{Provider, Http as HttpProvider, Middleware}; + /// # use std::{str::FromStr, convert::TryFrom}; + /// # #[tokio::main(flavor = "current_thread")] + /// # async fn main() { + /// # let provider = Provider::::try_from("https://mainnet.infura.io/v3/c60b0bb42f8a4c6481ecd229eddaca27").unwrap(); + /// let token = ethers_providers::erc::ERCNFT::from_str("erc721:0xc92ceddfb8dd984a89fb494c376f9a48b999aafc/9018").unwrap(); + /// let token_image = provider.resolve_nft(token).await.unwrap(); + /// assert_eq!(token_image.to_string(), "https://creature.mypinata.cloud/ipfs/QmNwj3aUzXfG4twV3no7hJRYxLLAWNPk6RrfQaqJ6nVJFa/9018.jpg"); + /// # } + /// ``` + /// + /// # Panics + /// + /// If the bytes returned from the ENS registrar/resolver cannot be interpreted as + /// a string. This should theoretically never happen. + async fn resolve_nft(&self, token: erc::ERCNFT) -> Result { + let selector = token.type_.resolution_selector(); + let tx = TransactionRequest { + data: Some([&selector[..], &token.id].concat().into()), + to: Some(NameOrAddress::Address(token.contract)), + ..Default::default() + }; + let data = self.call(&tx.into(), None).await?; + let mut metadata_url = Url::parse(&decode_bytes::(ParamType::String, data)) + .map_err(|e| ProviderError::CustomError(format!("Invalid metadata url: {}", e)))?; + + if token.type_ == erc::ERCNFTType::ERC1155 { + metadata_url + .set_path(&metadata_url.path().replace("%7Bid%7D", &hex::encode(&token.id))); + } + if metadata_url.scheme() == "ipfs" { + metadata_url = erc::http_link_ipfs(metadata_url).map_err(ProviderError::CustomError)?; + } + let metadata: erc::Metadata = reqwest::get(metadata_url).await?.json().await?; + Url::parse(&metadata.image).map_err(|e| ProviderError::CustomError(e.to_string())) + } + + /// Fetch a field for the `ens_name` (no None if not configured). + /// + /// # Panics + /// + /// If the bytes returned from the ENS registrar/resolver cannot be interpreted as + /// a string. This should theoretically never happen. + async fn resolve_field(&self, ens_name: &str, field: &str) -> Result { + let field: String = self + .query_resolver_parameters( + ParamType::String, + ens_name, + ens::FIELD_SELECTOR, + Some(&ens::parameterhash(field)), + ) + .await?; + Ok(field) + } + /// Returns the details of all transactions currently pending for inclusion in the next /// block(s), as well as the ones that are being scheduled for future execution only. /// Ref: [Here](https://geth.ethereum.org/docs/rpc/ns-txpool#txpool_content) @@ -981,6 +1121,16 @@ impl Provider

{ param: ParamType, ens_name: &str, selector: Selector, + ) -> Result { + self.query_resolver_parameters(param, ens_name, selector, None).await + } + + async fn query_resolver_parameters( + &self, + param: ParamType, + ens_name: &str, + selector: Selector, + parameters: Option<&[u8]>, ) -> Result { // Get the ENS address, prioritize the local override variable let ens_addr = self.ens.unwrap_or(ens::ENS_ADDRESS); @@ -995,8 +1145,9 @@ impl Provider

{ } // resolve - let data = - self.call(&ens::resolve(resolver_address, selector, ens_name).into(), None).await?; + let data = self + .call(&ens::resolve(resolver_address, selector, ens_name, parameters).into(), None) + .await?; Ok(decode_bytes(param, data)) } @@ -1346,6 +1497,30 @@ mod tests { .unwrap_err(); } + #[tokio::test] + async fn mainnet_resolve_avatar() { + let provider = Provider::::try_from(INFURA).unwrap(); + + for (ens_name, res) in &[ + // HTTPS + ("alisha.eth", "https://ipfs.io/ipfs/QmeQm91kAdPGnUKsE74WvkqYKUeHvc2oHd2FW11V3TrqkQ"), + // ERC-1155 + ("nick.eth", "https://lh3.googleusercontent.com/hKHZTZSTmcznonu8I6xcVZio1IF76fq0XmcxnvUykC-FGuVJ75UPdLDlKJsfgVXH9wOSmkyHw0C39VAYtsGyxT7WNybjQ6s3fM3macE"), + // HTTPS + ("parishilton.eth", "https://i.imgur.com/YW3Hzph.jpg"), + // ERC-721 with IPFS link + ("ikehaya-nft.eth", "https://ipfs.io/ipfs/QmdKkwCE8uVhgYd7tWBfhtHdQZDnbNukWJ8bvQmR6nZKsk"), + // ERC-1155 with IPFS link + ("vitalik.eth", "https://ipfs.io/ipfs/QmSP4nq9fnN9dAiCj42ug9Wa79rqmQerZXZch82VqpiH7U/image.gif"), + // IPFS + ("cdixon.eth", "https://ipfs.io/ipfs/QmYA6ZpEARgHvRHZQdFPynMMX8NtdL2JCadvyuyG2oA88u"), + ("0age.eth", "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHN0eWxlPSJiYWNrZ3JvdW5kLWNvbG9yOmJsYWNrIiB2aWV3Qm94PSIwIDAgNTAwIDUwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB4PSIxNTUiIHk9IjYwIiB3aWR0aD0iMTkwIiBoZWlnaHQ9IjM5MCIgZmlsbD0iIzY5ZmYzNyIvPjwvc3ZnPg==") + ] { + println!("Resolving: {}", ens_name); + assert_eq!(provider.resolve_avatar(ens_name).await.unwrap(), Url::parse(res).unwrap()); + } + } + #[tokio::test] #[cfg_attr(feature = "celo", ignore)] async fn test_new_block_filter() {