Added CCIP support to provider.call (#2478).

This commit is contained in:
Richard Moore 2022-03-04 16:15:47 -05:00
parent 33a029e457
commit ae23bb76a9
6 changed files with 342 additions and 51 deletions

@ -34,6 +34,7 @@ export type TransactionRequest = {
maxFeePerGas?: BigNumberish;
customData?: Record<string, any>;
ccipReadEnabled?: boolean;
}
export interface TransactionResponse extends Transaction {

@ -10,7 +10,7 @@ import { version } from "./_version";
const logger = new Logger(version);
const allowedTransactionKeys: Array<string> = [
"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 = [

@ -23,6 +23,7 @@ export interface Overrides {
type?: number;
accessList?: AccessListish;
customData?: Record<string, any>;
ccipReadEnabled?: boolean;
};
export interface PayableOverrides extends Overrides {
@ -58,6 +59,7 @@ export interface PopulatedTransaction {
maxPriorityFeePerGas?: BigNumber;
customData?: Record<string, any>;
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<string>): Promise<string> {
@ -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.

@ -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<BytesLike>) {
const result: Array<Uint8Array> = [ ];
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<string | Uint8Array> = [ "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<null | string> {
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<string>): Promise<null | string> {
if (this.disableCcipRead || urls.length === 0) { return null; }
const sender = (tx.from || "0x0000000000000000000000000000000000000000").toLowerCase();
const data = calldata.toLowerCase();
const errorMessages: Array<string> = [ ];
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<number> {
@ -1512,22 +1557,105 @@ export class BaseProvider extends Provider implements EnsProvider {
return this.formatter.filter(await resolveProperties(result));
}
async call(transaction: Deferrable<TransactionRequest>, blockTag?: BlockTag | Promise<BlockTag>): Promise<string> {
await this.getNetwork();
const params = await resolveProperties({
transaction: this._getTransactionRequest(transaction),
blockTag: this._getBlockTag(blockTag)
});
async _call(transaction: TransactionRequest, blockTag: BlockTag, attempt: number): Promise<string> {
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<string> = [];
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>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<TransactionRequest>, blockTag?: BlockTag | Promise<BlockTag>): Promise<string> {
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<TransactionRequest>): Promise<BigNumber> {
@ -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; }

@ -1393,3 +1393,155 @@ describe("Resolve ENS avatar", function() {
});
});
});
describe("Test EIP-2544 ENS wildcards", function() {
const provider = <ethers.providers.BaseProvider>(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");
(<ethers.providers.BaseProvider>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);
});
})

@ -50,6 +50,7 @@ export type ConnectionInfo = {
throttleCallback?: (attempt: number, url: string) => Promise<boolean>,
skipFetchSetup?: boolean;
errorPassThrough?: boolean;
timeout?: number,
};
@ -98,6 +99,8 @@ export function _fetchData<T = Uint8Array>(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<T = Uint8Array>(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,