diff --git a/src.ts/providers/abstract-provider.ts b/src.ts/providers/abstract-provider.ts index 707d772cb..0ca6a1181 100644 --- a/src.ts/providers/abstract-provider.ts +++ b/src.ts/providers/abstract-provider.ts @@ -7,6 +7,7 @@ // of Signer/ENS name to address so we can sync respond to listenerCount. import { resolveAddress } from "../address/index.js"; +import { Transaction } from "../transaction/index.js"; import { concat, dataLength, dataSlice, hexlify, isHexString, getBigInt, getBytes, getNumber, @@ -709,7 +710,7 @@ export class AbstractProvider implements Provider { } catch (error) { // CCIP Read OffchainLookup - if (!this.disableCcipRead && isCallException(error) && attempt >= 0 && blockTag === "latest" && transaction.to != null && dataSlice(error.data, 0, 4) === "0x556f1830") { + if (!this.disableCcipRead && isCallException(error) && error.data && attempt >= 0 && blockTag === "latest" && transaction.to != null && dataSlice(error.data, 0, 4) === "0x556f1830") { const data = error.data; const txSender = await resolveAddress(transaction.to, this); @@ -728,10 +729,16 @@ export class AbstractProvider implements Provider { // Check the sender of the OffchainLookup matches the transaction if (ccipArgs.sender.toLowerCase() !== txSender.toLowerCase()) { return throwError("CCIP Read sender mismatch", "CALL_EXCEPTION", { - data, transaction, - errorSignature: "OffchainLookup(address,string[],bytes,bytes4,bytes)", - errorName: "OffchainLookup", - errorArgs: ccipArgs.errorArgs + action: "call", + data, + reason: "OffchainLookup", + transaction: transaction, // @TODO: populate data? + invocation: null, + revert: { + signature: "OffchainLookup(address,string[],bytes,bytes4,bytes)", + name: "OffchainLookup", + args: ccipArgs.errorArgs + } }); } @@ -802,8 +809,21 @@ export class AbstractProvider implements Provider { // Write async broadcastTransaction(signedTx: string): Promise { - throw new Error(); - return { }; + const { blockNumber, hash, network } = await resolveProperties({ + blockNumber: this.getBlockNumber(), + hash: this._perform({ + method: "broadcastTransaction", + signedTransaction: signedTx + }), + network: this.getNetwork() + }); + + const tx = Transaction.from(signedTx); + if (tx.hash !== hash) { + throw new Error("@TODO: the returned hash did not match"); + } + + return this._wrapTransactionResponse(tx, network).replaceableTransaction(blockNumber); } async #getBlock(block: BlockTag | string, includeTransactions: boolean): Promise { diff --git a/src.ts/providers/abstract-signer.ts b/src.ts/providers/abstract-signer.ts index b5571597b..5e231809b 100644 --- a/src.ts/providers/abstract-signer.ts +++ b/src.ts/providers/abstract-signer.ts @@ -32,7 +32,7 @@ export abstract class AbstractSigner

> { + async #populate(op: string, tx: TransactionRequest): Promise<{ provider: Provider, pop: TransactionLike }> { const provider = this.#checkProvider(op); //let pop: Deferrable = Object.assign({ }, tx); @@ -60,17 +60,17 @@ export abstract class AbstractSigner

> { - const pop = await this.#populate("populateCall", tx); + const { pop } = await this.#populate("populateCall", tx); return pop; } async populateTransaction(tx: TransactionRequest): Promise> { - const pop = await this.#populate("populateTransaction", tx); + const { pop, provider } = await this.#populate("populateTransaction", tx); if (pop.nonce == null) { pop.nonce = await this.getNonce("pending"); @@ -91,10 +91,109 @@ export abstract class AbstractSigner

{ const provider = this.#checkProvider("sendTransaction"); - const txObj = Transaction.from(await this.populateTransaction(tx)); + const pop = await this.populateTransaction(tx); + const txObj = Transaction.from(pop); return await provider.broadcastTransaction(await this.signTransaction(txObj)); } diff --git a/src.ts/providers/ens-resolver.ts b/src.ts/providers/ens-resolver.ts index dc99429fe..2db1d07c6 100644 --- a/src.ts/providers/ens-resolver.ts +++ b/src.ts/providers/ens-resolver.ts @@ -1,5 +1,5 @@ import { getAddress } from "../address/index.js"; -import { ZeroHash } from "../constants/hashes.js"; +import { ZeroAddress, ZeroHash } from "../constants/index.js"; import { dnsEncode, namehash } from "../hash/index.js"; import { concat, dataSlice, getBytes, hexlify, zeroPadValue, @@ -197,6 +197,7 @@ export class EnsResolver { const addrData = concat([ selector, namehash(this.name), parameters ]); const tx: TransactionRequest = { to: this.address, + from: ZeroAddress, enableCcipRead: true, data: addrData }; @@ -213,8 +214,9 @@ export class EnsResolver { try { let data = await this.provider.call(tx); if ((getBytes(data).length % 32) === 4) { - return throwError("resolver threw error", "CALL_EXCEPTION", { - transaction: tx, data + return throwError("execution reverted during JSON-RPC call (could not parse reason; invalid data length)", "CALL_EXCEPTION", { + action: "call", data, reason: null, transaction: tx, + invocation: null, revert: null }); } if (wrapped) { return parseBytes(data, 0); } diff --git a/src.ts/providers/provider-alchemy.ts b/src.ts/providers/provider-alchemy.ts index 93272ad46..d0ed066da 100644 --- a/src.ts/providers/provider-alchemy.ts +++ b/src.ts/providers/provider-alchemy.ts @@ -1,6 +1,7 @@ import { - defineProperties, FetchRequest, throwArgumentError, throwError + defineProperties, resolveProperties, throwArgumentError, throwError, + FetchRequest } from "../utils/index.js"; import { showThrottleMessage } from "./community.js"; @@ -18,26 +19,21 @@ function getHost(name: string): string { switch(name) { case "mainnet": return "eth-mainnet.alchemyapi.io"; - case "ropsten": - return "eth-ropsten.alchemyapi.io"; - case "rinkeby": - return "eth-rinkeby.alchemyapi.io"; case "goerli": - return "eth-goerli.alchemyapi.io"; - case "kovan": - return "eth-kovan.alchemyapi.io"; + return "eth-goerli.g.alchemy.com"; + + case "arbitrum": + return "arb-mainnet.g.alchemy.com"; + case "arbitrum-goerli": + return "arb-goerli.g.alchemy.com"; case "matic": return "polygon-mainnet.g.alchemy.com"; case "maticmum": return "polygon-mumbai.g.alchemy.com"; - case "arbitrum": - return "arb-mainnet.g.alchemy.com"; - case "arbitrum-rinkeby": - return "arb-rinkeby.g.alchemy.com"; case "optimism": return "opt-mainnet.g.alchemy.com"; - case "optimism-kovan": - return "opt-kovan.g.alchemy.com"; + case "optimism-goerli": + return "opt-goerli.g.alchemy.com"; } return throwArgumentError("unsupported network", "network", name); @@ -67,8 +63,11 @@ export class AlchemyProvider extends JsonRpcProvider implements CommunityResourc // https://docs.alchemy.com/reference/trace-transaction if (req.method === "getTransactionResult") { - const trace = await this.send("trace_transaction", [ req.hash ]); - if (trace == null) { return null; } + const { trace, tx } = await resolveProperties({ + trace: this.send("trace_transaction", [ req.hash ]), + tx: this.getTransaction(req.hash) + }); + if (trace == null || tx == null) { return null; } let data: undefined | string; let error = false; @@ -80,7 +79,12 @@ export class AlchemyProvider extends JsonRpcProvider implements CommunityResourc if (data) { if (error) { throwError("an error occurred during transaction executions", "CALL_EXCEPTION", { - data + action: "getTransactionResult", + data, + reason: null, + transaction: tx, + invocation: null, + revert: null // @TODO }); } return data; diff --git a/src.ts/providers/provider-browser.ts b/src.ts/providers/provider-browser.ts new file mode 100644 index 000000000..856147403 --- /dev/null +++ b/src.ts/providers/provider-browser.ts @@ -0,0 +1,120 @@ +import { assertArgument } from "../utils/index.js"; + +import { JsonRpcApiPollingProvider } from "./provider-jsonrpc.js"; + +import type { + JsonRpcError, JsonRpcPayload, JsonRpcResult, + JsonRpcSigner +} from "./provider-jsonrpc.js"; +import type { Networkish } from "./network.js"; + + +export interface Eip1193Provider { + request(request: { method: string, params?: Array | Record }): Promise; +}; + +export type DebugEventJsonRpcApiProvider = { + action: "sendEip1193Payload", + payload: { method: string, params: Array } +} | { + action: "receiveEip1193Result", + result: any +} | { + action: "receiveEip1193Error", + error: Error +}; + + + +export class BrowserProvider extends JsonRpcApiPollingProvider { + #request: (method: string, params: Array | Record) => Promise; + + constructor(ethereum: Eip1193Provider, network?: Networkish) { + super(network, { batchMaxCount: 1 }); + + this.#request = async (method: string, params: Array | Record) => { + const payload = { method, params }; + this.emit("debug", { action: "sendEip1193Request", payload }); + try { + const result = await ethereum.request(payload); + this.emit("debug", { action: "receiveEip1193Result", result }); + return result; + } catch (e: any) { + const error = new Error(e.message); + (error).code = e.code; + (error).data = e.data; + (error).payload = payload; + this.emit("debug", { action: "receiveEip1193Error", error }); + throw error; + } + }; + } + + async send(method: string, params: Array | Record): Promise { + await this._start(); + + return await super.send(method, params); + } + + async _send(payload: JsonRpcPayload | Array): Promise> { + assertArgument(!Array.isArray(payload), "EIP-1193 does not support batch request", "payload", payload); + + try { + const result = await this.#request(payload.method, payload.params || [ ]); + return [ { id: payload.id, result } ]; + } catch (e: any) { + return [ { + id: payload.id, + error: { code: e.code, data: e.data, message: e.message } + } ]; + } + } + + getRpcError(payload: JsonRpcPayload, error: JsonRpcError): Error { + + error = JSON.parse(JSON.stringify(error)); + + // EIP-1193 gives us some machine-readable error codes, so rewrite + // them into + switch (error.error.code || -1) { + case 4001: + error.error.message = `ethers-user-denied: ${ error.error.message }`; + break; + case 4200: + error.error.message = `ethers-unsupported: ${ error.error.message }`; + break; + } + + return super.getRpcError(payload, error); + } + + async hasSigner(address: number | string): Promise { + if (address == null) { address = 0; } + + const accounts = await this.send("eth_accounts", [ ]); + if (typeof(address) === "number") { + return (accounts.length > address); + } + + address = address.toLowerCase(); + return accounts.filter((a: string) => (a.toLowerCase() === address)).length !== 0; + } + + async getSigner(address?: number | string): Promise { + if (address == null) { address = 0; } + + if (!(await this.hasSigner(address))) { + try { + //const resp = + await this.#request("eth_requestAccounts", [ ]); + //console.log("RESP", resp); + + } catch (error: any) { + const payload = error.payload; + throw this.getRpcError(payload, { id: payload.id, error }); + } + } + + return await super.getSigner(address); + } +} diff --git a/src.ts/providers/provider-etherscan-base.ts b/src.ts/providers/provider-etherscan-base.ts index f6529ba74..3a82b4c47 100644 --- a/src.ts/providers/provider-etherscan-base.ts +++ b/src.ts/providers/provider-etherscan-base.ts @@ -1,3 +1,4 @@ +import { getBuiltinCallException } from "../abi/index.js"; import { accessListify } from "../transaction/index.js"; import { defineProperties, @@ -82,14 +83,23 @@ export class BaseEtherscanProvider extends AbstractProvider { switch(this.network.name) { case "mainnet": return "https:/\/api.etherscan.io"; - case "ropsten": - return "https:/\/api-ropsten.etherscan.io"; - case "rinkeby": - return "https:/\/api-rinkeby.etherscan.io"; - case "kovan": - return "https:/\/api-kovan.etherscan.io"; case "goerli": return "https:/\/api-goerli.etherscan.io"; + case "sepolia": + return "https:/\/api-sepolia.etherscan.io"; + + case "arbitrum": + return "https:/\/api.arbiscan.io"; + case "arbitrum-goerli": + return "https:/\/api-goerli.arbiscan.io"; + case "matic": + return "https:/\/api.polygonscan.com"; + case "maticmum": + return "https:/\/api-testnet.polygonscan.com"; + case "optimism": + return "https:/\/api-optimistic.etherscan.io"; + case "optimism-goerli": + return "https:/\/api-goerli-optimistic.etherscan.io"; default: } @@ -246,6 +256,13 @@ export class BaseEtherscanProvider extends AbstractProvider { _checkError(req: PerformActionRequest, error: Error, transaction: any): never { + if (req.method === "call" || req.method === "estimateGas") { + if (error.message.match(/execution reverted/i)) { + const e = getBuiltinCallException(req.method, req.transaction, (error).data); + e.info = { request: req, error } + throw e; + } + } /* let body = ""; if (isError(error, Logger.Errors.SERVER_ERROR) && error.response && error.response.hasBody()) { diff --git a/src.ts/providers/provider-infura.ts b/src.ts/providers/provider-infura.ts index 0c274dc7c..9ce985e6c 100644 --- a/src.ts/providers/provider-infura.ts +++ b/src.ts/providers/provider-infura.ts @@ -18,26 +18,23 @@ function getHost(name: string): string { switch(name) { case "mainnet": return "mainnet.infura.io"; - case "ropsten": - return "ropsten.infura.io"; - case "rinkeby": - return "rinkeby.infura.io"; - case "kovan": - return "kovan.infura.io"; case "goerli": return "goerli.infura.io"; + case "sepolia": + return "sepolia.infura.io"; + + case "arbitrum": + return "arbitrum-mainnet.infura.io"; + case "arbitrum-goerli": + return "arbitrum-goerli.infura.io"; case "matic": return "polygon-mainnet.infura.io"; case "maticmum": return "polygon-mumbai.infura.io"; case "optimism": return "optimism-mainnet.infura.io"; - case "optimism-kovan": - return "optimism-kovan.infura.io"; - case "arbitrum": - return "arbitrum-mainnet.infura.io"; - case "arbitrum-rinkeby": - return "arbitrum-rinkeby.infura.io"; + case "optimism-goerli": + return "optimism-goerli.infura.io"; } return throwArgumentError("unsupported network", "network", name); diff --git a/src.ts/providers/provider-jsonrpc.ts b/src.ts/providers/provider-jsonrpc.ts index 8fc6454eb..bffba381d 100644 --- a/src.ts/providers/provider-jsonrpc.ts +++ b/src.ts/providers/provider-jsonrpc.ts @@ -3,6 +3,7 @@ // https://playground.open-rpc.org/?schemaUrl=https://raw.githubusercontent.com/ethereum/eth1.0-apis/assembled-spec/openrpc.json&uiSchema%5BappBar%5D%5Bui:splitView%5D=true&uiSchema%5BappBar%5D%5Bui:input%5D=false&uiSchema%5BappBar%5D%5Bui:examplesDropdown%5D=false +import { getBuiltinCallException } from "../abi/index.js"; import { getAddress, resolveAddress } from "../address/index.js"; import { TypedDataEncoder } from "../hash/index.js"; import { accessListify } from "../transaction/index.js"; @@ -295,7 +296,7 @@ export class JsonRpcSigner extends AbstractSigner { } const hexTx = this.provider.getRpcTransaction(tx); - return await this.provider.send("eth_sign_Transaction", [ hexTx ]); + return await this.provider.send("eth_signTransaction", [ hexTx ]); } @@ -782,39 +783,114 @@ export class JsonRpcApiProvider extends AbstractProvider { * that different nodes return, coercing them into a machine-readable * standardized error. */ - getRpcError(payload: JsonRpcPayload, error: JsonRpcError): Error { + getRpcError(payload: JsonRpcPayload, _error: JsonRpcError): Error { const { method } = payload; + const { error } = _error; - if (method === "eth_call") { - const transaction = >((payload).params[0]); - + if (method === "eth_call" || method === "eth_estimateGas") { const result = spelunkData(error); + + const e = getBuiltinCallException( + (method === "eth_call") ? "call": "estimateGas", + ((payload).params[0]), + (result ? result.data: null) + ); + e.info = { error, payload }; + return e; +/* + let message = "missing revert data during JSON-RPC call"; + + const action = <"call" | "estimateGas" | "unknown">(({ eth_call: "call", eth_estimateGas: "estimateGas" })[method] || "unknown"); + let data: null | string = null; + let reason: null | string = null; + const transaction = <{ from: string, to: string, data: string }>((payload).params[0]); + const invocation = null; + let revert: null | { signature: string, name: string, args: Array } = null; + if (result) { // @TODO: Extract errorSignature, errorName, errorArgs, reason if // it is Error(string) or Panic(uint25) - return makeError("execution reverted during JSON-RPC call", "CALL_EXCEPTION", { - data: result.data, - transaction - }); + message = "execution reverted during JSON-RPC call"; + data = result.data; + + let bytes = getBytes(data); + if (bytes.length % 32 !== 4) { + message += " (could not parse reason; invalid data length)"; + + } else if (data.substring(0, 10) === "0x08c379a0") { + // Error(string) + try { + if (bytes.length < 68) { throw new Error("bad length"); } + bytes = bytes.slice(4); + const pointer = getNumber(hexlify(bytes.slice(0, 32))); + bytes = bytes.slice(pointer); + if (bytes.length < 32) { throw new Error("overrun"); } + const length = getNumber(hexlify(bytes.slice(0, 32))); + bytes = bytes.slice(32); + if (bytes.length < length) { throw new Error("overrun"); } + reason = toUtf8String(bytes.slice(0, length)); + revert = { + signature: "Error(string)", + name: "Error", + args: [ reason ] + }; + message += `: ${ JSON.stringify(reason) }`; + + } catch (error) { + console.log(error); + message += " (could not parse reason; invalid data length)"; + } + + } else if (data.substring(0, 10) === "0x4e487b71") { + // Panic(uint256) + try { + if (bytes.length !== 36) { throw new Error("bad length"); } + const arg = getNumber(hexlify(bytes.slice(4))); + revert = { + signature: "Panic(uint256)", + name: "Panic", + args: [ arg ] + }; + reason = `Panic due to ${ PanicReasons.get(Number(arg)) || "UNKNOWN" }(${ arg })`; + message += `: ${ reason }`; + } catch (error) { + console.log(error); + message += " (could not parse panic reason)"; + } + } } - return makeError("missing revert data during JSON-RPC call", "CALL_EXCEPTION", { - data: "0x", transaction, info: { error } + return makeError(message, "CALL_EXCEPTION", { + action, data, reason, transaction, invocation, revert, + info: { payload, error } }); + */ } + // Only estimateGas and call can return arbitrary contract-defined text, so now we + // we can process text safely. + const message = JSON.stringify(spelunkMessage(error)); - if (method === "eth_estimateGas") { - const transaction = >((payload).params[0]); + if (typeof(error.message) === "string" && error.message.match(/user denied|ethers-user-denied/i)) { + const actionMap: Record = { + eth_sign: "signMessage", + personal_sign: "signMessage", + eth_signTypedData_v4: "signTypedData", + eth_signTransaction: "signTransaction", + eth_sendTransaction: "sendTransaction", + eth_requestAccounts: "requestAccess", + wallet_requestAccounts: "requestAccess", + }; - if (message.match(/gas required exceeds allowance|always failing transaction|execution reverted/)) { - return makeError("cannot estimate gas; transaction may fail or may require manual gas limit", "UNPREDICTABLE_GAS_LIMIT", { - transaction - }); - } + return makeError(`user rejected action`, "ACTION_REJECTED", { + action: (actionMap[method] || "unknown") , + reason: "rejected", + info: { payload, error } + }); } + if (method === "eth_sendRawTransaction" || method === "eth_sendTransaction") { const transaction = >((payload).params[0]); @@ -840,6 +916,12 @@ export class JsonRpcApiProvider extends AbstractProvider { } } + if (message.match(/the method .* does not exist/i)) { + return makeError("unsupported operation", "UNSUPPORTED_OPERATION", { + operation: payload.method + }); + } + return makeError("could not coalesce error", "UNKNOWN_ERROR", { error }); } @@ -893,7 +975,7 @@ export class JsonRpcApiProvider extends AbstractProvider { // Account index if (typeof(address) === "number") { const accounts = >(await accountsPromise); - if (address > accounts.length) { throw new Error("no such account"); } + if (address >= accounts.length) { throw new Error("no such account"); } return new JsonRpcSigner(this, accounts[address]); } @@ -914,6 +996,37 @@ export class JsonRpcApiProvider extends AbstractProvider { } } +export class JsonRpcApiPollingProvider extends JsonRpcApiProvider { + #pollingInterval: number; + constructor(network?: Networkish, options?: JsonRpcApiProviderOptions) { + super(network, options); + + this.#pollingInterval = 4000; + } + + _getSubscriber(sub: Subscription): Subscriber { + const subscriber = super._getSubscriber(sub); + if (isPollable(subscriber)) { + subscriber.pollingInterval = this.#pollingInterval; + } + return subscriber; + } + + /** + * The polling interval (default: 4000 ms) + */ + get pollingInterval(): number { return this.#pollingInterval; } + set pollingInterval(value: number) { + if (!Number.isInteger(value) || value < 0) { throw new Error("invalid interval"); } + this.#pollingInterval = value; + this._forEachSubscriber((sub) => { + if (isPollable(sub)) { + sub.pollingInterval = this.#pollingInterval; + } + }); + } +} + /** * The JsonRpcProvider is one of the most common Providers, * which performs all operations over HTTP (or HTTPS) requests. @@ -922,11 +1035,9 @@ export class JsonRpcApiProvider extends AbstractProvider { * number; when it advances, all block-base events are then checked * for updates. */ -export class JsonRpcProvider extends JsonRpcApiProvider { +export class JsonRpcProvider extends JsonRpcApiPollingProvider { #connect: FetchRequest; - #pollingInterval: number; - constructor(url?: string | FetchRequest, network?: Networkish, options?: JsonRpcApiProviderOptions) { if (url == null) { url = "http:/\/localhost:8545"; } super(network, options); @@ -936,8 +1047,6 @@ export class JsonRpcProvider extends JsonRpcApiProvider { } else { this.#connect = url.clone(); } - - this.#pollingInterval = 4000; } _getConnection(): FetchRequest { @@ -966,20 +1075,6 @@ export class JsonRpcProvider extends JsonRpcApiProvider { return resp; } - - /** - * The polling interval (default: 4000 ms) - */ - get pollingInterval(): number { return this.#pollingInterval; } - set pollingInterval(value: number) { - if (!Number.isInteger(value) || value < 0) { throw new Error("invalid interval"); } - this.#pollingInterval = value; - this._forEachSubscriber((sub) => { - if (isPollable(sub)) { - sub.pollingInterval = this.#pollingInterval; - } - }); - } } function spelunkData(value: any): null | { message: string, data: string } { diff --git a/src.ts/providers/provider.ts b/src.ts/providers/provider.ts index 0a7ec8cec..36776affa 100644 --- a/src.ts/providers/provider.ts +++ b/src.ts/providers/provider.ts @@ -124,13 +124,13 @@ export function copyRequest(req: TransactionRequest): PreparedTransactionRequest if (req.data) { result.data = hexlify(req.data); } const bigIntKeys = "chainId,gasLimit,gasPrice,maxFeePerGas, maxPriorityFeePerGas,value".split(/,/); - for (const key in bigIntKeys) { + for (const key of bigIntKeys) { if (!(key in req) || (req)[key] == null) { continue; } result[key] = getBigInt((req)[key], `request.${ key }`); } const numberKeys = "type,nonce".split(/,/); - for (const key in numberKeys) { + for (const key of numberKeys) { if (!(key in req) || (req)[key] == null) { continue; } result[key] = getNumber((req)[key], `request.${ key }`); }