From 83db8a6bd1364458dcfeea544de707df41890b4e Mon Sep 17 00:00:00 2001 From: Richard Moore Date: Fri, 4 Sep 2020 01:18:57 -0400 Subject: [PATCH] Added basic ENS resolver functions for contenthash, text and multi-coin addresses (#1003). --- packages/providers/package.json | 3 + packages/providers/src.ts/base-provider.ts | 263 ++++++++++++++++++++- packages/providers/src.ts/index.ts | 9 +- 3 files changed, 261 insertions(+), 14 deletions(-) diff --git a/packages/providers/package.json b/packages/providers/package.json index c4dd8ed4..d822d1e1 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -19,6 +19,7 @@ "@ethersproject/abstract-provider": "^5.0.3", "@ethersproject/abstract-signer": "^5.0.3", "@ethersproject/address": "^5.0.3", + "@ethersproject/basex": "^5.0.3", "@ethersproject/bignumber": "^5.0.6", "@ethersproject/bytes": "^5.0.4", "@ethersproject/constants": "^5.0.3", @@ -28,9 +29,11 @@ "@ethersproject/properties": "^5.0.3", "@ethersproject/random": "^5.0.3", "@ethersproject/rlp": "^5.0.3", + "@ethersproject/sha2": "^5.0.3", "@ethersproject/strings": "^5.0.3", "@ethersproject/transactions": "^5.0.3", "@ethersproject/web": "^5.0.4", + "bech32": "1.1.4", "ws": "7.2.3" }, "description": "Ethereum Providers for ethers.", diff --git a/packages/providers/src.ts/base-provider.ts b/packages/providers/src.ts/base-provider.ts index 0af4bef0..502af7a4 100644 --- a/packages/providers/src.ts/base-provider.ts +++ b/packages/providers/src.ts/base-provider.ts @@ -4,15 +4,20 @@ import { Block, BlockTag, BlockWithTransactions, EventType, Filter, FilterByBlockHash, ForkEvent, Listener, Log, Provider, TransactionReceipt, TransactionRequest, TransactionResponse } from "@ethersproject/abstract-provider"; +import { Base58 } from "@ethersproject/basex"; import { BigNumber, BigNumberish } from "@ethersproject/bignumber"; -import { arrayify, hexDataLength, hexlify, hexValue, isHexString } from "@ethersproject/bytes"; +import { arrayify, concat, hexConcat, hexDataLength, hexDataSlice, hexlify, hexValue, hexZeroPad, isHexString } from "@ethersproject/bytes"; +import { HashZero } from "@ethersproject/constants"; import { namehash } from "@ethersproject/hash"; import { getNetwork, Network, Networkish } from "@ethersproject/networks"; import { Deferrable, defineReadOnly, getStatic, resolveProperties } from "@ethersproject/properties"; import { Transaction } from "@ethersproject/transactions"; -import { toUtf8String } from "@ethersproject/strings"; +import { sha256 } from "@ethersproject/sha2"; +import { toUtf8Bytes, toUtf8String } from "@ethersproject/strings"; import { poll } from "@ethersproject/web"; +import { encode, toWords } from "bech32"; + import { Logger } from "@ethersproject/logger"; import { version } from "./_version"; const logger = new Logger(version); @@ -179,12 +184,246 @@ export class Event { } } +export interface EnsResolver { + + // Name this Resolver is associated with + readonly name: string; + + // The address of the resolver + readonly address: string; + + // Multichain address resolution (also normal address resolution) + // See: https://eips.ethereum.org/EIPS/eip-2304 + getAddress(coinType?: 60): Promise + + // Contenthash field + // See: https://eips.ethereum.org/EIPS/eip-1577 + getContentHash(): Promise; + + // Storage of text records + // See: https://eips.ethereum.org/EIPS/eip-634 + getText(key: string): Promise; +}; + +export interface EnsProvider { + resolveName(name: string): Promise; + lookupAddress(address: string): Promise; + getResolver(name: string): Promise; +} + +type CoinInfo = { + symbol: string, + ilk?: string, // General family + prefix?: string, // Bech32 prefix + p2pkh?: number, // Pay-to-Public-Key-Hash Version + p2sh?: number, // Pay-to-Script-Hash Version +}; + +// https://github.com/satoshilabs/slips/blob/master/slip-0044.md +const coinInfos: { [ coinType: string ]: CoinInfo } = { + "0": { symbol: "btc", p2pkh: 0x00, p2sh: 0x05, prefix: "bc" }, + "2": { symbol: "ltc", p2pkh: 0x30, p2sh: 0x32, prefix: "ltc" }, + "3": { symbol: "doge", p2pkh: 0x1e, p2sh: 0x16 }, + "60": { symbol: "eth", ilk: "eth" }, + "61": { symbol: "etc", ilk: "eth" }, + "700": { symbol: "xdai", ilk: "eth" }, +}; + +function bytes32ify(value: number): string { + return hexZeroPad(BigNumber.from(value).toHexString(), 32); +} + +// Compute the Base58Check encoded data (checksum is first 4 bytes of sha256d) +function base58Encode(data: Uint8Array): string { + return Base58.encode(concat([ data, hexDataSlice(sha256(sha256(data)), 0, 4) ])); +} + +export class Resolver implements EnsResolver { + readonly provider: BaseProvider; + + readonly name: string; + readonly address: string; + + constructor(provider: BaseProvider, address: string, name: string) { + defineReadOnly(this, "provider", provider); + defineReadOnly(this, "name", name); + defineReadOnly(this, "address", provider.formatter.address(address)); + } + + async _fetchBytes(selector: string, parameters?: string): Promise { + + // keccak256("addr(bytes32,uint256)") + const transaction = { + to: this.address, + data: hexConcat([ selector, namehash(this.name), (parameters || "0x") ]) + }; + + 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); + } + + _getAddress(coinType: number, hexBytes: string): string { + const coinInfo = coinInfos[String(coinType)]; + + if (coinInfo == null) { + logger.throwError(`unsupported coin type: ${ coinType }`, Logger.errors.UNSUPPORTED_OPERATION, { + operation: `getAddress(${ coinType })` + }); + } + + if (coinInfo.ilk === "eth") { + return this.provider.formatter.address(hexBytes); + } + + const bytes = arrayify(hexBytes); + + // P2PKH: OP_DUP OP_HASH160 OP_EQUALVERIFY OP_CHECKSIG + if (coinInfo.p2pkh != null) { + const p2pkh = hexBytes.match(/^0x76a9([0-9a-f][0-9a-f])([0-9a-f]*)88ac$/); + if (p2pkh) { + const length = parseInt(p2pkh[1], 16); + if (p2pkh[2].length === length * 2 && length >= 1 && length <= 75) { + return base58Encode(concat([ [ coinInfo.p2pkh ], ("0x" + p2pkh[2]) ])); + } + } + } + + // P2SH: OP_HASH160 OP_EQUAL + if (coinInfo.p2sh != null) { + const p2sh = hexBytes.match(/^0xa9([0-9a-f][0-9a-f])([0-9a-f]*)87$/); + if (p2sh) { + const length = parseInt(p2sh[1], 16); + if (p2sh[2].length === length * 2 && length >= 1 && length <= 75) { + return base58Encode(concat([ [ coinInfo.p2sh ], ("0x" + p2sh[2]) ])); + } + } + } + + // Bech32 + if (coinInfo.prefix != null) { + const length = bytes[1]; + + // https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki#witness-program + let version = bytes[0]; + if (version === 0x00) { + if (length !== 20 && length !== 32) { + version = -1; + } + } else { + version = -1; + } + + if (version >= 0 && bytes.length === 2 + length && length >= 1 && length <= 75) { + const words = toWords(bytes.slice(2)); + words.unshift(version); + return encode(coinInfo.prefix, words); + } + } + + return null; + } + + + async getAddress(coinType?: number): Promise { + if (coinType == null) { coinType = 60; } + + // If Ethereum, use the standard `addr(bytes32)` + if (coinType === 60) { + // keccak256("addr(bytes32)") + const transaction = { + to: this.address, + data: ("0x3b3b57de" + namehash(this.name).substring(2)) + }; + const hexBytes = await this.provider.call(transaction); + + // No address + if (hexBytes === "0x" || hexBytes === HashZero) { return null; } + + return this.provider.formatter.callAddress(hexBytes); + } + + // keccak256("addr(bytes32,uint256") + const hexBytes = await this._fetchBytes("0xf1cb7e06", bytes32ify(coinType)); + + // No address + if (hexBytes == null || hexBytes === "0x") { return null; } + + // Compute the address + const address = this._getAddress(coinType, hexBytes); + + if (address == null) { + logger.throwError(`invalid or unsupported coin data`, Logger.errors.UNSUPPORTED_OPERATION, { + operation: `getAddress(${ coinType })`, + coinType: coinType, + data: hexBytes + }); + } + + return address; + } + + async getContentHash(): Promise { + + // keccak256("contenthash()") + const hexBytes = await this._fetchBytes("0xbc1c58d1"); + + // No contenthash + if (hexBytes == null || hexBytes === "0x") { return null; } + + // IPFS (CID: 1, Type: DAG-PB) + const ipfs = hexBytes.match(/^0xe3010170(([0-9a-f][0-9a-f])([0-9a-f][0-9a-f])([0-9a-f]*))$/); + if (ipfs) { + const length = parseInt(ipfs[3], 16); + if (ipfs[4].length === length * 2) { + return "ipfs:/\/" + Base58.encode("0x" + ipfs[1]); + } + } + + // Swarm (CID: 1, Type: swarm-manifest; hash/length hard-coded to keccak256/32) + const swarm = hexBytes.match(/^0xe40101fa011b20([0-9a-f]*)$/) + if (swarm) { + if (swarm[1].length === (32 * 2)) { + return "bzz:/\/" + swarm[1] + } + } + + return logger.throwError(`invalid or unsupported content hash data`, Logger.errors.UNSUPPORTED_OPERATION, { + operation: "getContentHash()", + data: hexBytes + }); + } + + async getText(key: string): Promise { + + // The key encoded as parameter to fetchBytes + let keyBytes = toUtf8Bytes(key); + + // The nodehash consumes the first slot, so the string pointer targets + // offset 64, with the length at offset 64 and data starting at offset 96 + keyBytes = concat([ bytes32ify(64), bytes32ify(keyBytes.length), keyBytes ]); + + // Pad to word-size (32 bytes) + if ((keyBytes.length % 32) !== 0) { + keyBytes = concat([ keyBytes, hexZeroPad("0x", 32 - (key.length % 32)) ]) + } + + const hexBytes = await this._fetchBytes("0x59d1d43c", hexlify(keyBytes)); + if (hexBytes == null || hexBytes === "0x") { return null; } + + return toUtf8String(hexBytes); + } +} + let defaultFormatter: Formatter = null; let nextPollId = 1; -export class BaseProvider extends Provider { +export class BaseProvider extends Provider implements EnsProvider { _networkPromise: Promise; _network: Network; @@ -1046,6 +1285,12 @@ export class BaseProvider extends Provider { } + async getResolver(name: string): Promise { + const address = await this._getResolver(name); + if (address == null) { return null; } + return new Resolver(this, address, name); + } + async _getResolver(name: string): Promise { // Get the resolver from the blockchain const network = await this.getNetwork(); @@ -1084,16 +1329,10 @@ export class BaseProvider extends Provider { } // Get the addr from the resovler - const resolverAddress = await this._getResolver(name); - if (!resolverAddress) { return null; } + const resolver = await this.getResolver(name); + if (!resolver) { return null; } - // keccak256("addr(bytes32)") - const transaction = { - to: resolverAddress, - data: ("0x3b3b57de" + namehash(name).substring(2)) - }; - - return this.formatter.callAddress(await this.call(transaction)); + return await resolver.getAddress(); } async lookupAddress(address: string | Promise): Promise { diff --git a/packages/providers/src.ts/index.ts b/packages/providers/src.ts/index.ts index 3c4c9142..4a997374 100644 --- a/packages/providers/src.ts/index.ts +++ b/packages/providers/src.ts/index.ts @@ -16,7 +16,7 @@ import { import { getNetwork } from "@ethersproject/networks"; import { Network, Networkish } from "@ethersproject/networks"; -import { BaseProvider } from "./base-provider"; +import { BaseProvider, EnsProvider, EnsResolver, Resolver } from "./base-provider"; import { AlchemyProvider } from "./alchemy-provider"; import { CloudflareProvider } from "./cloudflare-provider"; @@ -93,6 +93,8 @@ export { Provider, BaseProvider, + Resolver, + UrlJsonRpcProvider, /////////////////////// @@ -149,6 +151,9 @@ export { JsonRpcFetchFunc, Network, - Networkish + Networkish, + + EnsProvider, + EnsResolver };