From 49f71574f4799d685a5ae8fd24fe1134f752d70a Mon Sep 17 00:00:00 2001 From: Richard Moore Date: Wed, 16 Sep 2020 02:19:28 -0400 Subject: [PATCH] More robust blockchain error detection (#1047) --- .../providers/src.ts/etherscan-provider.ts | 58 ++++++---- .../providers/src.ts/json-rpc-provider.ts | 93 +++++++--------- packages/tests/src.ts/test-providers.ts | 104 ++++++++++++++++++ 3 files changed, 179 insertions(+), 76 deletions(-) diff --git a/packages/providers/src.ts/etherscan-provider.ts b/packages/providers/src.ts/etherscan-provider.ts index 50f62d1450..6d76cea597 100644 --- a/packages/providers/src.ts/etherscan-provider.ts +++ b/packages/providers/src.ts/etherscan-provider.ts @@ -85,15 +85,45 @@ function checkLogTag(blockTag: string): number | "latest" { const defaultApiKey = "9D13ZE7XSBTJ94N9BNJ2MA33VMAY2YPIRB"; -function checkGasError(error: any, transaction: any): never { +function checkError(method: string, error: any, transaction: any): never { + + // Get the message from any nested error structure let message = error.message; - if (error.code === Logger.errors.SERVER_ERROR && error.error && typeof(error.error.message) === "string") { - message = error.error.message; + if (error.code === Logger.errors.SERVER_ERROR) { + if (error.error && typeof(error.error.message) === "string") { + message = error.error.message; + } else if (typeof(error.body) === "string") { + message = error.body; + } else if (typeof(error.responseText) === "string") { + message = error.responseText; + } + } + message = (message || "").toLowerCase(); + + // "Insufficient funds. The account you tried to send transaction from does not have enough funds. Required 21464000000000 and got: 0" + if (message.match(/insufficient funds/)) { + logger.throwError("insufficient funds for intrinsic transaction cost", Logger.errors.INSUFFICIENT_FUNDS, { + error, method, transaction + }); + } + + // "Transaction with the same hash was already imported." + if (message.match(/same hash was already imported|transaction nonce is too low/)) { + logger.throwError("nonce has already been used", Logger.errors.NONCE_EXPIRED, { + error, method, transaction + }); + } + + // "Transaction gas price is too low. There is another transaction with same nonce in the queue. Try increasing the gas price or incrementing the nonce." + if (message.match(/another transaction with same nonce/)) { + logger.throwError("replacement fee too low", Logger.errors.REPLACEMENT_UNDERPRICED, { + error, method, transaction + }); } if (message.match(/execution failed due to an exception/)) { logger.throwError("cannot estimate gas; transaction may fail or may require manual gas limit", Logger.errors.UNPREDICTABLE_GAS_LIMIT, { - error, transaction + error, method, transaction }); } @@ -215,21 +245,7 @@ export class EtherscanProvider extends BaseProvider{ url += "/api?module=proxy&action=eth_sendRawTransaction&hex=" + params.signedTransaction; url += apiKey; return get(url).catch((error) => { - if (error.responseText) { - // "Insufficient funds. The account you tried to send transaction from does not have enough funds. Required 21464000000000 and got: 0" - if (error.responseText.toLowerCase().indexOf("insufficient funds") >= 0) { - logger.throwError("insufficient funds", Logger.errors.INSUFFICIENT_FUNDS, { }); - } - // "Transaction with the same hash was already imported." - if (error.responseText.indexOf("same hash was already imported") >= 0) { - logger.throwError("nonce has already been used", Logger.errors.NONCE_EXPIRED, { }); - } - // "Transaction gas price is too low. There is another transaction with same nonce in the queue. Try increasing the gas price or incrementing the nonce." - if (error.responseText.indexOf("another transaction with same nonce") >= 0) { - logger.throwError("replacement fee too low", Logger.errors.REPLACEMENT_UNDERPRICED, { }); - } - } - throw error; + return checkError("sendTransaction", error, params.signedTransaction); }); case "getBlock": @@ -268,7 +284,7 @@ export class EtherscanProvider extends BaseProvider{ try { return await get(url); } catch (error) { - return checkGasError(error, params.transaction); + return checkError("call", error, params.transaction); } } @@ -280,7 +296,7 @@ export class EtherscanProvider extends BaseProvider{ try { return await get(url); } catch (error) { - return checkGasError(error, params.transaction); + return checkError("estimateGas", error, params.transaction); } } diff --git a/packages/providers/src.ts/json-rpc-provider.ts b/packages/providers/src.ts/json-rpc-provider.ts index cf331cfc31..58f7a87971 100644 --- a/packages/providers/src.ts/json-rpc-provider.ts +++ b/packages/providers/src.ts/json-rpc-provider.ts @@ -18,16 +18,49 @@ const logger = new Logger(version); import { BaseProvider, Event } from "./base-provider"; -const ErrorGas = [ "call", "estimateGas" ]; +const errorGas = [ "call", "estimateGas" ]; -function getMessage(error: any): string { +function checkError(method: string, error: any, params: any): never { let message = error.message; if (error.code === Logger.errors.SERVER_ERROR && error.error && typeof(error.error.message) === "string") { message = error.error.message; + } else if (typeof(error.body) === "string") { + message = error.body; } else if (typeof(error.responseText) === "string") { message = error.responseText; } - return message || ""; + message = (message || "").toLowerCase(); + + const transaction = params.transaction || params.signedTransaction; + + // "insufficient funds for gas * price + value + cost(data)" + if (message.match(/insufficient funds/)) { + logger.throwError("insufficient funds for intrinsic transaction cost", Logger.errors.INSUFFICIENT_FUNDS, { + error, method, transaction + }); + } + + // "nonce too low" + if (message.match(/nonce too low/)) { + logger.throwError("nonce has already been used", Logger.errors.NONCE_EXPIRED, { + error, method, transaction + }); + } + + // "replacement transaction underpriced" + if (message.match(/replacement transaction underpriced/)) { + logger.throwError("replacement fee too low", Logger.errors.REPLACEMENT_UNDERPRICED, { + error, method, transaction + }); + } + + if (errorGas.indexOf(method) >= 0 && message.match(/gas required exceeds allowance|always failing transaction|execution reverted/)) { + logger.throwError("cannot estimate gas; transaction may fail or may require manual gas limit", Logger.errors.UNPREDICTABLE_GAS_LIMIT, { + error, method, transaction + }); + } + + throw error; } function timer(timeout: number): Promise { @@ -145,25 +178,7 @@ export class JsonRpcSigner extends Signer { return this.provider.send("eth_sendTransaction", [ hexTx ]).then((hash) => { return hash; }, (error) => { - if (error.responseText) { - // See: JsonRpcProvider.sendTransaction (@TODO: Expose a ._throwError??) - if (error.responseText.indexOf("insufficient funds") >= 0) { - logger.throwError("insufficient funds", Logger.errors.INSUFFICIENT_FUNDS, { - transaction: tx - }); - } - if (error.responseText.indexOf("nonce too low") >= 0) { - logger.throwError("nonce has already been used", Logger.errors.NONCE_EXPIRED, { - transaction: tx - }); - } - if (error.responseText.indexOf("replacement transaction underpriced") >= 0) { - logger.throwError("replacement fee too low", Logger.errors.REPLACEMENT_UNDERPRICED, { - transaction: tx - }); - } - } - throw error; + return checkError("sendTransaction", error, hexTx); }); }); } @@ -420,42 +435,10 @@ export class JsonRpcProvider extends BaseProvider { logger.throwError(method + " not implemented", Logger.errors.NOT_IMPLEMENTED, { operation: method }); } - // We need a little extra logic to process errors from sendTransaction - if (method === "sendTransaction") { - try { - return await this.send(args[0], args[1]); - } catch (error) { - const message = getMessage(error); - - // "insufficient funds for gas * price + value" - if (message.match(/insufficient funds/)) { - logger.throwError("insufficient funds", Logger.errors.INSUFFICIENT_FUNDS, { }); - } - - // "nonce too low" - if (message.match(/nonce too low/)) { - logger.throwError("nonce has already been used", Logger.errors.NONCE_EXPIRED, { }); - } - - // "replacement transaction underpriced" - if (message.match(/replacement transaction underpriced/)) { - logger.throwError("replacement fee too low", Logger.errors.REPLACEMENT_UNDERPRICED, { }); - } - - throw error; - } - } - try { return await this.send(args[0], args[1]) } catch (error) { - if (ErrorGas.indexOf(method) >= 0 && getMessage(error).match(/gas required exceeds allowance|always failing transaction|execution reverted/)) { - logger.throwError("cannot estimate gas; transaction may fail or may require manual gas limit", Logger.errors.UNPREDICTABLE_GAS_LIMIT, { - transaction: params.transaction, - error: error - }); - } - throw error; + return checkError(method, error, params); } } diff --git a/packages/tests/src.ts/test-providers.ts b/packages/tests/src.ts/test-providers.ts index e9f1d230de..e844a37b14 100644 --- a/packages/tests/src.ts/test-providers.ts +++ b/packages/tests/src.ts/test-providers.ts @@ -564,6 +564,110 @@ function testProvider(providerName: string, networkName: string) { }); }); + if (networkName === "ropsten") { + + it("throws correct NONCE_EXPIRED errors", async function() { + this.timeout(60000); + + try { + const tx = await provider.sendTransaction("0xf86480850218711a0082520894000000000000000000000000000000000000000002801ba038aaddcaaae7d3fa066dfd6f196c8348e1bb210f2c121d36cb2c24ef20cea1fba008ae378075d3cd75aae99ab75a70da82161dffb2c8263dabc5d8adecfa9447fa"); + console.log(tx); + assert.ok(false); + } catch (error) { + assert.equal(error.code, ethers.utils.Logger.errors.NONCE_EXPIRED); + } + + await waiter(delay); + }); + + it("throws correct INSUFFICIENT_FUNDS errors", async function() { + this.timeout(60000); + + const txProps = { + to: "0x8ba1f109551bD432803012645Ac136ddd64DBA72", + gasPrice: 9000000000, + gasLimit: 21000, + value: 1 + }; + + const wallet = ethers.Wallet.createRandom(); + const tx = await wallet.signTransaction(txProps); + + try { + await provider.sendTransaction(tx); + assert.ok(false); + } catch (error) { + assert.equal(error.code, ethers.utils.Logger.errors.INSUFFICIENT_FUNDS); + } + + await waiter(delay); + }); + + it("throws correct INSUFFICIENT_FUNDS errors (signer)", async function() { + this.timeout(60000); + + const txProps = { + to: "0x8ba1f109551bD432803012645Ac136ddd64DBA72", + gasPrice: 9000000000, + gasLimit: 21000, + value: 1 + }; + + const wallet = ethers.Wallet.createRandom().connect(provider); + + try { + await wallet.sendTransaction(txProps); + assert.ok(false); + } catch (error) { + assert.equal(error.code, ethers.utils.Logger.errors.INSUFFICIENT_FUNDS); + } + + await waiter(delay); + }); + + it("throws correct UNPREDICTABLE_GAS_LIMIT errors", async function() { + this.timeout(60000); + + try { + await provider.estimateGas({ + to: "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e" // ENS; no payable fallback + }); + assert.ok(false); + } catch (error) { + assert.equal(error.code, ethers.utils.Logger.errors.UNPREDICTABLE_GAS_LIMIT); + } + + await waiter(delay); + }); + + it("sends a transaction", async function() { + this.timeout(360000); + + const wallet = ethers.Wallet.createRandom().connect(provider); + const funder = await ethers.utils.fetchJson(`https:/\/api.ethers.io/api/v1/?action=fundAccount&address=${ wallet.address.toLowerCase() }`); + await provider.waitForTransaction(funder.hash); + + const addr = "0x8210357f377E901f18E45294e86a2A32215Cc3C9"; + const gasPrice = 9000000000; + + let balance = await provider.getBalance(wallet.address); + assert.ok(balance.eq(ethers.utils.parseEther("3.141592653589793238")), "balance is pi after funding"); + + const tx = await wallet.sendTransaction({ + to: addr, + gasPrice: gasPrice, + value: balance.sub(21000 * gasPrice) + }); + + await tx.wait(); + + balance = await provider.getBalance(wallet.address); + assert.ok(balance.eq(ethers.constants.Zero), "balance is zero after after sweeping"); + + await waiter(delay); + }); + } + // Obviously many more cases to add here // - getTransactionCount