diff --git a/src.ts/providers/abstract-provider.ts b/src.ts/providers/abstract-provider.ts index bd4168b51..653a47d5f 100644 --- a/src.ts/providers/abstract-provider.ts +++ b/src.ts/providers/abstract-provider.ts @@ -23,7 +23,8 @@ import { FetchRequest, toBeArray, toQuantity, defineProperties, EventPayload, resolveProperties, - toUtf8String + toUtf8String, + isError } from "../utils/index.js"; import { EnsResolver } from "./ens-resolver.js"; @@ -54,14 +55,41 @@ import type { PreparedTransactionRequest, Provider, ProviderEvent, TransactionRequest } from "./provider.js"; +import { Interface } from "../abi/interface.js"; +import type { HexString } from "../utils/data.js"; + +// https://docs.ens.domains/ensip/21 +const LOCAL_BATCH_GATEWAY_URL = 'x-batch-gateway:true'; +const LOCAL_BATCH_GATEWAY_ABI = new Interface([ + "function query((address sender, string[] urls, bytes calldata)[]) view returns (bool[], bytes[])", // = query(CcipRequest[]) + "error HttpError(uint16 status, string message)", + "error Error(string)", +]); + +type CcipRequest = { + sender: HexString; + urls: string[]; + calldata: HexString; +} -type Timer = ReturnType; +type CcipArgs = CcipRequest & { + selector: HexString; + extraData: HexString; +}; + +const ORDERED_CCIP_ARGS = ["sender", "urls", "calldata", "selector", "extraData"] as const satisfies (keyof CcipArgs)[]; + +function toErrorArgs(args: CcipArgs) { + return ORDERED_CCIP_ARGS.map(x => args[x]); +} +type Timer = ReturnType; // Constants const BN_2 = BigInt(2); const MAX_CCIP_REDIRECTS = 10; +const MAX_CCIP_FETCHES = 100; function isPromise(value: any): value is Promise { return (value && typeof(value.then) === "function"); @@ -115,6 +143,14 @@ export type DebugEventAbstractProvider = { action: "receiveCcipReadCallError", transaction: { to: string, data: string } error: Error +} | { + action: "sendCcipReadBatchRequest", + requests: CcipRequest[], +} | { + action: "receiveCcipReadBatchResult", + requests: CcipRequest[], + failures: boolean[], + responses: HexString[] }; @@ -425,15 +461,6 @@ const defaultOptions = { pollingInterval: 4000 }; -type CcipArgs = { - sender: string; - urls: Array; - calldata: string; - selector: string; - extraData: string; - errorArgs: Array -}; - /** * An **AbstractProvider** provides a base class for other sub-classes to * implement the [[Provider]] API by normalizing input arguments and @@ -568,17 +595,46 @@ export class AbstractProvider implements Provider { return await perform; } - /** - * Resolves to the data for executing the CCIP-read operations. - */ - async ccipReadFetch(tx: PerformActionTransaction, calldata: string, urls: Array): Promise { - if (this.disableCcipRead || urls.length === 0 || tx.to == null) { return null; } - - const sender = tx.to.toLowerCase(); - const data = calldata.toLowerCase(); - + async #ccipReadFetch({sender, calldata: data, urls}: CcipRequest, counter = { count: 0 }): Promise { + if (urls.includes(LOCAL_BATCH_GATEWAY_URL)) { + const requests: CcipRequest[] = LOCAL_BATCH_GATEWAY_ABI.decodeFunctionData('query', data)[0].map( + (tuple: any) => ({ sender: tuple[0], urls: tuple[1], calldata: tuple[2]}) + ); + this.emit("debug", { action: "sendCcipReadBatchRequest", requests }); + const failures: boolean[] = [] + const responses: HexString[] = [] + await Promise.all(requests.map(async (request, i) => { + try { + responses[i] = await this.#ccipReadFetch(request, counter); + failures[i] = false; + } catch (error: unknown) { + failures[i] = true; + let errorMessage = 'unknown local batch gateway error'; + if (error instanceof Error) { + errorMessage = error.message; + } + if (isError(error, 'OFFCHAIN_FAULT') && error.info && error.info.statusCode) { + if (error.info.errorMessages) { + errorMessage = error.info.errorMessages.join('\n'); + } else if (error.info.errorMessage) { + errorMessage = error.info.errorMessage; + } + responses[i] = LOCAL_BATCH_GATEWAY_ABI.encodeErrorResult('HttpError', [ + error.info.statusCode, + errorMessage + ]); + } else { + responses[i] = LOCAL_BATCH_GATEWAY_ABI.encodeErrorResult('Error', [errorMessage]); + } + } + })); + this.emit("debug", { action: "receiveCcipReadBatchResult", requests, failures, responses }); + return LOCAL_BATCH_GATEWAY_ABI.encodeFunctionResult('query', [failures, responses]); + } + assert(++counter.count < MAX_CCIP_FETCHES, `too many fetches during CCIP fetch`, 'OFFCHAIN_FAULT'); + sender = sender.toLowerCase(); + data = data.toLowerCase(); const errorMessages: Array = [ ]; - for (let i = 0; i < urls.length; i++) { const url = urls[i]; @@ -625,16 +681,30 @@ export class AbstractProvider implements Provider { // 4xx indicates the result is not present; stop assert(resp.statusCode < 400 || resp.statusCode >= 500, `response not found during CCIP fetch: ${ errorMessage }`, - "OFFCHAIN_FAULT", { reason: "404_MISSING_RESOURCE", transaction: tx, info: { url, errorMessage } }); + "OFFCHAIN_FAULT", { reason: "404_MISSING_RESOURCE", info: { url, errorMessage, statusCode: resp.statusCode } }); // 5xx indicates server issue; try the next url errorMessages.push(errorMessage); } - - assert(false, `error encountered during CCIP fetch: ${ errorMessages.map((m) => JSON.stringify(m)).join(", ") }`, "OFFCHAIN_FAULT", { + assert(false, `no response during CCIP fetch`, 'OFFCHAIN_FAULT', { reason: "500_SERVER_ERROR", - transaction: tx, info: { urls, errorMessages } - }); + info: { urls, errorMessages, statusCode: 500 } + }) + } + + /** + * Resolves to the data for executing the CCIP-read operations. + */ + async ccipReadFetch(tx: PerformActionTransaction, calldata: HexString, urls: Array): Promise { + if (this.disableCcipRead || urls.length === 0 || tx.to == null) { return null; } + try { + return await this.#ccipReadFetch({sender: tx.to, calldata, urls}); + } catch (err: unknown) { + if (isError(err, 'OFFCHAIN_FAULT')) { + err.transaction = tx; + } + throw err; + } } /** @@ -1008,14 +1078,24 @@ export class AbstractProvider implements Provider { revert: { signature: "OffchainLookup(address,string[],bytes,bytes4,bytes)", name: "OffchainLookup", - args: ccipArgs.errorArgs + args: toErrorArgs(ccipArgs) } }); - const ccipResult = await this.ccipReadFetch(transaction, ccipArgs.calldata, ccipArgs.urls); - assert(ccipResult != null, "CCIP Read failed to fetch data", "OFFCHAIN_FAULT", { - reason: "FETCH_FAILED", transaction, info: { data: error.data, errorArgs: ccipArgs.errorArgs } }); - + let ccipResult: HexString; + try { + ccipResult = await this.#ccipReadFetch(ccipArgs); + } catch (cause: unknown) { + assert(false, "CCIP Read failed to fetch data", "OFFCHAIN_FAULT", { + reason: "FETCH_FAILED", + transaction, + info: { + data: error.data, + errorArgs: toErrorArgs(ccipArgs), + cause + } + }); + } const tx = { to: txSender, data: concat([ ccipArgs.selector, encodeBytes([ ccipResult, ccipArgs.extraData ]) ]) @@ -1186,24 +1266,24 @@ export class AbstractProvider implements Provider { }); } - async getResolver(name: string, preferUniversal?: boolean): Promise { - return await EnsResolver.fromName(this, name, preferUniversal); + async getResolver(name: string): Promise { + return EnsResolver.fromName(this, name); } async getAvatar(name: string): Promise { - const resolver = await this.getResolver(name, true); + const resolver = await this.getResolver(name); if (resolver) { return await resolver.getAvatar(); } return null; } - async resolveName(name: string): Promise{ - const resolver = await this.getResolver(name, true); - if (resolver) { return await resolver.getAddress(); } + async resolveName(name: string, coinType?: BigNumberish): Promise{ + const resolver = await this.getResolver(name); + if (resolver) { return await resolver.getAddress(coinType); } return null; } - async lookupAddress(address: string): Promise { - return await EnsResolver.lookupAddress(this, address); + async lookupAddress(address: HexString, coinType?: BigNumberish): Promise { + return await EnsResolver.lookupAddress(this, address, coinType); } async waitForTransaction(hash: string, _confirms?: null | number, timeout?: null | number): Promise { @@ -1716,7 +1796,5 @@ function parseOffchainLookup(data: string): CcipArgs { }); } - result.errorArgs = "sender,urls,calldata,selector,extraData".split(/,/).map((k) => (result)[k]) - return result; } diff --git a/src.ts/providers/ens-resolver.ts b/src.ts/providers/ens-resolver.ts index d1a6d14ca..f980fc479 100644 --- a/src.ts/providers/ens-resolver.ts +++ b/src.ts/providers/ens-resolver.ts @@ -8,21 +8,24 @@ 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 { dnsEncode, ensNormalize, namehash } from "../hash/index.js"; import { - hexlify, isHexString, toBeHex, + hexlify, toBeHex, defineProperties, encodeBase58, assert, assertArgument, isError, - FetchRequest + FetchRequest, + getBigInt } from "../utils/index.js"; import type { FunctionFragment } from "../abi/index.js"; -import type { BytesLike } from "../utils/index.js"; +import type { BigNumberish, BytesLike } from "../utils/index.js"; -import type { AbstractProvider, AbstractProviderPlugin } from "./abstract-provider.js"; +import { AbstractProvider, type AbstractProviderPlugin } from "./abstract-provider.js"; import type { EnsPlugin } from "./plugins-network.js"; import type { Provider } from "./provider.js"; +import type { HexString } from "../utils/data.js"; +import { chainFromCoinType, coinTypeFromChain, isEVMCoinType } from "../utils/cointype.js"; // @TODO: This should use the fetch-data:ipfs gateway // Trim off the ipfs:// prefix and return the default gateway URL @@ -102,7 +105,7 @@ export abstract class MulticoinProviderPlugin implements AbstractProviderPlugin defineProperties(this, { name }); } - connect(proivder: Provider): MulticoinProviderPlugin { + connect(provider: Provider): MulticoinProviderPlugin { return this; } @@ -160,7 +163,7 @@ export class EnsResolver { /** * The connected provider. */ - provider!: AbstractProvider; + provider!: Provider; /** * The address of the resolver. @@ -175,29 +178,24 @@ export class EnsResolver { // For EIP-2544 names, the ancestor that provided the resolver #supports2544: null | Promise; - readonly #resolver: Contract; - - readonly #universal: null | Contract; + /** + * The resolver contract. + */ + readonly resolver: Contract; - constructor(provider: AbstractProvider, address: string, name: string, universalResolver?: boolean) { + constructor(provider: Provider, address: string, name: string, wildcard?: boolean) { defineProperties(this, { provider, address, name }); - if (universalResolver) { - this.#supports2544 = Promise.resolve(true); - this.#universal = createUniversal(provider, address); - } else { - this.#supports2544 = null; - this.#universal = null; - } + this.#supports2544 = typeof wildcard === 'boolean' ? Promise.resolve(wildcard) : null; - - this.#resolver = new Contract(address, [ + 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)", + "function name(bytes32) view returns (string)", ], provider); } @@ -209,7 +207,7 @@ export class EnsResolver { if (this.#supports2544 == null) { this.#supports2544 = (async () => { try { - return await this.#resolver.supportsInterface("0x9061b923"); + return await this.resolver.supportsInterface("0x9061b923"); } catch (error) { // Wildcard resolvers must understand supportsInterface // and return true. @@ -228,7 +226,7 @@ export class EnsResolver { async #fetch(funcName: string, params?: Array): Promise { params = (params || []).slice(); - const iface = this.#resolver.interface; + const iface = this.resolver.interface; // The first parameters is always the nodehash params.unshift(namehash(this.name)) @@ -251,12 +249,7 @@ export class EnsResolver { params.push({ enableCcipRead: true }); try { - let result; - if (this.#universal) { - result = (await this.#universal.resolve(...params)).result; - } else { - result = await this.#resolver[funcName](...params); - } + let result = await this.resolver[funcName](...params); if (fragment) { return iface.decodeFunctionResult(fragment, result)[0]; @@ -271,59 +264,58 @@ export class EnsResolver { return null; } + async getName(): Promise { + return this.#fetch("name(bytes32)"); + } + + async getRawAddress(coinType: bigint): Promise { + return coinType == 60n + ? this.#fetch("addr(bytes32)") + : this.#fetch("addr(bytes32,uint)", [coinType]); + } + + async getEvmAddress(chain: number): Promise { + const data = await this.getRawAddress(coinTypeFromChain(chain)); + return data === '0x' ? ZeroAddress : getAddress(data); + } + /** * Resolves to the address for %%coinType%% or null if the * provided %%coinType%% has not been configured. */ - async getAddress(coinType?: number): Promise { - 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; + async getAddress(coinType: BigNumberish = 60): Promise { + coinType = getBigInt(coinType, "coinType"); + if (isEVMCoinType(coinType)) { + const chain = chainFromCoinType(coinType); + if (chain == 1) { + try { + return nullIfZero(await this.getEvmAddress(chain)); + } catch (error: any) { + if (isError(error, "CALL_EXCEPTION")) { return null; } + throw error; + } + } else { + return nullIfZero(await this.getEvmAddress(chain)); } } - - // 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 (coinType <= Number.MAX_SAFE_INTEGER && this.provider instanceof AbstractProvider) { + const coinNum = Number(coinType); + for (const coinPlugin of this.provider.plugins) { + if (coinPlugin instanceof MulticoinProviderPlugin && coinPlugin.supportsCoinType(coinNum)) { + const data = await this.getRawAddress(coinType); + if (data === '0x') return null; + const decoded = await coinPlugin.decodeAddress(coinNum, data); + assert(decoded, `invalid coin data`, "UNSUPPORTED_OPERATION", { + operation: `getAddress(${ coinType })`, + info: { coinType, coinPlugin, data } + }); + return decoded; + } } } - - 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", { + assert(false, `unknown coin type`, "UNSUPPORTED_OPERATION", { operation: `getAddress(${ coinType })`, - info: { coinType, data } + info: { coinType } }); } @@ -586,23 +578,29 @@ export class EnsResolver { return null; } - static async lookupAddress(provider: AbstractProvider, address: string, coinType?: number): Promise { - address = getAddress(address); - if (coinType == null) { coinType = 60; } + static async lookupAddress(provider: Provider, address: string, coinType: BigNumberish = 60): Promise { + coinType = getBigInt(coinType, "coinType"); + if (isEVMCoinType(coinType)) { + address = getAddress(address); + } // We have a Universal resolver, use it const universal = await getUniversal(provider); if (universal) { - const result = await universal.reverse(address, coinType, { - enableCcipRead: true - }); - - return result.primary || null; + try { + const [primary] = await universal.reverse(address, coinType, {enableCcipRead: true}); + if (primary == ensNormalize(primary)) { + return primary; + } + } catch (ignored: unknown) { + // can we surface these errors? + } + return null; } // Use legacy reverse lookup - - assert(coinType === 60, "lookupAddress coinType requires ENS Universal Resolver", "UNSUPPORTED_OPERATION", { + + assert(coinType === 60n, "lookupAddress coinType requires ENS Universal Resolver", "UNSUPPORTED_OPERATION", { operation: "lookupAddress" }); @@ -618,14 +616,10 @@ export class EnsResolver { const resolver = await ensContract.resolver(node); if (resolver == null || resolver === ZeroAddress) { return null; } - const resolverContract = new Contract(resolver, [ - "function name(bytes32) view returns (string)" - ], provider); - - const name = await resolverContract.name(node); + const name = await resolver.getName(node); // Failed forward resolution - const check = await provider.resolveName(name); + const check = await provider.resolveName(name, coinType); if (check !== address) { return null; } return name; @@ -640,8 +634,6 @@ export class EnsResolver { throw error; } - - return null; } /** @@ -654,17 +646,13 @@ export class EnsResolver { * more eth_call efficient, as it performs more on-chain, but is not * the actual resolver, if for example the setters are required. */ - static async fromName(provider: AbstractProvider, name: string, preferUniversal?: boolean): Promise { + static async fromName(provider: AbstractProvider, name: string): Promise { // We have a Universal Resolver, use it const universal = await getUniversal(provider); if (universal) { - if (preferUniversal) { - return new EnsResolver(provider, universal.target, name, true); - } - - const result = await universal.findResolver(dnsEncode(name)); - return new EnsResolver(provider, result.resolver, name); + const result: RequireResolverResult = await universal.requireResolver(dnsEncode(name, 255)); + return new EnsResolver(provider, result.resolver, name, result.extended); } let currentName = name; @@ -692,18 +680,31 @@ export class EnsResolver { currentName = currentName.split(".").slice(1).join("."); } } + + static async getUniversal(provider: AbstractProvider) { + return getUniversal(provider); + } } -function createUniversal(provider: AbstractProvider, address: string): Contract { +type RequireResolverResult = { + resolver: HexString; + extended: boolean; +} + +function createUniversal(provider: Provider, address: string): Contract { return new Contract(address, [ + "function requireResolver(bytes) view returns ((bytes name, uint256 offset, bytes32 node, address resolver, bool extended))", // RequireResolverResult "function findResolver(bytes) view returns (address resolver, bytes32 node, uint offset)", "function resolve(bytes name, bytes data) view returns (bytes result, address resolver)", "function reverse(bytes name, uint coinType) view returns (string primary, address resolver, address reverseResolver)", - "error HttpError(uint16 statusCode, string statusMessage)" + "error ResolverNotFound(bytes name)", + "error ResolverNotContract(bytes name, address resolver)", + "error ReverseAddressMismatch(string primary, bytes primaryAddress)", + "error HttpError(uint16 statusCode, string statusMessage)", ], provider); } -async function getUniversal(provider: AbstractProvider): Promise { +async function getUniversal(provider: Provider): Promise { const network = await provider.getNetwork(); const ensPlugin = network.getPlugin("org.ethers.plugins.network.Ens"); @@ -713,3 +714,7 @@ async function getUniversal(provider: AbstractProvider): Promise; + resolveName(ensName: string, coinType?: BigNumberish): Promise; /** * Resolves to the ENS name associated for the %%address%% or @@ -2118,7 +2118,7 @@ export interface Provider extends ContractRunner, EventEmitterable; + lookupAddress(address: string, coinType?: BigNumberish): Promise; /** * Waits until the transaction %%hash%% is mined and has %%confirms%% diff --git a/src.ts/utils/cointype.ts b/src.ts/utils/cointype.ts new file mode 100755 index 000000000..abc8c3219 --- /dev/null +++ b/src.ts/utils/cointype.ts @@ -0,0 +1,23 @@ +import { assert } from "./errors.js"; + +const COIN_TYPE_ETH = 60n; +const COIN_TYPE_DEFAULT = 1n << 31n; + +export function coinTypeFromChain(chain: number): bigint { + if (chain === 1) return COIN_TYPE_ETH; + assert((chain & Number(COIN_TYPE_DEFAULT - 1n)) === chain, "invalid chain id", "INVALID_ARGUMENT", { + argument: 'chain', + value: chain + }); + return BigInt(chain) | COIN_TYPE_DEFAULT +} + +export function chainFromCoinType(coinType: bigint): number { + if (coinType == COIN_TYPE_ETH) return 1; + coinType ^= COIN_TYPE_DEFAULT; + return coinType >= 0 && coinType < COIN_TYPE_DEFAULT ? Number(coinType) : 0; +} + +export function isEVMCoinType(coinType: bigint) { + return coinType === COIN_TYPE_DEFAULT || chainFromCoinType(coinType) > 0 +}