123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606 |
- /**
- * ENS is a service which allows easy-to-remember names to map to
- * network addresses.
- *
- * @_section: api/providers/ens-resolver:ENS Resolver [about-ens-rsolver]
- */
- import { getAddress } from "../address/index.js";
- import { ZeroAddress } from "../constants/index.js";
- import { Contract } from "../contract/index.js";
- import { dnsEncode, namehash } from "../hash/index.js";
- import {
- hexlify, isHexString, toBeHex,
- defineProperties, encodeBase58,
- assert, assertArgument, isError,
- FetchRequest
- } from "../utils/index.js";
- import type { FunctionFragment } from "../abi/index.js";
- import type { BytesLike } from "../utils/index.js";
- import type { AbstractProvider, AbstractProviderPlugin } from "./abstract-provider.js";
- import type { EnsPlugin } from "./plugins-network.js";
- import type { Provider } from "./provider.js";
- // @TODO: This should use the fetch-data:ipfs gateway
- // Trim off the ipfs:// prefix and return the default gateway URL
- function getIpfsLink(link: string): string {
- if (link.match(/^ipfs:\/\/ipfs\//i)) {
- link = link.substring(12);
- } else if (link.match(/^ipfs:\/\//i)) {
- link = link.substring(7);
- } else {
- assertArgument(false, "unsupported IPFS format", "link", link);
- }
- return `https:/\/gateway.ipfs.io/ipfs/${ link }`;
- }
- /**
- * The type of data found during a steip during avatar resolution.
- */
- export type AvatarLinkageType = "name" | "avatar" | "!avatar" | "url" | "data" | "ipfs" |
- "erc721" | "erc1155" | "!erc721-caip" | "!erc1155-caip" |
- "!owner" | "owner" | "!balance" | "balance" |
- "metadata-url-base" | "metadata-url-expanded" | "metadata-url" | "!metadata-url" |
- "!metadata" | "metadata" |
- "!imageUrl" | "imageUrl-ipfs" | "imageUrl" | "!imageUrl-ipfs";
- /**
- * An individual record for each step during avatar resolution.
- */
- export interface AvatarLinkage {
- /**
- * The type of linkage.
- */
- type: AvatarLinkageType;
- /**
- * The linkage value.
- */
- value: string;
- };
- /**
- * When resolving an avatar for an ENS name, there are many
- * steps involved, fetching metadata, validating results, et cetera.
- *
- * Some applications may wish to analyse this data, or use this data
- * to diagnose promblems, so an **AvatarResult** provides details of
- * each completed step during avatar resolution.
- */
- export interface AvatarResult {
- /**
- * How the [[url]] was arrived at, resolving the many steps required
- * for an avatar URL.
- */
- linkage: Array<AvatarLinkage>;
- /**
- * The avatar URL or null if the avatar was not set, or there was
- * an issue during validation (such as the address not owning the
- * avatar or a metadata error).
- */
- url: null | string;
- };
- /**
- * A provider plugin super-class for processing multicoin address types.
- */
- export abstract class MulticoinProviderPlugin implements AbstractProviderPlugin {
- /**
- * The name.
- */
- readonly name!: string;
- /**
- * Creates a new **MulticoinProviderPluing** for %%name%%.
- */
- constructor(name: string) {
- defineProperties<MulticoinProviderPlugin>(this, { name });
- }
- connect(proivder: Provider): MulticoinProviderPlugin {
- return this;
- }
- /**
- * Returns ``true`` if %%coinType%% is supported by this plugin.
- */
- supportsCoinType(coinType: number): boolean {
- return false;
- }
- /**
- * Resolves to the encoded %%address%% for %%coinType%%.
- */
- async encodeAddress(coinType: number, address: string): Promise<string> {
- throw new Error("unsupported coin");
- }
- /**
- * Resolves to the decoded %%data%% for %%coinType%%.
- */
- async decodeAddress(coinType: number, data: BytesLike): Promise<string> {
- throw new Error("unsupported coin");
- }
- }
- const BasicMulticoinPluginId = "org.ethers.plugins.provider.BasicMulticoin";
- /**
- * A **BasicMulticoinProviderPlugin** provides service for common
- * coin types, which do not require additional libraries to encode or
- * decode.
- */
- export class BasicMulticoinProviderPlugin extends MulticoinProviderPlugin {
- /**
- * Creates a new **BasicMulticoinProviderPlugin**.
- */
- constructor() {
- super(BasicMulticoinPluginId);
- }
- }
- const matcherIpfs = new RegExp("^(ipfs):/\/(.*)$", "i");
- const matchers = [
- new RegExp("^(https):/\/(.*)$", "i"),
- new RegExp("^(data):(.*)$", "i"),
- matcherIpfs,
- new RegExp("^eip155:[0-9]+/(erc[0-9]+):(.*)$", "i"),
- ];
- /**
- * A connected object to a resolved ENS name resolver, which can be
- * used to query additional details.
- */
- export class EnsResolver {
- /**
- * The connected provider.
- */
- provider!: AbstractProvider;
- /**
- * The address of the resolver.
- */
- address!: string;
- /**
- * The name this resolver was resolved against.
- */
- name!: string;
- // For EIP-2544 names, the ancestor that provided the resolver
- #supports2544: null | Promise<boolean>;
- #resolver: Contract;
- constructor(provider: AbstractProvider, address: string, name: string) {
- defineProperties<EnsResolver>(this, { provider, address, name });
- this.#supports2544 = null;
- this.#resolver = new Contract(address, [
- "function supportsInterface(bytes4) view returns (bool)",
- "function resolve(bytes, bytes) view returns (bytes)",
- "function addr(bytes32) view returns (address)",
- "function addr(bytes32, uint) view returns (bytes)",
- "function text(bytes32, string) view returns (string)",
- "function contenthash(bytes32) view returns (bytes)",
- ], provider);
- }
- /**
- * Resolves to true if the resolver supports wildcard resolution.
- */
- async supportsWildcard(): Promise<boolean> {
- if (this.#supports2544 == null) {
- this.#supports2544 = (async () => {
- try {
- return await this.#resolver.supportsInterface("0x9061b923");
- } catch (error) {
- // Wildcard resolvers must understand supportsInterface
- // and return true.
- if (isError(error, "CALL_EXCEPTION")) { return false; }
- // Let future attempts try again...
- this.#supports2544 = null;
- throw error;
- }
- })();
- }
- return await this.#supports2544;
- }
- async #fetch(funcName: string, params?: Array<any>): Promise<null | any> {
- params = (params || []).slice();
- const iface = this.#resolver.interface;
- // The first parameters is always the nodehash
- params.unshift(namehash(this.name))
- let fragment: null | FunctionFragment = null;
- if (await this.supportsWildcard()) {
- fragment = iface.getFunction(funcName);
- assert(fragment, "missing fragment", "UNKNOWN_ERROR", {
- info: { funcName }
- });
- params = [
- dnsEncode(this.name, 255),
- iface.encodeFunctionData(fragment, params)
- ];
- funcName = "resolve(bytes,bytes)";
- }
- params.push({
- enableCcipRead: true
- });
- try {
- const result = await this.#resolver[funcName](...params);
- if (fragment) {
- return iface.decodeFunctionResult(fragment, result)[0];
- }
- return result;
- } catch (error: any) {
- if (!isError(error, "CALL_EXCEPTION")) { throw error; }
- }
- return null;
- }
- /**
- * Resolves to the address for %%coinType%% or null if the
- * provided %%coinType%% has not been configured.
- */
- async getAddress(coinType?: number): Promise<null | string> {
- if (coinType == null) { coinType = 60; }
- if (coinType === 60) {
- try {
- const result = await this.#fetch("addr(bytes32)");
- // No address
- if (result == null || result === ZeroAddress) { return null; }
- return result;
- } catch (error: any) {
- if (isError(error, "CALL_EXCEPTION")) { return null; }
- throw error;
- }
- }
- // Try decoding its EVM canonical chain as an EVM chain address first
- if (coinType >= 0 && coinType < 0x80000000) {
- let ethCoinType = coinType + 0x80000000;
- const data = await this.#fetch("addr(bytes32,uint)", [ ethCoinType ]);
- if (isHexString(data, 20)) { return getAddress(data); }
- }
- let coinPlugin: null | MulticoinProviderPlugin = null;
- for (const plugin of this.provider.plugins) {
- if (!(plugin instanceof MulticoinProviderPlugin)) { continue; }
- if (plugin.supportsCoinType(coinType)) {
- coinPlugin = plugin;
- break;
- }
- }
- if (coinPlugin == null) { return null; }
- // keccak256("addr(bytes32,uint256")
- const data = await this.#fetch("addr(bytes32,uint)", [ coinType ]);
- // No address
- if (data == null || data === "0x") { return null; }
- // Compute the address
- const address = await coinPlugin.decodeAddress(coinType, data);
- if (address != null) { return address; }
- assert(false, `invalid coin data`, "UNSUPPORTED_OPERATION", {
- operation: `getAddress(${ coinType })`,
- info: { coinType, data }
- });
- }
- /**
- * Resolves to the EIP-634 text record for %%key%%, or ``null``
- * if unconfigured.
- */
- async getText(key: string): Promise<null | string> {
- const data = await this.#fetch("text(bytes32,string)", [ key ]);
- if (data == null || data === "0x") { return null; }
- return data;
- }
- /**
- * Rsolves to the content-hash or ``null`` if unconfigured.
- */
- async getContentHash(): Promise<null | string> {
- // keccak256("contenthash()")
- const data = await this.#fetch("contenthash(bytes32)");
- // No contenthash
- if (data == null || data === "0x") { return null; }
- // IPFS (CID: 1, Type: 70=DAG-PB, 72=libp2p-key)
- const ipfs = data.match(/^0x(e3010170|e5010172)(([0-9a-f][0-9a-f])([0-9a-f][0-9a-f])([0-9a-f]*))$/);
- if (ipfs) {
- const scheme = (ipfs[1] === "e3010170") ? "ipfs": "ipns";
- const length = parseInt(ipfs[4], 16);
- if (ipfs[5].length === length * 2) {
- return `${ scheme }:/\/${ encodeBase58("0x" + ipfs[2])}`;
- }
- }
- // Swarm (CID: 1, Type: swarm-manifest; hash/length hard-coded to keccak256/32)
- const swarm = data.match(/^0xe40101fa011b20([0-9a-f]*)$/)
- if (swarm && swarm[1].length === 64) {
- return `bzz:/\/${ swarm[1] }`;
- }
- assert(false, `invalid or unsupported content hash data`, "UNSUPPORTED_OPERATION", {
- operation: "getContentHash()",
- info: { data }
- });
- }
- /**
- * Resolves to the avatar url or ``null`` if the avatar is either
- * unconfigured or incorrectly configured (e.g. references an NFT
- * not owned by the address).
- *
- * If diagnosing issues with configurations, the [[_getAvatar]]
- * method may be useful.
- */
- async getAvatar(): Promise<null | string> {
- const avatar = await this._getAvatar();
- return avatar.url;
- }
- /**
- * When resolving an avatar, there are many steps involved, such
- * fetching metadata and possibly validating ownership of an
- * NFT.
- *
- * This method can be used to examine each step and the value it
- * was working from.
- */
- async _getAvatar(): Promise<AvatarResult> {
- const linkage: Array<AvatarLinkage> = [ { type: "name", value: this.name } ];
- try {
- // test data for ricmoo.eth
- //const avatar = "eip155:1/erc721:0x265385c7f4132228A0d54EB1A9e7460b91c0cC68/29233";
- const avatar = await this.getText("avatar");
- if (avatar == null) {
- linkage.push({ type: "!avatar", value: "" });
- return { url: null, linkage };
- }
- linkage.push({ type: "avatar", value: avatar });
- for (let i = 0; i < matchers.length; i++) {
- const match = avatar.match(matchers[i]);
- if (match == null) { continue; }
- const scheme = match[1].toLowerCase();
- switch (scheme) {
- case "https":
- case "data":
- linkage.push({ type: "url", value: avatar });
- return { linkage, url: avatar };
- case "ipfs": {
- const url = getIpfsLink(avatar);
- linkage.push({ type: "ipfs", value: avatar });
- linkage.push({ type: "url", value: url });
- return { linkage, url };
- }
- case "erc721":
- case "erc1155": {
- // Depending on the ERC type, use tokenURI(uint256) or url(uint256)
- const selector = (scheme === "erc721") ? "tokenURI(uint256)": "uri(uint256)";
- linkage.push({ type: scheme, value: avatar });
- // The owner of this name
- const owner = await this.getAddress();
- if (owner == null) {
- linkage.push({ type: "!owner", value: "" });
- return { url: null, linkage };
- }
- const comps = (match[2] || "").split("/");
- if (comps.length !== 2) {
- linkage.push({ type: <any>`!${ scheme }caip`, value: (match[2] || "") });
- return { url: null, linkage };
- }
- const tokenId = comps[1];
- const contract = new Contract(comps[0], [
- // ERC-721
- "function tokenURI(uint) view returns (string)",
- "function ownerOf(uint) view returns (address)",
- // ERC-1155
- "function uri(uint) view returns (string)",
- "function balanceOf(address, uint256) view returns (uint)"
- ], this.provider);
- // Check that this account owns the token
- if (scheme === "erc721") {
- const tokenOwner = await contract.ownerOf(tokenId);
- if (owner !== tokenOwner) {
- linkage.push({ type: "!owner", value: tokenOwner });
- return { url: null, linkage };
- }
- linkage.push({ type: "owner", value: tokenOwner });
- } else if (scheme === "erc1155") {
- const balance = await contract.balanceOf(owner, tokenId);
- if (!balance) {
- linkage.push({ type: "!balance", value: "0" });
- return { url: null, linkage };
- }
- linkage.push({ type: "balance", value: balance.toString() });
- }
- // Call the token contract for the metadata URL
- let metadataUrl = await contract[selector](tokenId);
- if (metadataUrl == null || metadataUrl === "0x") {
- linkage.push({ type: "!metadata-url", value: "" });
- return { url: null, linkage };
- }
- linkage.push({ type: "metadata-url-base", value: metadataUrl });
- // ERC-1155 allows a generic {id} in the URL
- if (scheme === "erc1155") {
- metadataUrl = metadataUrl.replace("{id}", toBeHex(tokenId, 32).substring(2));
- linkage.push({ type: "metadata-url-expanded", value: metadataUrl });
- }
- // Transform IPFS metadata links
- if (metadataUrl.match(/^ipfs:/i)) {
- metadataUrl = getIpfsLink(metadataUrl);
- }
- linkage.push({ type: "metadata-url", value: metadataUrl });
- // Get the token metadata
- let metadata: any = { };
- const response = await (new FetchRequest(metadataUrl)).send();
- response.assertOk();
- try {
- metadata = response.bodyJson;
- } catch (error) {
- try {
- linkage.push({ type: "!metadata", value: response.bodyText });
- } catch (error) {
- const bytes = response.body;
- if (bytes) {
- linkage.push({ type: "!metadata", value: hexlify(bytes) });
- }
- return { url: null, linkage };
- }
- return { url: null, linkage };
- }
- if (!metadata) {
- linkage.push({ type: "!metadata", value: "" });
- return { url: null, linkage };
- }
- linkage.push({ type: "metadata", value: JSON.stringify(metadata) });
- // Pull the image URL out
- let imageUrl = metadata.image;
- if (typeof(imageUrl) !== "string") {
- linkage.push({ type: "!imageUrl", value: "" });
- return { url: null, linkage };
- }
- if (imageUrl.match(/^(https:\/\/|data:)/i)) {
- // Allow
- } else {
- // Transform IPFS link to gateway
- const ipfs = imageUrl.match(matcherIpfs);
- if (ipfs == null) {
- linkage.push({ type: "!imageUrl-ipfs", value: imageUrl });
- return { url: null, linkage };
- }
- linkage.push({ type: "imageUrl-ipfs", value: imageUrl });
- imageUrl = getIpfsLink(imageUrl);
- }
- linkage.push({ type: "url", value: imageUrl });
- return { linkage, url: imageUrl };
- }
- }
- }
- } catch (error) { }
- return { linkage, url: null };
- }
- static async getEnsAddress(provider: Provider): Promise<string> {
- const network = await provider.getNetwork();
- const ensPlugin = network.getPlugin<EnsPlugin>("org.ethers.plugins.network.Ens");
- // No ENS...
- assert(ensPlugin, "network does not support ENS", "UNSUPPORTED_OPERATION", {
- operation: "getEnsAddress", info: { network } });
- return ensPlugin.address;
- }
- static async #getResolver(provider: Provider, name: string): Promise<null | string> {
- const ensAddr = await EnsResolver.getEnsAddress(provider);
- try {
- const contract = new Contract(ensAddr, [
- "function resolver(bytes32) view returns (address)"
- ], provider);
- const addr = await contract.resolver(namehash(name), {
- enableCcipRead: true
- });
- if (addr === ZeroAddress) { return null; }
- return addr;
- } catch (error) {
- // ENS registry cannot throw errors on resolver(bytes32),
- // so probably a link error
- throw error;
- }
- return null;
- }
- /**
- * Resolve to the ENS resolver for %%name%% using %%provider%% or
- * ``null`` if unconfigured.
- */
- static async fromName(provider: AbstractProvider, name: string): Promise<null | EnsResolver> {
- let currentName = name;
- while (true) {
- if (currentName === "" || currentName === ".") { return null; }
- // Optimization since the eth node cannot change and does
- // not have a wildcard resolver
- if (name !== "eth" && currentName === "eth") { return null; }
- // Check the current node for a resolver
- const addr = await EnsResolver.#getResolver(provider, currentName);
- // Found a resolver!
- if (addr != null) {
- const resolver = new EnsResolver(provider, addr, name);
- // Legacy resolver found, using EIP-2544 so it isn't safe to use
- if (currentName !== name && !(await resolver.supportsWildcard())) { return null; }
- return resolver;
- }
- // Get the parent node
- currentName = currentName.split(".").slice(1).join(".");
- }
- }
- }
|