diff --git a/packages/abstract-provider/src.ts/index.ts b/packages/abstract-provider/src.ts/index.ts index af3a80698..6e2472fdb 100644 --- a/packages/abstract-provider/src.ts/index.ts +++ b/packages/abstract-provider/src.ts/index.ts @@ -34,6 +34,7 @@ export type TransactionRequest = { maxFeePerGas?: BigNumberish; customData?: Record; + ccipReadEnabled?: boolean; } export interface TransactionResponse extends Transaction { diff --git a/packages/abstract-signer/src.ts/index.ts b/packages/abstract-signer/src.ts/index.ts index 8ec850d89..f0e5d875e 100644 --- a/packages/abstract-signer/src.ts/index.ts +++ b/packages/abstract-signer/src.ts/index.ts @@ -10,7 +10,7 @@ import { version } from "./_version"; const logger = new Logger(version); const allowedTransactionKeys: Array = [ - "accessList", "chainId", "customData", "data", "from", "gasLimit", "gasPrice", "maxFeePerGas", "maxPriorityFeePerGas", "nonce", "to", "type", "value" + "accessList", "ccipReadEnabled", "chainId", "customData", "data", "from", "gasLimit", "gasPrice", "maxFeePerGas", "maxPriorityFeePerGas", "nonce", "to", "type", "value" ]; const forwardErrors = [ diff --git a/packages/contracts/src.ts/index.ts b/packages/contracts/src.ts/index.ts index 2f96bae14..40afbf84d 100644 --- a/packages/contracts/src.ts/index.ts +++ b/packages/contracts/src.ts/index.ts @@ -23,6 +23,7 @@ export interface Overrides { type?: number; accessList?: AccessListish; customData?: Record; + ccipReadEnabled?: boolean; }; export interface PayableOverrides extends Overrides { @@ -58,6 +59,7 @@ export interface PopulatedTransaction { maxPriorityFeePerGas?: BigNumber; customData?: Record; + ccipReadEnabled?: boolean; }; export type EventFilter = { @@ -110,7 +112,8 @@ const allowedTransactionKeys: { [ key: string ]: boolean } = { chainId: true, data: true, from: true, gasLimit: true, gasPrice:true, nonce: true, to: true, value: true, type: true, accessList: true, maxFeePerGas: true, maxPriorityFeePerGas: true, - customData: true + customData: true, + ccipReadEnabled: true } async function resolveName(resolver: Signer | Provider, nameOrPromise: string | Promise): Promise { @@ -274,6 +277,10 @@ async function populateTransaction(contract: Contract, fragment: FunctionFragmen tx.customData = shallowCopy(ro.customData); } + if (ro.ccipReadEnabled) { + tx.ccipReadEnabled = !!ro.ccipReadEnabled; + } + // Remove the overrides delete overrides.nonce; delete overrides.gasLimit; @@ -288,6 +295,7 @@ async function populateTransaction(contract: Contract, fragment: FunctionFragmen delete overrides.maxPriorityFeePerGas; delete overrides.customData; + delete overrides.ccipReadEnabled; // Make sure there are no stray overrides, which may indicate a // typo or using an unsupported key. diff --git a/packages/providers/src.ts/base-provider.ts b/packages/providers/src.ts/base-provider.ts index ceef9a180..ec08ed299 100644 --- a/packages/providers/src.ts/base-provider.ts +++ b/packages/providers/src.ts/base-provider.ts @@ -6,7 +6,7 @@ import { } from "@ethersproject/abstract-provider"; import { Base58 } from "@ethersproject/basex"; import { BigNumber, BigNumberish } from "@ethersproject/bignumber"; -import { arrayify, concat, hexConcat, hexDataLength, hexDataSlice, hexlify, hexValue, hexZeroPad, isHexString } from "@ethersproject/bytes"; +import { arrayify, BytesLike, concat, hexConcat, hexDataLength, hexDataSlice, hexlify, hexValue, hexZeroPad, isHexString } from "@ethersproject/bytes"; import { HashZero } from "@ethersproject/constants"; import { dnsEncode, namehash } from "@ethersproject/hash"; import { getNetwork, Network, Networkish } from "@ethersproject/networks"; @@ -24,6 +24,8 @@ const logger = new Logger(version); import { Formatter } from "./formatter"; +const MAX_CCIP_REDIRECTS = 10; + ////////////////////////////// // Event Serializeing @@ -250,18 +252,19 @@ const matchers = [ new RegExp("^eip155:[0-9]+/(erc[0-9]+):(.*)$", "i"), ]; -function _parseString(result: string): null | string { +function _parseString(result: string, start: number): null | string { try { - return toUtf8String(_parseBytes(result)); + return toUtf8String(_parseBytes(result, start)); } catch(error) { } return null; } -function _parseBytes(result: string): null | string { +function _parseBytes(result: string, start: number): null | string { if (result === "0x") { return null; } - const offset = BigNumber.from(hexDataSlice(result, 0, 32)).toNumber(); + const offset = BigNumber.from(hexDataSlice(result, start, start + 32)).toNumber(); const length = BigNumber.from(hexDataSlice(result, offset, offset + 32)).toNumber(); + return hexDataSlice(result, offset + 32, offset + 32 + length); } @@ -295,6 +298,32 @@ function bytesPad(value: Uint8Array): Uint8Array { return result; } +// ABI Encodes a series of (bytes, bytes, ...) +function encodeBytes(datas: Array) { + const result: Array = [ ]; + + let byteCount = 0; + + // Add place-holders for pointers as we add items + for (let i = 0; i < datas.length; i++) { + result.push(null); + byteCount += 32; + } + + for (let i = 0; i < datas.length; i++) { + const data = arrayify(datas[i]); + + // Update the bytes offset + result[i] = numPad(byteCount); + + // The length and padded value of data + result.push(numPad(data.length)); + result.push(bytesPad(data)); + byteCount += 32 + Math.ceil(data.length / 32) * 32; + } + + return hexConcat(result); +} export class Resolver implements EnsResolver { readonly provider: BaseProvider; @@ -346,41 +375,13 @@ export class Resolver implements EnsResolver { if (await this.supportsWildcard()) { parseBytes = true; - const p0 = arrayify(dnsEncode(this.name)); - const p1 = arrayify(tx.data); - // selector("resolve(bytes,bytes)") - const bytes: Array = [ "0x9061b923" ]; - let byteCount = 0; - - // Place-holder pointer to p0 - const placeHolder0 = bytes.length; - bytes.push("0x"); - byteCount += 32; - - // Place-holder pointer to p1 - const placeHolder1 = bytes.length; - bytes.push("0x"); - byteCount += 32; - - // The length and padded value of p0 - bytes[placeHolder0] = numPad(byteCount); - bytes.push(numPad(p0.length)); - bytes.push(bytesPad(p0)); - byteCount += 32 + Math.ceil(p0.length / 32) * 32; - - // The length and padded value of p0 - bytes[placeHolder1] = numPad(byteCount); - bytes.push(numPad(p1.length)); - bytes.push(bytesPad(p1)); - byteCount += 32 + Math.ceil(p1.length / 32) * 32; - - tx.data = hexConcat(bytes); + tx.data = hexConcat([ "0x9061b923", encodeBytes([ dnsEncode(this.name), tx.data ]) ]); } try { let result = await this.provider.call(tx); - if (parseBytes) { result = _parseBytes(result); } + if (parseBytes) { result = _parseBytes(result, 0); } return result; } catch (error) { if (error.code === Logger.errors.CALL_EXCEPTION) { return null; } @@ -390,7 +391,7 @@ export class Resolver implements EnsResolver { async _fetchBytes(selector: string, parameters?: string): Promise { const result = await this._fetch(selector, parameters); - if (result != null) { return _parseBytes(result); } + if (result != null) { return _parseBytes(result, 0); } return null; } @@ -561,7 +562,7 @@ export class Resolver implements EnsResolver { data: hexConcat([ selector, tokenId ]) }; - let metadataUrl = _parseString(await this.provider.call(tx)) + let metadataUrl = _parseString(await this.provider.call(tx), 0); if (metadataUrl == null) { return null; } linkage.push({ type: "metadata-url-base", content: metadataUrl }); @@ -700,6 +701,8 @@ export class BaseProvider extends Provider implements EnsProvider { readonly anyNetwork: boolean; + disableCcipRead: boolean; + /** * ready @@ -721,6 +724,8 @@ export class BaseProvider extends Provider implements EnsProvider { this._emitted = { block: -2 }; + this.disableCcipRead = false; + this.formatter = new.target.getFormatter(); // If network is any, this Provider allows the underlying @@ -822,6 +827,46 @@ export class BaseProvider extends Provider implements EnsProvider { return getNetwork((network == null) ? "homestead": network); } + async ccipReadFetch(tx: Transaction, calldata: string, urls: Array): Promise { + if (this.disableCcipRead || urls.length === 0) { return null; } + + const sender = (tx.from || "0x0000000000000000000000000000000000000000").toLowerCase(); + const data = calldata.toLowerCase(); + + const errorMessages: Array = [ ]; + + for (let i = 0; i < urls.length; i++) { + const url = urls[i]; + + // URL expansion + const href = url.replace("{sender}", sender).replace("{data}", data); + + // If no {data} is present, use POST; otherwise GET + const json: string | null = (url.indexOf("{data}") >= 0) ? null: JSON.stringify({ data, sender }); + + const result = await fetchJson({ url: href, errorPassThrough: true }, json, (value, response) => { + value.status = response.statusCode; + return value; + }); + + if (result.data) { return result.data; } + + const errorMessage = (result.message || "unknown error"); + + // 4xx indicates the result is not present; stop + if (result.status >= 400 && result.status < 500) { + return logger.throwError(`response not found during CCIP fetch: ${ errorMessage }`, Logger.errors.SERVER_ERROR, { url, errorMessage }); + } + + // 5xx indicates server issue; try the next url + errorMessages.push(errorMessage); + } + + return logger.throwError(`error encountered during CCIP fetch: ${ errorMessages.map((m) => JSON.stringify(m)).join(", ") }`, Logger.errors.SERVER_ERROR, { + urls, errorMessages + }); + } + // Fetches the blockNumber, but will reuse any result that is less // than maxAge old or has been requested since the last request async _getInternalBlockNumber(maxAge: number): Promise { @@ -1512,22 +1557,105 @@ export class BaseProvider extends Provider implements EnsProvider { return this.formatter.filter(await resolveProperties(result)); } - async call(transaction: Deferrable, blockTag?: BlockTag | Promise): Promise { - await this.getNetwork(); - const params = await resolveProperties({ - transaction: this._getTransactionRequest(transaction), - blockTag: this._getBlockTag(blockTag) - }); + async _call(transaction: TransactionRequest, blockTag: BlockTag, attempt: number): Promise { + if (attempt >= MAX_CCIP_REDIRECTS) { + logger.throwError("CCIP read exceeded maximum redirections", Logger.errors.SERVER_ERROR, { + redirects: attempt, transaction + }); + } + + const txSender = transaction.to; + + const result = await this.perform("call", { transaction, blockTag }); + + // CCIP Read request via OffchainLookup(address,string[],bytes,bytes4,bytes) + if (attempt >= 0 && blockTag === "latest" && txSender != null && result.substring(0, 10) === "0x556f1830" && (hexDataLength(result) % 32 === 4)) { + try { + const data = hexDataSlice(result, 4); + + // Check the sender of the OffchainLookup matches the transaction + const sender = hexDataSlice(data, 0, 32); + if (!BigNumber.from(sender).eq(txSender)) { + logger.throwError("CCIP Read sender did not match", Logger.errors.CALL_EXCEPTION, { + name: "OffchainLookup", + signature: "OffchainLookup(address,string[],bytes,bytes4,bytes)", + transaction, data: result + }); + } + + // Read the URLs from the response + const urls: Array = []; + const urlsOffset = BigNumber.from(hexDataSlice(data, 32, 64)).toNumber(); + const urlsLength = BigNumber.from(hexDataSlice(data, urlsOffset, urlsOffset + 32)).toNumber(); + const urlsData = hexDataSlice(data, urlsOffset + 32); + for (let u = 0; u < urlsLength; u++) { + const url = _parseString(urlsData, u * 32); + if (url == null) { + logger.throwError("CCIP Read contained corrupt URL string", Logger.errors.CALL_EXCEPTION, { + name: "OffchainLookup", + signature: "OffchainLookup(address,string[],bytes,bytes4,bytes)", + transaction, data: result + }); + } + urls.push(url); + } + + // Get the CCIP calldata to forward + const calldata = _parseBytes(data, 64); + + // Get the callbackSelector (bytes4) + if (!BigNumber.from(hexDataSlice(data, 100, 128)).isZero()) { + logger.throwError("CCIP Read callback selector included junk", Logger.errors.CALL_EXCEPTION, { + name: "OffchainLookup", + signature: "OffchainLookup(address,string[],bytes,bytes4,bytes)", + transaction, data: result + }); + } + const callbackSelector = hexDataSlice(data, 96, 100); + + // Get the extra data to send back to the contract as context + const extraData = _parseBytes(data, 128); + + const ccipResult = await this.ccipReadFetch(transaction, calldata, urls); + if (ccipResult == null) { + logger.throwError("CCIP Read disabled or provided no URLs", Logger.errors.CALL_EXCEPTION, { + name: "OffchainLookup", + signature: "OffchainLookup(address,string[],bytes,bytes4,bytes)", + transaction, data: result + }); + } + + const tx = { + to: txSender, + data: hexConcat([ callbackSelector, encodeBytes([ ccipResult, extraData ]) ]) + }; + + return this._call(tx, blockTag, attempt + 1); + + } catch (error) { + if (error.code === Logger.errors.SERVER_ERROR) { throw error; } + } + } - const result = await this.perform("call", params); try { return hexlify(result); } catch (error) { return logger.throwError("bad result from backend", Logger.errors.SERVER_ERROR, { method: "call", - params, result, error + params: { transaction, blockTag }, result, error }); } + + } + + async call(transaction: Deferrable, blockTag?: BlockTag | Promise): Promise { + await this.getNetwork(); + const resolved = await resolveProperties({ + transaction: this._getTransactionRequest(transaction), + blockTag: this._getBlockTag(blockTag), + ccipReadEnabled: Promise.resolve(transaction.ccipReadEnabled) + }); + return this._call(resolved.transaction, resolved.blockTag, resolved.ccipReadEnabled ? 0: -1); } async estimateGas(transaction: Deferrable): Promise { @@ -1843,7 +1971,7 @@ export class BaseProvider extends Provider implements EnsProvider { const name = _parseString(await this.call({ to: resolverAddr, data: ("0x691f3431" + namehash(node).substring(2)) - })); + }), 0); const addr = await this.resolveName(name); if (addr != address) { return null; } @@ -1877,7 +2005,7 @@ export class BaseProvider extends Provider implements EnsProvider { const name = _parseString(await this.call({ to: resolverAddress, data: ("0x691f3431" + namehash(node).substring(2)) - })); + }), 0); resolver = await this.getResolver(name); } catch (error) { if (error.code !== Logger.errors.CALL_EXCEPTION) { throw error; } diff --git a/packages/tests/src.ts/test-providers.ts b/packages/tests/src.ts/test-providers.ts index e02ef015a..d55af0f8c 100644 --- a/packages/tests/src.ts/test-providers.ts +++ b/packages/tests/src.ts/test-providers.ts @@ -1393,3 +1393,155 @@ describe("Resolve ENS avatar", function() { }); }); }); + +describe("Test EIP-2544 ENS wildcards", function() { + const provider = (providerFunctions[0].create("ropsten")); + + it("Resolves recursively", async function() { + const resolver = await provider.getResolver("ricmoose.hatch.eth"); + assert.equal(resolver.address, "0x8fc4C380c5d539aE631daF3Ca9182b40FB21D1ae", "found the correct resolver"); + assert.equal(await resolver.supportsWildcard(), true, "supportsWildcard"); + assert.equal((await resolver.getAvatar()).url, "https://static.ricmoo.com/uploads/profile-06cb9c3031c9.jpg", "gets passed-through avatar"); + assert.equal(await resolver.getAddress(), "0x4FaBE0A3a4DDd9968A7b4565184Ad0eFA7BE5411", "gets resolved address"); + }); +}); + +describe("Test CCIP execution", function() { + const address = "0xAe375B05A08204C809b3cA67C680765661998886"; + const ABI = [ + //'error OffchainLookup(address sender, string[] urls, bytes callData, bytes4 callbackFunction, bytes extraData)', + 'function testGet(bytes callData) view returns (bytes32)', + 'function testGetFail(bytes callData) view returns (bytes32)', + 'function testGetSenderFail(bytes callData) view returns (bytes32)', + 'function testGetFallback(bytes callData) view returns (bytes32)', + 'function testGetMissing(bytes callData) view returns (bytes32)', + 'function testPost(bytes callData) view returns (bytes32)', + 'function verifyTest(bytes result, bytes extraData) pure returns (bytes32)' + ]; + + const provider = providerFunctions[0].create("ropsten"); + const contract = new ethers.Contract(address, ABI, provider); + + // This matches the verify method in the Solidity contract against the + // processed data from the endpoint + const verify = function(sender: string, data: string, result: string): void { + const check = ethers.utils.concat([ + ethers.utils.arrayify(ethers.utils.arrayify(sender).length), + sender, + ethers.utils.arrayify(ethers.utils.arrayify(data).length), + data + ]); + assert.equal(result, ethers.utils.keccak256(check), "response is equal"); + } + + it("testGet passes under normal operation", async function() { + this.timeout(60000); + const data = "0x1234"; + const result = await contract.testGet(data, { ccipReadEnabled: true }); + verify(ethers.constants.AddressZero, data, result); + }); + + it("testGet should fail with CCIP not explicitly enabled by overrides", async function() { + this.timeout(60000); + + try { + const data = "0x1234"; + const result = await contract.testGet(data); + console.log(result); + assert.fail("throw-failed"); + } catch (error: any) { + if (error.message === "throw-failed") { throw error; } + if (error.code !== "CALL_EXCEPTION") { + console.log(error); + assert.fail("failed"); + } + } + }); + + it("testGet should fail with CCIP explicitly disabled on provider", async function() { + this.timeout(60000); + + const provider = providerFunctions[0].create("ropsten"); + (provider).disableCcipRead = true; + const contract = new ethers.Contract(address, ABI, provider); + + try { + const data = "0x1234"; + const result = await contract.testGet(data, { ccipReadEnabled: true }); + console.log(result); + assert.fail("throw-failed"); + } catch (error: any) { + if (error.message === "throw-failed") { throw error; } + if (error.code !== "CALL_EXCEPTION") { + console.log(error); + assert.fail("failed"); + } + } + }); + + it("testGetFail should fail if all URLs 5xx", async function() { + this.timeout(60000); + + try { + const data = "0x1234"; + const result = await contract.testGetFail(data, { ccipReadEnabled: true }); + console.log(result); + assert.fail("throw-failed"); + } catch (error: any) { + if (error.message === "throw-failed") { throw error; } + if (error.code !== "SERVER_ERROR" || (error.errorMessages || []).pop() !== "hello world") { + console.log(error); + assert.fail("failed"); + } + } + }); + + it("testGetSenderFail should fail if sender does not match", async function() { + this.timeout(60000); + + try { + const data = "0x1234"; + const result = await contract.testGetSenderFail(data, { ccipReadEnabled: true }); + console.log(result); + assert.fail("throw-failed"); + } catch (error: any) { + if (error.message === "throw-failed") { throw error; } + if (error.code !== "CALL_EXCEPTION") { + console.log(error); + assert.fail("failed"); + } + } + }); + + it("testGetMissing should fail if early URL 4xx", async function() { + this.timeout(60000); + + try { + const data = "0x1234"; + const result = await contract.testGetMissing(data, { ccipReadEnabled: true }); + console.log(result); + assert.fail("throw-failed"); + } catch (error: any) { + if (error.message === "throw-failed") { throw error; } + if (error.code !== "SERVER_ERROR" || error.errorMessage !== "hello world") { + console.log(error); + assert.fail("failed"); + } + } + }); + + it("testGetFallback passes if any URL returns coorectly", async function() { + this.timeout(60000); + const data = "0x123456"; + const result = await contract.testGetFallback(data, { ccipReadEnabled: true }); + verify(ethers.constants.AddressZero, data, result); + }); + + it("testPost passes under normal operation", async function() { + this.timeout(60000); + const data = "0x1234"; + const result = await contract.testPost(data, { ccipReadEnabled: true }); + verify(ethers.constants.AddressZero, data, result); + }); + +}) diff --git a/packages/web/src.ts/index.ts b/packages/web/src.ts/index.ts index 92ac6ccbb..c46cf9203 100644 --- a/packages/web/src.ts/index.ts +++ b/packages/web/src.ts/index.ts @@ -50,6 +50,7 @@ export type ConnectionInfo = { throttleCallback?: (attempt: number, url: string) => Promise, skipFetchSetup?: boolean; + errorPassThrough?: boolean; timeout?: number, }; @@ -98,6 +99,8 @@ export function _fetchData(connection: string | ConnectionInfo, logger.assertArgument((throttleSlotInterval > 0 && (throttleSlotInterval % 1) === 0), "invalid connection throttle slot interval", "connection.throttleSlotInterval", throttleSlotInterval); + const errorPassThrough = ((typeof(connection) === "object") ? !!(connection.errorPassThrough): false); + const headers: { [key: string]: Header } = { }; let url: string = null; @@ -288,8 +291,7 @@ export function _fetchData(connection: string | ConnectionInfo, if (allow304 && response.statusCode === 304) { body = null; - - } else if (response.statusCode < 200 || response.statusCode >= 300) { + } else if (!errorPassThrough && (response.statusCode < 200 || response.statusCode >= 300)) { runningTimeout.cancel(); logger.throwError("bad response", Logger.errors.SERVER_ERROR, { status: response.statusCode,