Skip to content
This repository has been archived by the owner on Oct 19, 2024. It is now read-only.

Add ENS avatar and TXT records resolution #889

Merged
merged 11 commits into from
Feb 16, 2022
40 changes: 38 additions & 2 deletions ethers-providers/src/ens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)')
Expand All @@ -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())),
Expand All @@ -56,7 +62,7 @@ pub fn reverse_address(addr: Address) -> String {
/// Returns the ENS namehash as specified in [EIP-137](https://eips.ethereum.org/EIPS/eip-137)
pub fn namehash(name: &str) -> H256 {
if name.is_empty() {
return H256::zero()
return H256::zero();
}

// iterate in reverse
Expand All @@ -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::*;
Expand All @@ -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,
]
);
}
}
26 changes: 26 additions & 0 deletions ethers-providers/src/erc.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
use ethers_core::types::Selector;

use serde::Deserialize;
use url::Url;

/// tokenURI(uint256)
pub const ERC721_SELECTOR: Selector = [0x63, 0x52, 0x21, 0x1e];

/// url(uint256)
pub const ERC1155_SELECTOR: Selector = [0x00, 0xfd, 0xd5, 0x8e];

const IPFS_GATEWAY: &str = "https://ipfs.io/ipfs/";

/// 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())
}
11 changes: 11 additions & 0 deletions ethers-providers/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@ pub use stream::{interval, FilterWatcher, TransactionStream, DEFAULT_POLL_INTERV
mod pubsub;
pub use pubsub::{PubsubClient, SubscriptionStream};

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};

Expand Down Expand Up @@ -263,6 +266,14 @@ 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_field(&self, ens_name: &str, field: &str) -> Result<String, Self::Error> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please document these trait functions, or link to the matching functions, that are document.

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,
Expand Down
192 changes: 186 additions & 6 deletions ethers-providers/src/provider.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
};
Expand Down Expand Up @@ -786,6 +786,151 @@ 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)
///
/// # Panics
///
sbihel marked this conversation as resolved.
Show resolved Hide resolved
/// 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 = self.resolve_field(ens_name, "avatar").await?;
let url = Url::from_str(&field).map_err(|e| ProviderError::CustomError(e.to_string()))?;
let owner = self.resolve_name(ens_name).await?;
sbihel marked this conversation as resolved.
Show resolved Hide resolved
match url.scheme() {
"https" | "data" => Ok(url),
"ipfs" => erc::http_link_ipfs(url).map_err(ProviderError::CustomError),
"eip155" => {
sbihel marked this conversation as resolved.
Show resolved Hide resolved
let split: Vec<&str> = url.path().trim_start_matches("1/").split(':').collect();
let (inner_scheme, inner_path) = if split.len() == 2 {
(split[0], split[1])
sbihel marked this conversation as resolved.
Show resolved Hide resolved
} else {
return Err(ProviderError::CustomError("Unsupported ERC link".to_string()));
};

let token_split: Vec<&str> = inner_path.split('/').collect();
sbihel marked this conversation as resolved.
Show resolved Hide resolved
let (contract_addr, token_id) = if token_split.len() == 2 {
let token_id = U256::from_dec_str(token_split[1]).map_err(|e| {
ProviderError::CustomError(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| {
ProviderError::CustomError(format!(
"Invalid contract address: {} {}",
token_split[0], e
))
},
)?,
token_id_bytes,
)
} else {
return Err(ProviderError::CustomError(
"Unsupported ERC link path".to_string(),
));
};
let selector: Selector = match inner_scheme {
"erc721" => {
let tx = TransactionRequest {
data: Some([&erc::ERC721_SELECTOR[..], &token_id].concat().into()),
to: Some(NameOrAddress::Address(contract_addr)),
..Default::default()
};
let data = self.call(&tx.into(), None).await?;
if decode_bytes::<Address>(ParamType::Address, data) != owner {
sbihel marked this conversation as resolved.
Show resolved Hide resolved
return Err(ProviderError::CustomError("Incorrect owner.".to_string()));
}
[0xc8, 0x7b, 0x56, 0xdd]
}
"erc1155" => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if possible, we should make them 2 separate functions

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

307bdcc adds a ERCToken type and another method to resolve a token. Not sure about how future this is though 🤔

let tx = TransactionRequest {
data: Some(
[&erc::ERC1155_SELECTOR[..], &[0x0; 12], &owner.0, &token_id]
.concat()
.into(),
),
to: Some(NameOrAddress::Address(contract_addr)),
..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(),
));
}
[0x0e, 0x89, 0x34, 0x1c]
}
_ => {
return Err(ProviderError::CustomError(
"Unsupported eip155 token type".to_string(),
))
}
};

let tx = TransactionRequest {
data: Some([&selector[..], &token_id].concat().into()),
to: Some(NameOrAddress::Address(contract_addr)),
..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 inner_scheme == "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
.map_err(|e| ProviderError::CustomError(e.to_string()))?
sbihel marked this conversation as resolved.
Show resolved Hide resolved
.json()
.await
.map_err(|e| ProviderError::CustomError(e.to_string()))?;

let image_url = Url::parse(&metadata.image)
.map_err(|e| ProviderError::CustomError(e.to_string()))?;
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())),
}
}

/// Fetch a field for the `ens_name` (no None if not configured).
///
/// # Panics
///
sbihel marked this conversation as resolved.
Show resolved Hide resolved
/// 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)
Expand Down Expand Up @@ -981,6 +1126,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);
Expand All @@ -991,12 +1146,13 @@ impl<P: JsonRpcClient> Provider<P> {

let resolver_address: Address = decode_bytes(ParamType::Address, data);
if resolver_address == Address::zero() {
return Err(ProviderError::EnsError(ens_name.to_owned()))
return Err(ProviderError::EnsError(ens_name.to_owned()));
}

// 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))
}
Expand Down Expand Up @@ -1346,6 +1502,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
("shaq.eth", "https://creature.mypinata.cloud/ipfs/QmY4mSRgKa9BdB4iCaQ3tt7Vy7evvhduPwHn9JFG6iC3ie/9018.jpg"),
// 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() {
Expand Down