diff --git a/packages/providers/src.ts/base-provider.ts b/packages/providers/src.ts/base-provider.ts index 7b4a69a66d..3d34c4e3db 100644 --- a/packages/providers/src.ts/base-provider.ts +++ b/packages/providers/src.ts/base-provider.ts @@ -14,7 +14,7 @@ import { Deferrable, defineReadOnly, getStatic, resolveProperties } from "@ether import { Transaction } from "@ethersproject/transactions"; import { sha256 } from "@ethersproject/sha2"; import { toUtf8Bytes, toUtf8String } from "@ethersproject/strings"; -import { poll } from "@ethersproject/web"; +import { fetchJson, poll } from "@ethersproject/web"; import bech32 from "bech32"; @@ -237,32 +237,59 @@ function base58Encode(data: Uint8Array): string { return Base58.encode(concat([ data, hexDataSlice(sha256(sha256(data)), 0, 4) ])); } +export interface Avatar { + url: string; + linkage: Array<{ type: string, content: string }>; +} + +const matchers = [ + new RegExp("^(https):/\/(.*)$", "i"), + new RegExp("^(data):(.*)$", "i"), + new RegExp("^(ipfs):/\/(.*)$", "i"), + new RegExp("^eip155:[0-9]+/(erc[0-9]+):(.*)$", "i"), +]; + +function _parseString(result: string): null | string { + try { + return toUtf8String(_parseBytes(result)); + } catch(error) { } + return null; +} + +function _parseBytes(result: string): null | string { + if (result === "0x") { return null; } + + const offset = BigNumber.from(hexDataSlice(result, 0, 32)).toNumber(); + const length = BigNumber.from(hexDataSlice(result, offset, offset + 32)).toNumber(); + return hexDataSlice(result, offset + 32, offset + 32 + length); +} + + export class Resolver implements EnsResolver { readonly provider: BaseProvider; readonly name: string; readonly address: string; - constructor(provider: BaseProvider, address: string, name: string) { + readonly _resolvedAddress: null | string; + + // The resolvedAddress is only for creating a ReverseLookup resolver + constructor(provider: BaseProvider, address: string, name: string, resolvedAddress?: string) { defineReadOnly(this, "provider", provider); defineReadOnly(this, "name", name); defineReadOnly(this, "address", provider.formatter.address(address)); + defineReadOnly(this, "_resolvedAddress", resolvedAddress); } - async _fetchBytes(selector: string, parameters?: string): Promise { - // keccak256("addr(bytes32,uint256)") - const transaction = { + async _fetchBytes(selector: string, parameters?: string): Promise { + // e.g. keccak256("addr(bytes32,uint256)") + const tx = { to: this.address, data: hexConcat([ selector, namehash(this.name), (parameters || "0x") ]) }; try { - const result = await this.provider.call(transaction); - if (result === "0x") { return null; } - - const offset = BigNumber.from(hexDataSlice(result, 0, 32)).toNumber(); - const length = BigNumber.from(hexDataSlice(result, offset, offset + 32)).toNumber(); - return hexDataSlice(result, offset + 32, offset + 32 + length); + return _parseBytes(await this.provider.call(tx)); } catch (error) { if (error.code === Logger.errors.CALL_EXCEPTION) { return null; } return null; @@ -374,6 +401,95 @@ export class Resolver implements EnsResolver { return address; } + async getAvatar(): Promise { + const linkage: Array<{ type: string, content: string }> = [ ]; + try { + const avatar = await this.getText("avatar"); + if (avatar == null) { return null; } + + for (let i = 0; i < matchers.length; i++) { + const match = avatar.match(matchers[i]); + + if (match == null) { continue; } + switch (match[1]) { + case "https": + linkage.push({ type: "url", content: avatar }); + return { linkage, url: avatar }; + + case "data": + linkage.push({ type: "data", content: avatar }); + return { linkage, url: avatar }; + + case "ipfs": + linkage.push({ type: "ipfs", content: avatar }); + return { linkage, url: `https:/\/gateway.ipfs.io/ipfs/${ avatar.substring(7) }` } + + case "erc721": + case "erc1155": { + // Depending on the ERC type, use tokenURI(uint256) or url(uint256) + const selector = (match[1] === "erc721") ? "0xc87b56dd": "0x0e89341c"; + linkage.push({ type: match[1], content: avatar }); + + // The owner of this name + const owner = (this._resolvedAddress || await this.getAddress()); + + const comps = (match[2] || "").split("/"); + if (comps.length !== 2) { return null; } + + const addr = await this.provider.formatter.address(comps[0]); + const tokenId = hexZeroPad(BigNumber.from(comps[1]).toHexString(), 32); + + // Check that this account owns the token + if (match[1] === "erc721") { + // ownerOf(uint256 tokenId) + const tokenOwner = this.provider.formatter.callAddress(await this.provider.call({ + to: addr, data: hexConcat([ "0x6352211e", tokenId ]) + })); + if (owner !== tokenOwner) { return null; } + linkage.push({ type: "owner", content: tokenOwner }); + + } else if (match[1] === "erc1155") { + // balanceOf(address owner, uint256 tokenId) + const balance = BigNumber.from(await this.provider.call({ + to: addr, data: hexConcat([ "0x00fdd58e", hexZeroPad(owner, 32), tokenId ]) + })); + if (balance.isZero()) { return null; } + linkage.push({ type: "balance", content: balance.toString() }); + } + + // Call the token contract for the metadata URL + const tx = { + to: this.provider.formatter.address(comps[0]), + data: hexConcat([ selector, tokenId ]) + }; + let metadataUrl = _parseString(await this.provider.call(tx)) + if (metadataUrl == null) { return null; } + linkage.push({ type: "metadata-url", content: metadataUrl }); + + // ERC-1155 allows a generic {id} in the URL + if (match[1] === "erc1155") { + metadataUrl = metadataUrl.replace("{id}", tokenId.substring(2)); + } + + // Get the token metadata + const metadata = await fetchJson(metadataUrl); + + // Pull the image URL out + if (!metadata || typeof(metadata.image) !== "string" || !metadata.image.match(/^https:\/\//i)) { + return null; + } + linkage.push({ type: "metadata", content: JSON.stringify(metadata) }); + linkage.push({ type: "url", content: metadata.image }); + + return { linkage, url: metadata.image }; + } + } + } + } catch (error) { } + + return null; + } + async getContentHash(): Promise { // keccak256("contenthash()") @@ -1615,6 +1731,30 @@ export class BaseProvider extends Provider implements EnsProvider { return name; } + async getAvatar(nameOrAddress: string): Promise { + let resolver: Resolver = null; + if (isHexString(nameOrAddress)) { + // Address; reverse lookup + const address = this.formatter.address(nameOrAddress); + + const reverseName = address.substring(2).toLowerCase() + ".addr.reverse"; + + const resolverAddress = await this._getResolver(reverseName); + if (!resolverAddress) { return null; } + + resolver = new Resolver(this, resolverAddress, "_", address); + + } else { + // ENS name; forward lookup + resolver = await this.getResolver(nameOrAddress); + } + + const avatar = await resolver.getAvatar(); + if (avatar == null) { return null; } + + return avatar.url; + } + perform(method: string, params: any): Promise { return logger.throwError(method + " not implemented", Logger.errors.NOT_IMPLEMENTED, { operation: method }); } diff --git a/packages/tests/src.ts/test-providers.ts b/packages/tests/src.ts/test-providers.ts index b9d0acd029..595397a865 100644 --- a/packages/tests/src.ts/test-providers.ts +++ b/packages/tests/src.ts/test-providers.ts @@ -1366,3 +1366,30 @@ describe("Bad ENS resolution", function() { }); }); + +describe("Resolve ENS avatar", function() { + [ + { title: "data", name: "data-avatar.tests.eth", value: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAMAAACeL25MAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyVpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDYuMC1jMDAyIDc5LjE2NDQ4OCwgMjAyMC8wNy8xMC0yMjowNjo1MyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIDIyLjAgKE1hY2ludG9zaCkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NUQ4NTEyNUIyOEIwMTFFQzg0NTBDNTU2RDk1NTA5NzgiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NUQ4NTEyNUMyOEIwMTFFQzg0NTBDNTU2RDk1NTA5NzgiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo1RDg1MTI1OTI4QjAxMUVDODQ1MEM1NTZEOTU1MDk3OCIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo1RDg1MTI1QTI4QjAxMUVDODQ1MEM1NTZEOTU1MDk3OCIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PkbM0uMAAAAGUExURQAA/wAAAHtivz4AAAAOSURBVHjaYmDABAABBgAAFAABaEkyYwAAAABJRU5ErkJggg==" }, + { title: "ipfs", name: "ipfs-avatar.tests.eth", value: "https:/\/gateway.ipfs.io/ipfs/QmQsQgpda6JAYkFoeVcj5iPbwV3xRcvaiXv3bhp1VuYUqw" }, + { title: "url", name: "url-avatar.tests.eth", value: "https:/\/ethers.org/static/logo.png" }, + ].forEach((test) => { + it(`Resolves avatar for ${ test.title }`, async function() { + this.timeout(60000); + const provider = ethers.getDefaultProvider("ropsten", getApiKeys("ropsten")); + const avatar = await provider.getAvatar(test.name); + assert.equal(test.value, avatar, "avatar url"); + }); + }); + + [ + { title: "ERC-1155", name: "nick.eth", value: "https:/\/lh3.googleusercontent.com/hKHZTZSTmcznonu8I6xcVZio1IF76fq0XmcxnvUykC-FGuVJ75UPdLDlKJsfgVXH9wOSmkyHw0C39VAYtsGyxT7WNybjQ6s3fM3macE" }, + { title: "ERC-721", name: "brantly.eth", value: "https:/\/wrappedpunks.com:3000/images/punks/2430.png" }, + ].forEach((test) => { + it(`Resolves avatar for ${ test.title }`, async function() { + this.timeout(60000); + const provider = ethers.getDefaultProvider("homestead", getApiKeys("homestead")); + const avatar = await provider.getAvatar(test.name); + assert.equal(test.value, avatar, "avatar url"); + }); + }); +});