Skip to content

Commit

Permalink
More robust blockchain error detection (#1047)
Browse files Browse the repository at this point in the history
  • Loading branch information
ricmoo committed Sep 16, 2020
1 parent 9ee685d commit 49f7157
Show file tree
Hide file tree
Showing 3 changed files with 179 additions and 76 deletions.
58 changes: 37 additions & 21 deletions packages/providers/src.ts/etherscan-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
}

Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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);
}
}

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

Expand Down
93 changes: 38 additions & 55 deletions packages/providers/src.ts/json-rpc-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down Expand Up @@ -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);
});
});
}
Expand Down Expand Up @@ -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);
}
}

Expand Down
104 changes: 104 additions & 0 deletions packages/tests/src.ts/test-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 49f7157

Please sign in to comment.