diff --git a/packages/core/src/internal/errors-list.ts b/packages/core/src/internal/errors-list.ts index 96210e61d..a0697bdac 100644 --- a/packages/core/src/internal/errors-list.ts +++ b/packages/core/src/internal/errors-list.ts @@ -183,6 +183,19 @@ export const ERRORS = { number: 407, message: "The calculated max fee per gas exceeds the configured limit.", }, + INSUFFICIENT_FUNDS_FOR_TRANSFER: { + number: 408, + message: + "Account %sender% has insufficient funds to transfer %amount% wei", + }, + INSUFFICIENT_FUNDS_FOR_DEPLOY: { + number: 409, + message: "Account %sender% has insufficient funds to deploy the contract", + }, + GAS_ESTIMATION_FAILED: { + number: 410, + message: "Gas estimation failed: %error%", + }, }, RECONCILIATION: { INVALID_EXECUTION_STATUS: { diff --git a/packages/core/src/internal/execution/future-processor/handlers/send-transaction.ts b/packages/core/src/internal/execution/future-processor/handlers/send-transaction.ts index 1df9e5222..d1805c3e2 100644 --- a/packages/core/src/internal/execution/future-processor/handlers/send-transaction.ts +++ b/packages/core/src/internal/execution/future-processor/handlers/send-transaction.ts @@ -12,8 +12,6 @@ import { CallStrategyGenerator, DeploymentStrategyGenerator, ExecutionStrategy, - OnchainInteractionResponseType, - SIMULATION_SUCCESS_SIGNAL_TYPE, } from "../../types/execution-strategy"; import { CallExecutionStateCompleteMessage, @@ -23,6 +21,7 @@ import { TransactionSendMessage, } from "../../types/messages"; import { NetworkInteractionType } from "../../types/network-interaction"; +import { decodeSimulationResult } from "../helpers/decode-simulation-result"; import { createExecutionStateCompleteMessageForExecutionsWithOnchainInteractions } from "../helpers/messages-helpers"; import { TRANSACTION_SENT_TYPE, @@ -87,27 +86,8 @@ export async function sendTransaction( jsonRpcClient, exState.from, lastNetworkInteraction, - async (_sender: string) => nonceManager.getNextNonce(_sender), - async (simulationResult) => { - const response = await strategyGenerator.next({ - type: OnchainInteractionResponseType.SIMULATION_RESULT, - result: simulationResult, - }); - - assertIgnitionInvariant( - response.value.type === SIMULATION_SUCCESS_SIGNAL_TYPE || - response.value.type === - ExecutionResultType.STRATEGY_SIMULATION_ERROR || - response.value.type === ExecutionResultType.SIMULATION_ERROR, - `Invalid response received from strategy after a simulation was run before sending a transaction for ExecutionState ${exState.id}` - ); - - if (response.value.type === SIMULATION_SUCCESS_SIGNAL_TYPE) { - return undefined; - } - - return response.value; - } + nonceManager, + decodeSimulationResult(strategyGenerator, exState) ); // If the transaction failed during simulation, we need to revert the nonce allocation diff --git a/packages/core/src/internal/execution/future-processor/helpers/decode-simulation-result.ts b/packages/core/src/internal/execution/future-processor/helpers/decode-simulation-result.ts new file mode 100644 index 000000000..473b01521 --- /dev/null +++ b/packages/core/src/internal/execution/future-processor/helpers/decode-simulation-result.ts @@ -0,0 +1,52 @@ +import { assertIgnitionInvariant } from "../../../utils/assertions"; +import { + ExecutionResultType, + SimulationErrorExecutionResult, + StrategySimulationErrorExecutionResult, +} from "../../types/execution-result"; +import { + CallExecutionState, + DeploymentExecutionState, + SendDataExecutionState, +} from "../../types/execution-state"; +import { + CallStrategyGenerator, + DeploymentStrategyGenerator, + OnchainInteractionResponseType, + SIMULATION_SUCCESS_SIGNAL_TYPE, +} from "../../types/execution-strategy"; +import { RawStaticCallResult } from "../../types/jsonrpc"; + +export function decodeSimulationResult( + strategyGenerator: DeploymentStrategyGenerator | CallStrategyGenerator, + exState: + | DeploymentExecutionState + | CallExecutionState + | SendDataExecutionState +) { + return async ( + simulationResult: RawStaticCallResult + ): Promise< + | SimulationErrorExecutionResult + | StrategySimulationErrorExecutionResult + | undefined + > => { + const response = await strategyGenerator.next({ + type: OnchainInteractionResponseType.SIMULATION_RESULT, + result: simulationResult, + }); + + assertIgnitionInvariant( + response.value.type === SIMULATION_SUCCESS_SIGNAL_TYPE || + response.value.type === ExecutionResultType.STRATEGY_SIMULATION_ERROR || + response.value.type === ExecutionResultType.SIMULATION_ERROR, + `Invalid response received from strategy after a simulation was run before sending a transaction for ExecutionState ${exState.id}` + ); + + if (response.value.type === SIMULATION_SUCCESS_SIGNAL_TYPE) { + return undefined; + } + + return response.value; + }; +} diff --git a/packages/core/src/internal/execution/future-processor/helpers/network-interaction-execution.ts b/packages/core/src/internal/execution/future-processor/helpers/network-interaction-execution.ts index 50691c1f6..17d03e6d8 100644 --- a/packages/core/src/internal/execution/future-processor/helpers/network-interaction-execution.ts +++ b/packages/core/src/internal/execution/future-processor/helpers/network-interaction-execution.ts @@ -5,8 +5,11 @@ * @file */ +import { IgnitionError } from "../../../../errors"; +import { ERRORS } from "../../../errors-list"; import { assertIgnitionInvariant } from "../../../utils/assertions"; import { JsonRpcClient, TransactionParams } from "../../jsonrpc-client"; +import { NonceManager } from "../../nonce-management/json-rpc-nonce-manager"; import { SimulationErrorExecutionResult, StrategySimulationErrorExecutionResult, @@ -96,7 +99,7 @@ export async function sendTransactionForOnchainInteraction( client: JsonRpcClient, sender: string, onchainInteraction: OnchainInteraction, - getNonce: (sender: string) => Promise, + nonceManager: NonceManager, decodeSimulationResult: ( simulationResult: RawStaticCallResult ) => Promise< @@ -113,7 +116,8 @@ export async function sendTransactionForOnchainInteraction( nonce: number; } > { - const nonce = onchainInteraction.nonce ?? (await getNonce(sender)); + const nonce = + onchainInteraction.nonce ?? (await nonceManager.getNextNonce(sender)); const fees = await getNextTransactionFees(client, onchainInteraction); // TODO: Should we check the balance here? Before or after estimating gas? @@ -140,10 +144,6 @@ export async function sendTransactionForOnchainInteraction( // If the gas estimation failed, we simulate the transaction to get information // about why it failed. - // - // TODO: We are catching every error (e.g. network errors) here, which may be - // too broad and make the assertion below fail. We could try to catch only - // estimation errors. const failedEstimateGasSimulationResult = await client.call( paramsWithoutFees, "pending" @@ -153,12 +153,35 @@ export async function sendTransactionForOnchainInteraction( failedEstimateGasSimulationResult ); + if (decoded !== undefined) { + return decoded; + } + + // this is just for type inference assertIgnitionInvariant( - decoded !== undefined, - "Expected failed simulation after having failed to estimate gas" + error instanceof Error, + "Unexpected error type while resolving failed gas estimation" ); - return decoded; + // If the user has tried to transfer funds (i.e. m.send(...)) and they have insufficient funds + if (/insufficient funds for transfer/.test(error.message)) { + throw new IgnitionError( + ERRORS.EXECUTION.INSUFFICIENT_FUNDS_FOR_TRANSFER, + { sender, amount: estimateGasPrams.value.toString() } + ); + } + // if the user has insufficient funds to deploy the contract they're trying to deploy + else if (/contract creation code storage out of gas/.test(error.message)) { + throw new IgnitionError(ERRORS.EXECUTION.INSUFFICIENT_FUNDS_FOR_DEPLOY, { + sender, + }); + } + // catch-all error for all other errors + else { + throw new IgnitionError(ERRORS.EXECUTION.GAS_ESTIMATION_FAILED, { + error: error.message, + }); + } } const transactionParams: TransactionParams = { diff --git a/packages/core/test/execution/future-processor/helpers/network-interaction-execution.ts b/packages/core/test/execution/future-processor/helpers/network-interaction-execution.ts index 755773efb..c943c062a 100644 --- a/packages/core/test/execution/future-processor/helpers/network-interaction-execution.ts +++ b/packages/core/test/execution/future-processor/helpers/network-interaction-execution.ts @@ -4,7 +4,12 @@ import { GetTransactionRetryConfig, monitorOnchainInteraction, } from "../../../../src/internal/execution/future-processor/handlers/monitor-onchain-interaction"; -import { runStaticCall } from "../../../../src/internal/execution/future-processor/helpers/network-interaction-execution"; +import { decodeSimulationResult } from "../../../../src/internal/execution/future-processor/helpers/decode-simulation-result"; +import { + TRANSACTION_SENT_TYPE, + runStaticCall, + sendTransactionForOnchainInteraction, +} from "../../../../src/internal/execution/future-processor/helpers/network-interaction-execution"; import { Block, CallParams, @@ -12,13 +17,22 @@ import { JsonRpcClient, TransactionParams, } from "../../../../src/internal/execution/jsonrpc-client"; +import { NonceManager } from "../../../../src/internal/execution/nonce-management/json-rpc-nonce-manager"; import { TransactionTrackingTimer } from "../../../../src/internal/execution/transaction-tracking-timer"; +import { EvmExecutionResultTypes } from "../../../../src/internal/execution/types/evm-execution"; +import { + ExecutionResultType, + SimulationErrorExecutionResult, +} from "../../../../src/internal/execution/types/execution-result"; import { + CallExecutionState, DeploymentExecutionState, ExecutionSateType, ExecutionStatus, } from "../../../../src/internal/execution/types/execution-state"; +import { CallStrategyGenerator } from "../../../../src/internal/execution/types/execution-strategy"; import { + EIP1559NetworkFees, NetworkFees, RawStaticCallResult, Transaction, @@ -28,6 +42,7 @@ import { import { JournalMessageType } from "../../../../src/internal/execution/types/messages"; import { NetworkInteractionType, + OnchainInteraction, StaticCall, } from "../../../../src/internal/execution/types/network-interaction"; import { FutureType } from "../../../../src/types/module"; @@ -282,59 +297,442 @@ describe("Network interactions", () => { describe("sendTransactionForOnchainInteraction", () => { describe("First transaction", () => { - it("Should allocate a nonce for the onchain interaction's sender", async () => { - // TODO @alcuadrado - }); + class MockJsonRpcClient extends StubJsonRpcClient { + public async getNetworkFees(): Promise { + return { + maxFeePerGas: 0n, + maxPriorityFeePerGas: 0n, + }; + } + + public async estimateGas( + _transactionParams: EstimateGasParams + ): Promise { + return 0n; + } + + public async call( + _callParams: CallParams, + _blockTag: "latest" | "pending" + ): Promise { + return { + customErrorReported: false, + returnData: "0x", + success: true, + }; + } + + public async sendTransaction( + _transactionParams: TransactionParams + ): Promise { + return "0x1234"; + } + } + + class MockNonceManager implements NonceManager { + public calls: Record = {}; + + public async getNextNonce(_address: string): Promise { + this.calls[_address] = this.calls[_address] ?? 0; + this.calls[_address] += 1; + return this.calls[_address] - 1; + } + + public revertNonce(_sender: string): void { + throw new Error("Method not implemented."); + } + } it("Should use the recommended network fees", async () => { - // TODO @alcuadrado + class LocalMockJsonRpcClient extends MockJsonRpcClient { + public storedFees: EIP1559NetworkFees = {} as EIP1559NetworkFees; + + public async getNetworkFees(): Promise { + return { + maxFeePerGas: 100n, + maxPriorityFeePerGas: 50n, + }; + } + + public async sendTransaction( + _transactionParams: TransactionParams + ): Promise { + this.storedFees = _transactionParams.fees as EIP1559NetworkFees; + return "0x1234"; + } + } + + const client = new LocalMockJsonRpcClient(); + const nonceManager = new MockNonceManager(); + + const onchainInteraction: OnchainInteraction = { + to: exampleAccounts[1], + data: "0x", + value: 0n, + id: 1, + type: NetworkInteractionType.ONCHAIN_INTERACTION, + transactions: [], + shouldBeResent: false, + }; + + await sendTransactionForOnchainInteraction( + client, + exampleAccounts[0], + onchainInteraction, + nonceManager, + async () => undefined + ); + + assert.equal(client.storedFees.maxFeePerGas, 100n); + assert.equal(client.storedFees.maxPriorityFeePerGas, 50n); + }); + + describe("When allocating a nonce", () => { + it("Should allocate a nonce when the onchainInteraction doesn't have one", async () => { + const client = new MockJsonRpcClient(); + const nonceManager = new MockNonceManager(); + + const onchainInteraction: OnchainInteraction = { + to: exampleAccounts[1], + data: "0x", + value: 0n, + id: 1, + type: NetworkInteractionType.ONCHAIN_INTERACTION, + transactions: [], + shouldBeResent: false, + }; + + await sendTransactionForOnchainInteraction( + client, + exampleAccounts[0], + onchainInteraction, + nonceManager, + async () => undefined + ); + + assert.equal(nonceManager.calls[exampleAccounts[0]], 1); + }); + + it("Should use the onchainInteraction nonce if present", async () => { + class LocalMockJsonRpcClient extends MockJsonRpcClient { + public storedNonce: number | undefined; + + public async sendTransaction( + _transactionParams: TransactionParams + ): Promise { + this.storedNonce = _transactionParams.nonce; + return "0x1234"; + } + } + + const client = new LocalMockJsonRpcClient(); + const nonceManager = new MockNonceManager(); + + const onchainInteraction: OnchainInteraction = { + to: exampleAccounts[1], + data: "0x", + value: 0n, + nonce: 5, + id: 1, + type: NetworkInteractionType.ONCHAIN_INTERACTION, + transactions: [], + shouldBeResent: false, + }; + + await sendTransactionForOnchainInteraction( + client, + exampleAccounts[0], + onchainInteraction, + nonceManager, + async () => undefined + ); + + assert.equal(nonceManager.calls[exampleAccounts[0]], undefined); + assert.equal(client.storedNonce, 5); + }); }); describe("When the gas estimation succeeds", () => { describe("When the simulation fails", () => { it("Should return the decoded simulation error", async () => { - // TODO @alcuadrado + class LocalMockJsonRpcClient extends MockJsonRpcClient { + public async call( + _callParams: CallParams, + _blockTag: "latest" | "pending" + ): Promise { + return { + customErrorReported: true, + returnData: "0x1111", + success: false, + }; + } + } + + const client = new LocalMockJsonRpcClient(); + const nonceManager = new MockNonceManager(); + + const onchainInteraction: OnchainInteraction = { + to: exampleAccounts[1], + data: "0x", + value: 0n, + id: 1, + type: NetworkInteractionType.ONCHAIN_INTERACTION, + transactions: [], + shouldBeResent: false, + }; + + const mockStrategyGenerator = { + next(): { value: SimulationErrorExecutionResult } { + return { + value: { + type: ExecutionResultType.SIMULATION_ERROR, + error: { + type: EvmExecutionResultTypes.REVERT_WITH_REASON, + message: "mock error", + }, + }, + }; + }, + } as unknown as CallStrategyGenerator; + + const mockExecutionState = { + id: "test", + } as unknown as CallExecutionState; + + const result = await sendTransactionForOnchainInteraction( + client, + exampleAccounts[0], + onchainInteraction, + nonceManager, + decodeSimulationResult(mockStrategyGenerator, mockExecutionState) + ); + + // type casting + if ( + result.type !== ExecutionResultType.SIMULATION_ERROR || + result.error.type !== EvmExecutionResultTypes.REVERT_WITH_REASON + ) { + return assert.fail("Unexpected result type"); + } + + assert.equal(result.error.message, "mock error"); }); }); describe("When the simulation succeeds", () => { it("Should send the transaction and return its hash and nonce", async () => { - // TODO @alcuadrado + const client = new MockJsonRpcClient(); + const nonceManager = new MockNonceManager(); + + const onchainInteraction: OnchainInteraction = { + to: exampleAccounts[1], + data: "0x", + value: 0n, + id: 1, + type: NetworkInteractionType.ONCHAIN_INTERACTION, + transactions: [], + shouldBeResent: false, + }; + + const result = await sendTransactionForOnchainInteraction( + client, + exampleAccounts[0], + onchainInteraction, + nonceManager, + async () => undefined + ); + + // type casting + if (result.type !== TRANSACTION_SENT_TYPE) { + return assert.fail("Unexpected result type"); + } + + assert.equal(result.nonce, 0); + assert.equal(result.transaction.hash, "0x1234"); }); }); }); describe("When the gas estimation fails", () => { + class LocalMockJsonRpcClient extends MockJsonRpcClient { + public errorMessage: string = "testing failure case"; + + constructor(_errorMessage?: string) { + super(); + this.errorMessage = _errorMessage ?? this.errorMessage; + } + + public async estimateGas( + _transactionParams: EstimateGasParams + ): Promise { + throw new Error(this.errorMessage); + } + + public async call( + _callParams: CallParams, + _blockTag: "latest" | "pending" + ): Promise { + return { + customErrorReported: true, + returnData: "0x1111", + success: false, + }; + } + } + describe("When the simulation fails", () => { it("Should return the decoded simulation error", async () => { - // TODO @alcuadrado + const client = new LocalMockJsonRpcClient(); + const nonceManager = new MockNonceManager(); + + const onchainInteraction: OnchainInteraction = { + to: exampleAccounts[1], + data: "0x", + value: 0n, + id: 1, + type: NetworkInteractionType.ONCHAIN_INTERACTION, + transactions: [], + shouldBeResent: false, + }; + + const mockStrategyGenerator = { + next(): { value: SimulationErrorExecutionResult } { + return { + value: { + type: ExecutionResultType.SIMULATION_ERROR, + error: { + type: EvmExecutionResultTypes.REVERT_WITH_REASON, + message: "mock error", + }, + }, + }; + }, + } as unknown as CallStrategyGenerator; + + const mockExecutionState = { + id: "test", + } as unknown as CallExecutionState; + + const result = await sendTransactionForOnchainInteraction( + client, + exampleAccounts[0], + onchainInteraction, + nonceManager, + decodeSimulationResult(mockStrategyGenerator, mockExecutionState) + ); + + // type casting + if ( + result.type !== ExecutionResultType.SIMULATION_ERROR || + result.error.type !== EvmExecutionResultTypes.REVERT_WITH_REASON + ) { + return assert.fail("Unexpected result type"); + } + + assert.equal(result.error.message, "mock error"); }); }); describe("When the simulation succeeds", () => { - it("Should hit an invariant violation", async () => { - // TODO @alcuadrado + describe("When there are insufficient funds for a transfer", () => { + it("Should throw an error", async () => { + const client = new LocalMockJsonRpcClient( + "insufficient funds for transfer" + ); + const nonceManager = new MockNonceManager(); + + const onchainInteraction: OnchainInteraction = { + to: exampleAccounts[1], + data: "0x", + value: 0n, + id: 1, + type: NetworkInteractionType.ONCHAIN_INTERACTION, + transactions: [], + shouldBeResent: false, + }; + + await assert.isRejected( + sendTransactionForOnchainInteraction( + client, + exampleAccounts[0], + onchainInteraction, + nonceManager, + async () => undefined + ), + /^IGN408/ + ); + }); }); - }); - }); - }); - describe("Follow up transaction", () => { - it("Should reuse the nonce that the onchain interaction has, and not allocate a new one", async () => { - // TODO @alcuadrado - }); + describe("When there are insufficient funds for a deployment", () => { + it("Should throw an error", async () => { + const client = new LocalMockJsonRpcClient( + "contract creation code storage out of gas" + ); + const nonceManager = new MockNonceManager(); + + const onchainInteraction: OnchainInteraction = { + to: exampleAccounts[1], + data: "0x", + value: 0n, + id: 1, + type: NetworkInteractionType.ONCHAIN_INTERACTION, + transactions: [], + shouldBeResent: false, + }; + + await assert.isRejected( + sendTransactionForOnchainInteraction( + client, + exampleAccounts[0], + onchainInteraction, + nonceManager, + async () => undefined + ), + /^IGN409/ + ); + }); + }); - it("Should bump fees and also take recommended network fees into account", async () => { - // TODO @alcuadrado + describe("When the gas estimation fails for any other reason", () => { + it("Should throw an error", async () => { + const client = new LocalMockJsonRpcClient("unknown error"); + const nonceManager = new MockNonceManager(); + + const onchainInteraction: OnchainInteraction = { + to: exampleAccounts[1], + data: "0x", + value: 0n, + id: 1, + type: NetworkInteractionType.ONCHAIN_INTERACTION, + transactions: [], + shouldBeResent: false, + }; + + await assert.isRejected( + sendTransactionForOnchainInteraction( + client, + exampleAccounts[0], + onchainInteraction, + nonceManager, + async () => undefined + ), + /^IGN410/ + ); + }); + }); + }); }); + }); + }); - it("Should re-estimate the gas limit", async () => { - // TODO @alcuadrado - }); + describe("getNextTransactionFees", () => { + it("Should bump fees and also take recommended network fees into account", async () => { + // TODO @zoeyTM + }); - it("Should run a new simulation", async () => { - // TODO @alcuadrado - }); + it("Should re-estimate the gas limit", async () => { + // TODO @zoeyTM }); }); }); diff --git a/packages/hardhat-plugin/src/utils/shouldBeHardhatPluginError.ts b/packages/hardhat-plugin/src/utils/shouldBeHardhatPluginError.ts index 021c6f134..f6f69090f 100644 --- a/packages/hardhat-plugin/src/utils/shouldBeHardhatPluginError.ts +++ b/packages/hardhat-plugin/src/utils/shouldBeHardhatPluginError.ts @@ -9,10 +9,10 @@ import type { IgnitionError } from "@nomicfoundation/ignition-core"; * - If there's an exception that doesn't fit in either category, let's discuss it and review the categories. */ const whitelist = [ - 200, 201, 202, 203, 204, 403, 404, 405, 406, 407, 600, 601, 602, 700, 701, - 702, 703, 704, 705, 706, 707, 708, 709, 710, 711, 712, 713, 714, 715, 716, - 717, 718, 719, 720, 721, 722, 723, 724, 725, 726, 800, 900, 1000, 1001, 1002, - 1101, 1102, 1103, + 200, 201, 202, 203, 204, 403, 404, 405, 406, 407, 408, 409, 600, 601, 602, + 700, 701, 702, 703, 704, 705, 706, 707, 708, 709, 710, 711, 712, 713, 714, + 715, 716, 717, 718, 719, 720, 721, 722, 723, 724, 725, 726, 800, 900, 1000, + 1001, 1002, 1101, 1102, 1103, ]; export function shouldBeHardhatPluginError(error: IgnitionError): boolean { diff --git a/packages/hardhat-plugin/test/deploy/rerun/rerun-after-kill.ts b/packages/hardhat-plugin/test/deploy/rerun/rerun-after-kill.ts index 6fb2bed0e..01c04dc57 100644 --- a/packages/hardhat-plugin/test/deploy/rerun/rerun-after-kill.ts +++ b/packages/hardhat-plugin/test/deploy/rerun/rerun-after-kill.ts @@ -15,7 +15,9 @@ import { * * This covers a bug in the nonce mangement code: see #576 */ -describe("execution - rerun after kill", () => { +describe("execution - rerun after kill", function () { + this.timeout(60000); + useFileIgnitionProject("minimal", "rerun-after-kill"); it("should pickup deployment and run contracts to completion", async function () {