More robust blockchain error detection (#1047)

This commit is contained in:
Richard Moore 2020-09-16 02:19:28 -04:00
parent 9ee685df46
commit 49f71574f4
No known key found for this signature in database
GPG Key ID: 665176BE8E9DC651
3 changed files with 179 additions and 76 deletions

@ -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);
}
}

@ -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<any> {
@ -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);
}
}

@ -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