diff --git a/.changeset/ignition-max-batch-size.md b/.changeset/ignition-max-batch-size.md new file mode 100644 index 00000000000..6e50d873d3b --- /dev/null +++ b/.changeset/ignition-max-batch-size.md @@ -0,0 +1,8 @@ +--- +# docs: https://github.com/NomicFoundation/hardhat-website/pull/315 +"@nomicfoundation/ignition-core": minor +"@nomicfoundation/hardhat-ignition": minor +"@nomicfoundation/hardhat-errors": patch +--- + +Add an optional `ignition.maxBatchSize` setting to split deployment batches while preserving dependency boundaries. Resume pending transactions in nonce order before starting new futures, including when reducing the batch limit between runs. The `batches` helper also accepts an optional maximum batch size. diff --git a/packages/hardhat-errors/src/descriptors.ts b/packages/hardhat-errors/src/descriptors.ts index 661ad9ade4e..899806e55bc 100644 --- a/packages/hardhat-errors/src/descriptors.ts +++ b/packages/hardhat-errors/src/descriptors.ts @@ -1794,6 +1794,14 @@ Please try again. If it persists, please open an issue in the Hardhat repository websiteDescription: "Hardhat Ignition needs to migrate the artifacts to the new format. Please run `npx hardhat ignition migrate {deploymentId}`", }, + INVALID_MAX_BATCH_SIZE: { + number: 10003, + messageTemplate: + "Invalid maxBatchSize: {value}. Expected a positive safe integer.", + websiteTitle: "Invalid maximum batch size", + websiteDescription: + "The Ignition maxBatchSize option must be a positive safe integer. Omit it to leave the batch size unlimited.", + }, }, INTERNAL: { INTERNAL_ERROR: { diff --git a/packages/hardhat-ignition/README.md b/packages/hardhat-ignition/README.md index 7c82bc04e6e..bf8427a55f2 100644 --- a/packages/hardhat-ignition/README.md +++ b/packages/hardhat-ignition/README.md @@ -26,3 +26,20 @@ export default defineConfig({ ## Usage To learn more about how to use Hardhat Ignition, check out the [Hardhat Ignition documentation](https://hardhat.org/ignition). + +### Limiting deployment batch size + +To limit the number of futures executed in each deployment batch, set `ignition.maxBatchSize` in your Hardhat configuration: + +```typescript +export default defineConfig({ + plugins: [hardhatIgnition], + ignition: { + maxBatchSize: 10, + }, +}); +``` + +The value must be a positive safe integer. When omitted, Ignition keeps its existing dependency-based batches without a size limit. Each batch must finish successfully, including the required transaction confirmations, before the next batch starts. Splitting a batch preserves the boundaries between the original dependency batches. + +This limit counts futures, including operations that do not send transactions. When resuming a deployment, Ignition processes pending nonces before assigning new ones within each dependency batch. Reducing the limit does not cancel transactions that a previous run already submitted. diff --git a/packages/hardhat-ignition/test/config.ts b/packages/hardhat-ignition/test/config.ts index 856ff3f804a..cdaee2a45db 100644 --- a/packages/hardhat-ignition/test/config.ts +++ b/packages/hardhat-ignition/test/config.ts @@ -1,4 +1,3 @@ -import type { KeyListOf } from "./test-helpers/type-helper.js"; import type { HardhatConfig, NetworkConfig } from "hardhat/types/config"; import { buildModule } from "@nomicfoundation/ignition-core"; @@ -41,6 +40,7 @@ describe("config", () => { blockPollingInterval: 100, timeBeforeBumpingFees: 60 * 1000, maxFeeBumps: 2, + maxBatchSize: 3, strategyConfig: { create2: { salt: "custom-salt", @@ -68,6 +68,10 @@ describe("config", () => { assert.equal(loadedOptions.timeBeforeBumpingFees, 60 * 1000); }); + it("should apply maxBatchSize", () => { + assert.equal(loadedOptions.maxBatchSize, 3); + }); + it("should apply maxFeeBumps", async function () { assert.equal(loadedOptions.maxFeeBumps, 2); }); @@ -119,18 +123,22 @@ describe("config", () => { }); it("should only have known config", () => { - const configOptions: KeyListOf = [ - "blockPollingInterval", - "disableFeeBumping", - "maxFeeBumps", - "maxRetries", - "requiredConfirmations", - "retryInterval", - "strategyConfig", - "timeBeforeBumpingFees", - ]; - - assert.deepStrictEqual(Object.keys(loadedOptions).sort(), configOptions); + const configOptions: Record = { + blockPollingInterval: true, + disableFeeBumping: true, + maxBatchSize: true, + maxFeeBumps: true, + maxRetries: true, + requiredConfirmations: true, + retryInterval: true, + strategyConfig: true, + timeBeforeBumpingFees: true, + }; + + assert.deepStrictEqual( + Object.keys(loadedOptions).sort(), + Object.keys(configOptions).sort(), + ); }); }); diff --git a/packages/hardhat-ignition/test/deploy/max-batch-size.ts b/packages/hardhat-ignition/test/deploy/max-batch-size.ts new file mode 100644 index 00000000000..799c865f6ab --- /dev/null +++ b/packages/hardhat-ignition/test/deploy/max-batch-size.ts @@ -0,0 +1,318 @@ +import type { ExecutionEventListener } from "@nomicfoundation/ignition-core"; + +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { + buildModule, + deploy, + DeploymentResultType, + ExecutionEventType, +} from "@nomicfoundation/ignition-core"; +import { assert } from "chai"; +import { createHardhatRuntimeEnvironment } from "hardhat/hre"; +import sinon from "sinon"; + +import { HardhatArtifactResolver } from "../../src/helpers/hardhat-artifact-resolver.js"; +import { PrettyEventHandler } from "../../src/helpers/pretty-event-handler.js"; +import hardhatIgnitionPlugin from "../../src/index.js"; +import { clearPendingTransactionsFromMemoryPool } from "../test-helpers/clear-pending-transactions-from-memory-pool.js"; +import { sleep } from "../test-helpers/sleep.js"; +import { useFileIgnitionProject } from "../test-helpers/use-ignition-project.js"; + +const twoContracts = buildModule("Limited", (m) => { + const a = m.contract("Foo", [], { id: "A" }); + const b = m.contract("Foo", [], { id: "B" }); + return { a, b }; +}); + +const onlyB = buildModule("Limited", (m) => { + const b = m.contract("Foo", [], { id: "B" }); + return { b }; +}); + +describe("execution - maximum batch size", function () { + useFileIgnitionProject("minimal", "max-batch-size", { + blockPollingInterval: 20, + }); + + it("waits for each batch to confirm before submitting the next", async function () { + this.config.maxBatchSize = 1; + const result = await this.runControlledDeploy(twoContracts, async (c) => { + await c.waitForPendingTxs(1); + // Allow multiple polling cycles without mining: the next transaction + // must not be submitted while the current batch is unconfirmed. + await sleep(100); + const pending: any = await this.connection.provider.request({ + method: "eth_getBlockByNumber", + params: ["pending", false], + }); + assert.lengthOf(pending.transactions, 1); + await c.mineBlock(); + await c.mineBlock(1); + }); + assert.equal(await result.a.read.x(), 1n); + assert.equal(await result.b.read.x(), 1n); + }); + + it("resumes pending futures before newly added futures with earlier ids", async function () { + await this.runControlledDeploy(onlyB, async (c) => { + await c.waitForPendingTxs(1); + c.exitDeploy(); + }); + this.config.maxBatchSize = 1; + const result = await this.runControlledDeploy(twoContracts, async (c) => { + await sleep(100); + const pending: any = await this.connection.provider.request({ + method: "eth_getBlockByNumber", + params: ["pending", false], + }); + assert.lengthOf(pending.transactions, 1); + await c.mineBlock(1); + await c.mineBlock(1); + }); + assert.equal(await result.a.read.x(), 1n); + assert.equal(await result.b.read.x(), 1n); + }); + + it("resends dropped transactions in nonce order when reducing the limit", async function () { + await this.runControlledDeploy(onlyB, async (c) => { + await c.waitForPendingTxs(1); + c.exitDeploy(); + }); + // B has nonce 0; adding A gives it nonce 1, reversing future id order. + await this.runControlledDeploy(twoContracts, async (c) => { + await c.waitForPendingTxs(2); + c.exitDeploy(); + }); + await clearPendingTransactionsFromMemoryPool(this.connection); + this.config.maxBatchSize = 1; + const result = await this.runControlledDeploy(twoContracts, async (c) => { + await c.mineBlock(1); + await c.mineBlock(1); + }); + assert.equal(await result.a.read.x(), 1n); + assert.equal(await result.b.read.x(), 1n); + }); + + it("resends dropped transactions in nonce order for mixed-case sender addresses", async function () { + const [sender] = (await this.connection.provider.request({ + method: "eth_accounts", + })) as string[]; + const definition = buildModule("MixedCase", (m) => { + const a = m.contract("Foo", [], { id: "A", from: sender.toLowerCase() }); + const b = m.contract("Foo", [], { + id: "B", + from: `0x${sender.slice(2).toUpperCase()}`, + }); + return { a, b }; + }); + await this.runControlledDeploy(definition, async (c) => { + await c.waitForPendingTxs(2); + c.exitDeploy(); + }); + await clearPendingTransactionsFromMemoryPool(this.connection); + this.config.maxBatchSize = 1; + const requests = sinon.spy(this.connection.provider, "request"); + try { + const result = await this.runControlledDeploy(definition, async (c) => { + // A higher nonce sent first would be queued behind the missing nonce 0, + // leaving no executable transaction in the pending block. + while (true) { + const send = requests + .getCalls() + .find((call) => call.args[0].method === "eth_sendTransaction"); + if (send !== undefined) { + await send.returnValue; + break; + } + await sleep(10); + } + const pending: any = await this.connection.provider.request({ + method: "eth_getBlockByNumber", + params: ["pending", true], + }); + try { + assert.lengthOf(pending.transactions, 1); + assert.equal(pending.transactions[0].nonce, "0x0"); + } catch (error) { + c.exitDeploy(); + throw error; + } + await c.mineBlock(); + await c.mineBlock(1); + }); + assert.equal(await result.a.read.x(), 1n); + assert.equal(await result.b.read.x(), 1n); + } finally { + requests.restore(); + } + }); + + it("sorts after nonce synchronization when a replacement clears an earlier nonce", async function () { + await this.runControlledDeploy(twoContracts, async (c) => { + await c.waitForPendingTxs(2); + c.exitDeploy(); + }); + await clearPendingTransactionsFromMemoryPool(this.connection); + const [sender] = (await this.connection.provider.request({ + method: "eth_accounts", + })) as string[]; + // Replace A (nonce 0) with a confirmed user transaction. B (nonce 1) + // was dropped and must be resent before A is assigned its new nonce 2. + await this.connection.provider.request({ + method: "eth_sendTransaction", + params: [ + { from: sender, to: sender, value: "0x0", nonce: "0x0", gas: "0x5208" }, + ], + }); + await this.connection.provider.request({ method: "evm_mine" }); + this.config.maxBatchSize = 1; + const result = await this.runControlledDeploy(twoContracts, async (c) => { + await c.mineBlock(1); + await c.mineBlock(1); + }); + assert.equal(await result.a.read.x(), 1n); + assert.equal(await result.b.read.x(), 1n); + }); + + it("reports the actual batches and skips successful futures on a rerun", async function () { + await this.connection.provider.request({ + method: "evm_setAutomine", + params: [true], + }); + const events: any[] = []; + const listener = new Proxy({} as ExecutionEventListener, { + get: () => async (event: any) => { + events.push(event); + }, + }); + const accounts = (await this.connection.provider.request({ + method: "eth_accounts", + })) as string[]; + const options = { + config: { maxBatchSize: 1 }, + provider: this.connection.provider, + deploymentDir: this.deploymentDir, + artifactResolver: new HardhatArtifactResolver(this.hre.artifacts), + ignitionModule: twoContracts, + accounts, + deploymentParameters: {}, + executionEventListener: listener, + }; + assert.equal( + (await deploy(options)).type, + DeploymentResultType.SUCCESSFUL_DEPLOYMENT, + ); + assert.deepEqual( + events.filter((e) => e.type === ExecutionEventType.BATCH_INITIALIZE), + [ + { + type: ExecutionEventType.BATCH_INITIALIZE, + batches: [["Limited#A"], ["Limited#B"]], + }, + ], + ); + assert.lengthOf( + events.filter((e) => e.type === ExecutionEventType.BEGIN_NEXT_BATCH), + 2, + ); + events.length = 0; + assert.equal( + (await deploy(options)).type, + DeploymentResultType.SUCCESSFUL_DEPLOYMENT, + ); + assert.deepEqual( + events.filter((e) => e.type === ExecutionEventType.BATCH_INITIALIZE), + [ + { + type: ExecutionEventType.BATCH_INITIALIZE, + batches: [], + }, + ], + ); + assert.lengthOf( + events.filter((e) => e.type === ExecutionEventType.TRANSACTION_SEND), + 0, + ); + }); + + it("does not start later chunks when an earlier chunk fails", async function () { + await this.connection.provider.request({ + method: "evm_setAutomine", + params: [true], + }); + const definition = buildModule("Failure", (m) => { + const foo = m.contract("Foo"); + m.call(foo, "incByPositiveNumber", [0], { id: "A" }); + m.call(foo, "inc", [], { id: "B" }); + return { foo }; + }); + const events: any[] = []; + const result = await deploy({ + config: { maxBatchSize: 1 }, + provider: this.connection.provider, + artifactResolver: new HardhatArtifactResolver(this.hre.artifacts), + ignitionModule: definition, + accounts: (await this.connection.provider.request({ + method: "eth_accounts", + })) as string[], + deploymentParameters: {}, + executionEventListener: new Proxy({} as ExecutionEventListener, { + get: () => async (event: any) => { + events.push(event); + }, + }), + }); + assert.equal(result.type, DeploymentResultType.EXECUTION_ERROR); + assert.lengthOf( + events.filter((e) => e.type === ExecutionEventType.BEGIN_NEXT_BATCH), + 2, + ); + assert.lengthOf( + events.filter((e) => e.type === ExecutionEventType.TRANSACTION_SEND), + 1, + ); + assert.isFalse(events.some((e) => e.futureId === "Failure#B")); + }); +}); + +describe("deploy task - maximum batch size", function () { + it("passes the global Ignition configuration to the deployment engine", async function () { + const projectPath = path.join( + import.meta.dirname, + "../fixture-projects/minimal", + ); + const configPath = path.join(projectPath, "hardhat.config.js"); + const { default: userConfig } = await import( + pathToFileURL(configPath).href + ); + const hre = await createHardhatRuntimeEnvironment( + { + ...userConfig, + plugins: [hardhatIgnitionPlugin], + ignition: { maxBatchSize: 1 }, + }, + { config: configPath }, + projectPath, + ); + const batchInitialize = sinon.spy( + PrettyEventHandler.prototype, + "batchInitialize", + ); + try { + const result = await hre.tasks.getTask(["ignition", "deploy"]).run({ + modulePath: path.join(projectPath, "ignition/modules/MyModule.js"), + }); + assert.equal(result.type, DeploymentResultType.SUCCESSFUL_DEPLOYMENT); + assert.isTrue(batchInitialize.calledOnce); + assert.deepEqual(batchInitialize.firstCall.args[0].batches, [ + ["MyModule#Bar"], + ["MyModule#UsesContract"], + ["MyModule#UsesContract.setAddress"], + ]); + } finally { + batchInitialize.restore(); + } + }); +}); diff --git a/packages/ignition-core/src/batches.ts b/packages/ignition-core/src/batches.ts index 3301313214b..c973742f0f0 100644 --- a/packages/ignition-core/src/batches.ts +++ b/packages/ignition-core/src/batches.ts @@ -9,14 +9,16 @@ import { deploymentStateReducer } from "./internal/execution/reducers/deployment * run from as a fresh deployment. * * @param ignitionModule - the Ignition module to be get batch information for + * @param maxBatchSize - the maximum number of futures in each batch, if limited * @returns the batches Ignition will use for the module * * @public */ export function batches( ignitionModule: IgnitionModule>, + maxBatchSize?: number, ): string[][] { const deploymentState = deploymentStateReducer(undefined); - return Batcher.batch(ignitionModule, deploymentState); + return Batcher.batch(ignitionModule, deploymentState, maxBatchSize); } diff --git a/packages/ignition-core/src/deploy.ts b/packages/ignition-core/src/deploy.ts index 22d2d78da12..00bf68cf3d3 100644 --- a/packages/ignition-core/src/deploy.ts +++ b/packages/ignition-core/src/deploy.ts @@ -24,6 +24,7 @@ import { EIP1193JsonRpcClient } from "./internal/execution/jsonrpc-client.js"; import { equalAddresses } from "./internal/execution/utils/address.js"; import { getDefaultSender } from "./internal/execution/utils/get-default-sender.js"; import { checkAutominedNetwork } from "./internal/utils/check-automined-network.js"; +import { validateMaxBatchSize } from "./internal/utils/split-batches.js"; import { validate } from "./internal/validation/validate.js"; import { resolveStrategy } from "./strategies/resolve-strategy.js"; import { ExecutionEventType } from "./types/execution-events.js"; @@ -77,6 +78,8 @@ export async function deploy< gasPrice?: bigint; disableFeeBumping?: boolean; }): Promise { + validateMaxBatchSize(config.maxBatchSize); + const executionStrategy: ExecutionStrategy = resolveStrategy( strategy, strategyConfig, diff --git a/packages/ignition-core/src/internal/batcher.ts b/packages/ignition-core/src/internal/batcher.ts index 67c74bd581f..6e3931e7344 100644 --- a/packages/ignition-core/src/internal/batcher.ts +++ b/packages/ignition-core/src/internal/batcher.ts @@ -5,6 +5,7 @@ import type { Future, IgnitionModule } from "../types/module.js"; import { ExecutionStatus } from "./execution/types/execution-state.js"; import { AdjacencyListConverter } from "./utils/adjacency-list-converter.js"; import { getFuturesFromModule } from "./utils/get-futures-from-module.js"; +import { splitBatches } from "./utils/split-batches.js"; enum VisitStatus { UNVISITED, @@ -24,6 +25,7 @@ export class Batcher { public static batch( module: IgnitionModule, deploymentState: DeploymentState, + maxBatchSize?: number, ): string[][] { const batchState = this._initializeBatchStateFrom(module, deploymentState); @@ -37,7 +39,7 @@ export class Batcher { this._markAsVisited(batchState, nextBatch); } - return batches; + return splitBatches(batches, maxBatchSize); } private static _initializeBatchStateFrom( diff --git a/packages/ignition-core/src/internal/deployer.ts b/packages/ignition-core/src/internal/deployer.ts index c1b649f864d..74444614ba4 100644 --- a/packages/ignition-core/src/internal/deployer.ts +++ b/packages/ignition-core/src/internal/deployer.ts @@ -176,7 +176,9 @@ export class Deployer { const batches = Batcher.batch(ignitionModule, deploymentState); - await this._emitDeploymentBatchEvent(batches); + if (this._config.maxBatchSize === undefined || batches.length === 0) { + await this._emitDeploymentBatchEvent(batches); + } if (this._hasBatchesToExecute(batches)) { await this._emitRunStartEvent(); @@ -194,6 +196,7 @@ export class Deployer { this._config.disableFeeBumping, this._config.maxRetries, this._config.retryInterval, + this._config.maxBatchSize, ); deploymentState = await executionEngine.executeModule( diff --git a/packages/ignition-core/src/internal/execution/execution-engine.ts b/packages/ignition-core/src/internal/execution/execution-engine.ts index a83e817852e..5b9d17c1af5 100644 --- a/packages/ignition-core/src/internal/execution/execution-engine.ts +++ b/packages/ignition-core/src/internal/execution/execution-engine.ts @@ -17,6 +17,7 @@ import sortBy from "lodash-es/sortBy.js"; import { ExecutionEventType } from "../../types/execution-events.js"; import { assertIgnitionInvariant } from "../utils/assertions.js"; import { getFuturesFromModule } from "../utils/get-futures-from-module.js"; +import { splitBatches } from "../utils/split-batches.js"; import { getNetworkExecutionStates } from "../views/execution-state/get-network-execution-states.js"; import { getPendingNonceAndSender } from "../views/execution-state/get-pending-nonce-and-sender.js"; import { hasExecutionSucceeded } from "../views/has-execution-succeeded.js"; @@ -49,6 +50,7 @@ export class ExecutionEngine { private readonly _disableFeeBumping: boolean, private readonly _maxRetries: number, private readonly _retryInterval: number, + private readonly _maxBatchSize?: number, ) {} /** @@ -115,6 +117,25 @@ export class ExecutionEngine { const futures = getFuturesFromModule(module); const futuresById = new Map(futures.map((f) => [f.id, f])); + if (this._maxBatchSize !== undefined) { + // Nonce synchronization can clear replaced nonces. Sort the complete + // dependency layer afterwards so that a new transaction cannot block + // an earlier pending nonce in a later chunk. + batches = splitBatches( + batches.map((batch) => + this._getBatchSortedByHighestPendingNonce( + batch.map((id) => this._lookupFuture(futuresById, id)), + deploymentState, + ).map((future) => future.id), + ), + this._maxBatchSize, + ); + await this._executionEventListener?.batchInitialize({ + type: ExecutionEventType.BATCH_INITIALIZE, + batches, + }); + } + for (const batch of batches) { await this._emitBeginNextBatchEvent(); @@ -307,6 +328,7 @@ export class ExecutionEngine { const batchWithNonces = batch.map((f) => { const NO_PENDING_RESULT = { future: f, + withoutPendingNonce: true, nonce: Number.MAX_SAFE_INTEGER, from: "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", }; @@ -325,12 +347,18 @@ export class ExecutionEngine { return { future: f, + withoutPendingNonce: false, nonce: pendingNonceAndSender.nonce, - from: pendingNonceAndSender.sender, + from: pendingNonceAndSender.sender.toLowerCase(), }; }); - const sortedBatch = sortBy(batchWithNonces, ["from", "nonce", "future.id"]); + const sortedBatch = sortBy(batchWithNonces, [ + "withoutPendingNonce", + "from", + "nonce", + "future.id", + ]); return sortedBatch.map((f) => f.future); } diff --git a/packages/ignition-core/src/internal/utils/split-batches.ts b/packages/ignition-core/src/internal/utils/split-batches.ts new file mode 100644 index 00000000000..acea3769293 --- /dev/null +++ b/packages/ignition-core/src/internal/utils/split-batches.ts @@ -0,0 +1,32 @@ +import { HardhatError } from "@nomicfoundation/hardhat-errors"; + +export function validateMaxBatchSize(maxBatchSize: number | undefined): void { + if ( + maxBatchSize !== undefined && + (!Number.isSafeInteger(maxBatchSize) || maxBatchSize <= 0) + ) { + throw new HardhatError( + HardhatError.ERRORS.IGNITION.GENERAL.INVALID_MAX_BATCH_SIZE, + { value: String(maxBatchSize) }, + ); + } +} + +export function splitBatches( + batches: string[][], + maxBatchSize: number | undefined, +): string[][] { + validateMaxBatchSize(maxBatchSize); + + if (maxBatchSize === undefined) { + return batches; + } + + return batches.flatMap((batch) => { + const chunks: string[][] = []; + for (let start = 0; start < batch.length; start += maxBatchSize) { + chunks.push(batch.slice(start, start + maxBatchSize)); + } + return chunks; + }); +} diff --git a/packages/ignition-core/src/types/deploy.ts b/packages/ignition-core/src/types/deploy.ts index 61ac1ce2b4c..b5f031aa87b 100644 --- a/packages/ignition-core/src/types/deploy.ts +++ b/packages/ignition-core/src/types/deploy.ts @@ -6,6 +6,12 @@ import type { ModuleParameters } from "./module.js"; * @public */ export interface DeployConfig { + /** + * The maximum number of futures in each deployment batch. + * Must be a positive safe integer. Omit to leave the batch size unlimited. + */ + maxBatchSize?: number; + /** * The interval, in milliseconds, between checks to see if a new block * has been created diff --git a/packages/ignition-core/test/execution/execution-engine.ts b/packages/ignition-core/test/execution/execution-engine.ts index e11ae324163..9809649b688 100644 --- a/packages/ignition-core/test/execution/execution-engine.ts +++ b/packages/ignition-core/test/execution/execution-engine.ts @@ -3,6 +3,12 @@ import { assertRejectsWithHardhatError } from "@nomicfoundation/hardhat-test-uti import { assert } from "chai"; import path from "path"; +import { buildModule } from "../../src/build-module.js"; +import { + ExecutionSateType, + ExecutionStatus, +} from "../../src/internal/execution/types/execution-state.js"; +import { NetworkInteractionType } from "../../src/internal/execution/types/network-interaction.js"; import { ExecutionEngine } from "../../src/internal/execution/execution-engine.js"; import { FileDeploymentLoader } from "../../src/internal/deployment-loader/file-deployment-loader.js"; import { loadDeploymentState } from "../../src/internal/execution/deployment-state-helpers.js"; @@ -11,6 +17,65 @@ import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); describe("ExecutionEngine", () => { + for (const higherNonceSender of [ + "0xffffffffffffffffffffffffffffffffffffffff", + "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", + ]) { + it(`orders pending nonces before new futures for sender ${higherNonceSender}`, () => { + const module = buildModule("Order", (m) => { + const a = m.contract("A"); + const b = m.contract("B"); + const c = m.contract("C"); + return { a, b, c }; + }); + const engine = new ExecutionEngine( + {} as any, + {} as any, + {} as any, + {} as any, + undefined, + 1, + 1, + 1, + 1, + false, + 1, + 1, + ); + const state = ( + id: string, + nonce: number, + from = "0xffffffffffffffffffffffffffffffffffffffff", + ) => ({ + id, + type: ExecutionSateType.DEPLOYMENT_EXECUTION_STATE, + status: ExecutionStatus.STARTED, + from, + networkInteractions: [ + { + type: NetworkInteractionType.ONCHAIN_INTERACTION, + nonce, + transactions: [], + }, + ], + }); + const futures = Object.values(module.results); + const sorted = (engine as any)._getBatchSortedByHighestPendingNonce( + futures, + { + executionStates: { + "Order#B": state("Order#B", 1, higherNonceSender), + "Order#C": state("Order#C", 0), + }, + }, + ); + assert.deepEqual( + sorted.map((f: any) => f.id), + ["Order#C", "Order#B", "Order#A"], + ); + }); + } + describe("_checkForMissingTransactions", () => { it("should throw if there are PREPARE_SEND_TRANSACTION messages without a corresponding SEND_TRANSACTION message", async () => { const deploymentLoader = new FileDeploymentLoader( diff --git a/packages/ignition-core/test/max-batch-size.ts b/packages/ignition-core/test/max-batch-size.ts new file mode 100644 index 00000000000..17b0673508f --- /dev/null +++ b/packages/ignition-core/test/max-batch-size.ts @@ -0,0 +1,71 @@ +import { HardhatError } from "@nomicfoundation/hardhat-errors"; +import { assert } from "chai"; + +import { batches } from "../src/batches.js"; +import { buildModule } from "../src/build-module.js"; +import { deploy } from "../src/deploy.js"; + +const moduleDefinition = buildModule("Limited", (m) => { + const a = m.contract("A"); + m.contract("B"); + m.contract("C"); + const d = m.contract("D", [], { after: [a] }); + m.contract("E", [], { after: [a] }); + m.contract("F", [], { after: [a] }); + return { d }; +}); + +describe("maximum batch size", () => { + it("preserves dependency layers when splitting multiple oversized batches", () => { + assert.deepEqual(batches(moduleDefinition, 2), [ + ["Limited#A", "Limited#B"], + ["Limited#C"], + ["Limited#D", "Limited#E"], + ["Limited#F"], + ]); + }); + + it("can execute one future per batch", () => { + assert.deepEqual( + batches(moduleDefinition, 1), + ["A", "B", "C", "D", "E", "F"].map((id) => [`Limited#${id}`]), + ); + }); + + for (const limit of [undefined, 3, 4, Number.MAX_SAFE_INTEGER]) { + it(`preserves batches that fit the limit ${limit}`, () => { + assert.deepEqual(batches(moduleDefinition, limit), [ + ["Limited#A", "Limited#B", "Limited#C"], + ["Limited#D", "Limited#E", "Limited#F"], + ]); + }); + } + + for (const limit of [ + 0, + -1, + 1.5, + NaN, + Infinity, + Number.MAX_SAFE_INTEGER + 1, + ]) { + it(`rejects invalid limit ${limit}, including for empty modules`, async () => { + const empty = buildModule("Empty", () => ({})); + assert.throws(() => batches(empty, limit), /maxBatchSize/); + try { + await deploy({ + config: { maxBatchSize: limit }, + ignitionModule: empty, + artifactResolver: {} as any, + provider: {} as any, + accounts: [], + deploymentParameters: {}, + }); + assert.fail("Expected invalid batch size to be rejected"); + } catch (error) { + assert.isTrue(HardhatError.isHardhatError(error)); + assert.match((error as Error).message, /maxBatchSize/); + } + }); + } +});