Skip to content

Commit ecce861

Browse files
committed
Added ENS avatar support to provider (#2185).
1 parent 5899c8a commit ecce861

File tree

2 files changed

+178
-11
lines changed

2 files changed

+178
-11
lines changed

packages/providers/src.ts/base-provider.ts

Lines changed: 151 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { Deferrable, defineReadOnly, getStatic, resolveProperties } from "@ether
1414
import { Transaction } from "@ethersproject/transactions";
1515
import { sha256 } from "@ethersproject/sha2";
1616
import { toUtf8Bytes, toUtf8String } from "@ethersproject/strings";
17-
import { poll } from "@ethersproject/web";
17+
import { fetchJson, poll } from "@ethersproject/web";
1818

1919
import bech32 from "bech32";
2020

@@ -237,32 +237,59 @@ function base58Encode(data: Uint8Array): string {
237237
return Base58.encode(concat([ data, hexDataSlice(sha256(sha256(data)), 0, 4) ]));
238238
}
239239

240+
export interface Avatar {
241+
url: string;
242+
linkage: Array<{ type: string, content: string }>;
243+
}
244+
245+
const matchers = [
246+
new RegExp("^(https):/\/(.*)$", "i"),
247+
new RegExp("^(data):(.*)$", "i"),
248+
new RegExp("^(ipfs):/\/(.*)$", "i"),
249+
new RegExp("^eip155:[0-9]+/(erc[0-9]+):(.*)$", "i"),
250+
];
251+
252+
function _parseString(result: string): null | string {
253+
try {
254+
return toUtf8String(_parseBytes(result));
255+
} catch(error) { }
256+
return null;
257+
}
258+
259+
function _parseBytes(result: string): null | string {
260+
if (result === "0x") { return null; }
261+
262+
const offset = BigNumber.from(hexDataSlice(result, 0, 32)).toNumber();
263+
const length = BigNumber.from(hexDataSlice(result, offset, offset + 32)).toNumber();
264+
return hexDataSlice(result, offset + 32, offset + 32 + length);
265+
}
266+
267+
240268
export class Resolver implements EnsResolver {
241269
readonly provider: BaseProvider;
242270

243271
readonly name: string;
244272
readonly address: string;
245273

246-
constructor(provider: BaseProvider, address: string, name: string) {
274+
readonly _resolvedAddress: null | string;
275+
276+
// The resolvedAddress is only for creating a ReverseLookup resolver
277+
constructor(provider: BaseProvider, address: string, name: string, resolvedAddress?: string) {
247278
defineReadOnly(this, "provider", provider);
248279
defineReadOnly(this, "name", name);
249280
defineReadOnly(this, "address", provider.formatter.address(address));
281+
defineReadOnly(this, "_resolvedAddress", resolvedAddress);
250282
}
251283

252-
async _fetchBytes(selector: string, parameters?: string): Promise<string> {
253-
// keccak256("addr(bytes32,uint256)")
254-
const transaction = {
284+
async _fetchBytes(selector: string, parameters?: string): Promise<null | string> {
285+
// e.g. keccak256("addr(bytes32,uint256)")
286+
const tx = {
255287
to: this.address,
256288
data: hexConcat([ selector, namehash(this.name), (parameters || "0x") ])
257289
};
258290

259291
try {
260-
const result = await this.provider.call(transaction);
261-
if (result === "0x") { return null; }
262-
263-
const offset = BigNumber.from(hexDataSlice(result, 0, 32)).toNumber();
264-
const length = BigNumber.from(hexDataSlice(result, offset, offset + 32)).toNumber();
265-
return hexDataSlice(result, offset + 32, offset + 32 + length);
292+
return _parseBytes(await this.provider.call(tx));
266293
} catch (error) {
267294
if (error.code === Logger.errors.CALL_EXCEPTION) { return null; }
268295
return null;
@@ -374,6 +401,95 @@ export class Resolver implements EnsResolver {
374401
return address;
375402
}
376403

404+
async getAvatar(): Promise<null | Avatar> {
405+
const linkage: Array<{ type: string, content: string }> = [ ];
406+
try {
407+
const avatar = await this.getText("avatar");
408+
if (avatar == null) { return null; }
409+
410+
for (let i = 0; i < matchers.length; i++) {
411+
const match = avatar.match(matchers[i]);
412+
413+
if (match == null) { continue; }
414+
switch (match[1]) {
415+
case "https":
416+
linkage.push({ type: "url", content: avatar });
417+
return { linkage, url: avatar };
418+
419+
case "data":
420+
linkage.push({ type: "data", content: avatar });
421+
return { linkage, url: avatar };
422+
423+
case "ipfs":
424+
linkage.push({ type: "ipfs", content: avatar });
425+
return { linkage, url: `https:/\/gateway.ipfs.io/ipfs/${ avatar.substring(7) }` }
426+
427+
case "erc721":
428+
case "erc1155": {
429+
// Depending on the ERC type, use tokenURI(uint256) or url(uint256)
430+
const selector = (match[1] === "erc721") ? "0xc87b56dd": "0x0e89341c";
431+
linkage.push({ type: match[1], content: avatar });
432+
433+
// The owner of this name
434+
const owner = (this._resolvedAddress || await this.getAddress());
435+
436+
const comps = (match[2] || "").split("/");
437+
if (comps.length !== 2) { return null; }
438+
439+
const addr = await this.provider.formatter.address(comps[0]);
440+
const tokenId = hexZeroPad(BigNumber.from(comps[1]).toHexString(), 32);
441+
442+
// Check that this account owns the token
443+
if (match[1] === "erc721") {
444+
// ownerOf(uint256 tokenId)
445+
const tokenOwner = this.provider.formatter.callAddress(await this.provider.call({
446+
to: addr, data: hexConcat([ "0x6352211e", tokenId ])
447+
}));
448+
if (owner !== tokenOwner) { return null; }
449+
linkage.push({ type: "owner", content: tokenOwner });
450+
451+
} else if (match[1] === "erc1155") {
452+
// balanceOf(address owner, uint256 tokenId)
453+
const balance = BigNumber.from(await this.provider.call({
454+
to: addr, data: hexConcat([ "0x00fdd58e", hexZeroPad(owner, 32), tokenId ])
455+
}));
456+
if (balance.isZero()) { return null; }
457+
linkage.push({ type: "balance", content: balance.toString() });
458+
}
459+
460+
// Call the token contract for the metadata URL
461+
const tx = {
462+
to: this.provider.formatter.address(comps[0]),
463+
data: hexConcat([ selector, tokenId ])
464+
};
465+
let metadataUrl = _parseString(await this.provider.call(tx))
466+
if (metadataUrl == null) { return null; }
467+
linkage.push({ type: "metadata-url", content: metadataUrl });
468+
469+
// ERC-1155 allows a generic {id} in the URL
470+
if (match[1] === "erc1155") {
471+
metadataUrl = metadataUrl.replace("{id}", tokenId.substring(2));
472+
}
473+
474+
// Get the token metadata
475+
const metadata = await fetchJson(metadataUrl);
476+
477+
// Pull the image URL out
478+
if (!metadata || typeof(metadata.image) !== "string" || !metadata.image.match(/^https:\/\//i)) {
479+
return null;
480+
}
481+
linkage.push({ type: "metadata", content: JSON.stringify(metadata) });
482+
linkage.push({ type: "url", content: metadata.image });
483+
484+
return { linkage, url: metadata.image };
485+
}
486+
}
487+
}
488+
} catch (error) { }
489+
490+
return null;
491+
}
492+
377493
async getContentHash(): Promise<string> {
378494

379495
// keccak256("contenthash()")
@@ -1615,6 +1731,30 @@ export class BaseProvider extends Provider implements EnsProvider {
16151731
return name;
16161732
}
16171733

1734+
async getAvatar(nameOrAddress: string): Promise<null | string> {
1735+
let resolver: Resolver = null;
1736+
if (isHexString(nameOrAddress)) {
1737+
// Address; reverse lookup
1738+
const address = this.formatter.address(nameOrAddress);
1739+
1740+
const reverseName = address.substring(2).toLowerCase() + ".addr.reverse";
1741+
1742+
const resolverAddress = await this._getResolver(reverseName);
1743+
if (!resolverAddress) { return null; }
1744+
1745+
resolver = new Resolver(this, resolverAddress, "_", address);
1746+
1747+
} else {
1748+
// ENS name; forward lookup
1749+
resolver = await this.getResolver(nameOrAddress);
1750+
}
1751+
1752+
const avatar = await resolver.getAvatar();
1753+
if (avatar == null) { return null; }
1754+
1755+
return avatar.url;
1756+
}
1757+
16181758
perform(method: string, params: any): Promise<any> {
16191759
return logger.throwError(method + " not implemented", Logger.errors.NOT_IMPLEMENTED, { operation: method });
16201760
}

packages/tests/src.ts/test-providers.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1366,3 +1366,30 @@ describe("Bad ENS resolution", function() {
13661366
});
13671367

13681368
});
1369+
1370+
describe("Resolve ENS avatar", function() {
1371+
[
1372+
{ title: "data", name: "data-avatar.tests.eth", value: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAMAAACeL25MAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyVpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDYuMC1jMDAyIDc5LjE2NDQ4OCwgMjAyMC8wNy8xMC0yMjowNjo1MyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIDIyLjAgKE1hY2ludG9zaCkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NUQ4NTEyNUIyOEIwMTFFQzg0NTBDNTU2RDk1NTA5NzgiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NUQ4NTEyNUMyOEIwMTFFQzg0NTBDNTU2RDk1NTA5NzgiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo1RDg1MTI1OTI4QjAxMUVDODQ1MEM1NTZEOTU1MDk3OCIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo1RDg1MTI1QTI4QjAxMUVDODQ1MEM1NTZEOTU1MDk3OCIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PkbM0uMAAAAGUExURQAA/wAAAHtivz4AAAAOSURBVHjaYmDABAABBgAAFAABaEkyYwAAAABJRU5ErkJggg==" },
1373+
{ title: "ipfs", name: "ipfs-avatar.tests.eth", value: "https:/\/gateway.ipfs.io/ipfs/QmQsQgpda6JAYkFoeVcj5iPbwV3xRcvaiXv3bhp1VuYUqw" },
1374+
{ title: "url", name: "url-avatar.tests.eth", value: "https:/\/ethers.org/static/logo.png" },
1375+
].forEach((test) => {
1376+
it(`Resolves avatar for ${ test.title }`, async function() {
1377+
this.timeout(60000);
1378+
const provider = ethers.getDefaultProvider("ropsten", getApiKeys("ropsten"));
1379+
const avatar = await provider.getAvatar(test.name);
1380+
assert.equal(test.value, avatar, "avatar url");
1381+
});
1382+
});
1383+
1384+
[
1385+
{ title: "ERC-1155", name: "nick.eth", value: "https:/\/lh3.googleusercontent.com/hKHZTZSTmcznonu8I6xcVZio1IF76fq0XmcxnvUykC-FGuVJ75UPdLDlKJsfgVXH9wOSmkyHw0C39VAYtsGyxT7WNybjQ6s3fM3macE" },
1386+
{ title: "ERC-721", name: "brantly.eth", value: "https:/\/wrappedpunks.com:3000/images/punks/2430.png" },
1387+
].forEach((test) => {
1388+
it(`Resolves avatar for ${ test.title }`, async function() {
1389+
this.timeout(60000);
1390+
const provider = ethers.getDefaultProvider("homestead", getApiKeys("homestead"));
1391+
const avatar = await provider.getAvatar(test.name);
1392+
assert.equal(test.value, avatar, "avatar url");
1393+
});
1394+
});
1395+
});

0 commit comments

Comments
 (0)