Add ENS avatar and TXT records resolution (#889)

* Add ENS avatar resolution

As well as arbitrary fields.

* Use try_join

* Improve reqwest's error passing

* Split avatar resolution in ERC token parsing and resolution

* no_run examples

* Rename token to NFT

* A bit more documentation

* Nightly cargo fmt

* Use different ERC-721 test

* Update CHANGELOG

Co-authored-by: Georgios Konstantopoulos <me@gakonst.com>
This commit is contained in:
Simon Bihel 2022-02-16 14:25:41 +00:00 committed by GitHub
parent 78161f07e7
commit cd8a9b5a97
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 342 additions and 7 deletions

View File

@ -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)

View File

@ -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(())
# }
```

View File

@ -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<T: Into<Address>>(ens_address: T, name: &str) -> TransactionRequest {
// keccak256('resolver(bytes32)')
@ -39,8 +44,9 @@ pub fn resolve<T: Into<Address>>(
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<u8> {
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<u8> {
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,
]
);
}
}

View File

@ -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<ERCNFT, Self::Err> {
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<ERCNFTType, Self::Err> {
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, String> {
Url::parse(IPFS_GATEWAY)
.unwrap()
.join(url.to_string().trim_start_matches("ipfs://").trim_start_matches("ipfs/"))
.map_err(|e| e.to_string())
}

View File

@ -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<Url, Self::Error> {
self.inner().resolve_avatar(ens_name).await.map_err(FromErr::from)
}
async fn resolve_nft(&self, token: erc::ERCNFT) -> Result<Url, Self::Error> {
self.inner().resolve_nft(token).await.map_err(FromErr::from)
}
async fn resolve_field(&self, ens_name: &str, field: &str) -> Result<String, Self::Error> {
self.inner().resolve_field(ens_name, field).await.map_err(FromErr::from)
}
async fn get_block<T: Into<BlockId> + Send + Sync>(
&self,
block_hash_or_number: T,

View File

@ -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<P: JsonRpcClient> Middleware for Provider<P> {
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::<HttpProvider>::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<Url, ProviderError> {
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::<Address>(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::<u64>(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::<HttpProvider>::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<Url, ProviderError> {
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::<String>(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<String, ProviderError> {
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<P: JsonRpcClient> Provider<P> {
param: ParamType,
ens_name: &str,
selector: Selector,
) -> Result<T, ProviderError> {
self.query_resolver_parameters(param, ens_name, selector, None).await
}
async fn query_resolver_parameters<T: Detokenize>(
&self,
param: ParamType,
ens_name: &str,
selector: Selector,
parameters: Option<&[u8]>,
) -> Result<T, ProviderError> {
// Get the ENS address, prioritize the local override variable
let ens_addr = self.ens.unwrap_or(ens::ENS_ADDRESS);
@ -995,8 +1145,9 @@ impl<P: JsonRpcClient> Provider<P> {
}
// 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::<HttpProvider>::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() {