import type { Future, IgnitionModule, IgnitionModuleResult, } from "../../../types/module.js"; import type { JsonRpcClient } from "../jsonrpc-client.js"; import type { DeploymentState } from "../types/deployment-state.js"; import type { OnchainInteractionDroppedMessage, OnchainInteractionReplacedByUserMessage, } from "../types/messages.js"; import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { uniq } from "lodash-es"; import { isArtifactContractAtFuture, isEncodeFunctionCallFuture, isNamedContractAtFuture, isReadEventArgumentFuture, } from "../../../type-guards.js"; import { getFuturesFromModule } from "../../utils/get-futures-from-module.js"; import { getPendingOnchainInteraction } from "../../views/execution-state/get-pending-onchain-interaction.js"; import { resolveFutureFrom } from "../future-processor/helpers/future-resolvers.js"; import { ExecutionSateType, ExecutionStatus, } from "../types/execution-state.js"; import { JournalMessageType } from "../types/messages.js"; /** * This function is meant to be used to sync the local state's nonces * with those of the network. * * This function has three goals: * - Ensure that we never proceed with Ignition if there are transactions * sent by the user that haven't got enough confirmations yet. * - Detect if the user has replaced a transaction sent by Ignition. * - Distinguish if a transaction not being present in the mempool was * dropped or replaced by the user. * * The way this function works means that there's one case we don't handle: * - If the user replaces a transaction sent by Ignition with one of theirs * we'll allocate a new nonce for our transaction. * - If the user's transaction gets dropped, we won't reallocate the original * nonce for any of our transactions, and Ignition will eventually fail, * setting one or more ExecutionState as TIMEOUT. * - This is intentional, as reusing user nonces can lead to unexpected * results. * - To understand this better, please consider that a transaction being * dropped by your node doesn't mean that the entire network forgot about it. * * @param jsonRpcClient The client used to interact with the network. * @param deploymentState The current deployment state, which we want to sync. * @param requiredConfirmations The amount of confirmations that a transaction * must have before we consider it confirmed. * @returns The messages that should be applied to the state. */ export async function getNonceSyncMessages( jsonRpcClient: JsonRpcClient, deploymentState: DeploymentState, ignitionModule: IgnitionModule>, accounts: string[], defaultSender: string, requiredConfirmations: number, ): Promise< Array< OnchainInteractionReplacedByUserMessage | OnchainInteractionDroppedMessage > > { const messages: Array< OnchainInteractionReplacedByUserMessage | OnchainInteractionDroppedMessage > = []; const pendingTransactionsPerSender = createMapFromSenderToNonceAndTransactions( deploymentState, ignitionModule, accounts, defaultSender, ); const block = await jsonRpcClient.getLatestBlock(); const confirmedBlockNumber: number | undefined = block.number - requiredConfirmations + 1 >= 0 ? block.number - requiredConfirmations + 1 : undefined; for (const [sender, pendingIgnitionTransactions] of Object.entries( pendingTransactionsPerSender, )) { // If this is undefined, it means that no transaction has fully confirmed. const safeConfirmationsCount = confirmedBlockNumber !== undefined ? await jsonRpcClient.getTransactionCount(sender, confirmedBlockNumber) : undefined; const pendingCount = await jsonRpcClient.getTransactionCount( sender, "pending", ); const latestCount = await jsonRpcClient.getTransactionCount( sender, "latest", ); // Is the pending count the same as the safe count (x confirmation blocks // in the past), then all pending transactions have been safely confirmed. // There is one other case, where the current block is so low, we // can't have enough confirmations (i.e. block 2 when confirmations required // is 5). In that case all pending onchain transactions are unconfirmed. const hasOnchainUnconfirmedPendingTransactions = safeConfirmationsCount === undefined ? pendingCount > 0 : safeConfirmationsCount !== pendingCount; // Case 0: We don't have any pending Ignition transactions if (pendingIgnitionTransactions.length === 0) { if (hasOnchainUnconfirmedPendingTransactions) { throw new HardhatError( HardhatError.ERRORS.IGNITION.EXECUTION.WAITING_FOR_CONFIRMATIONS, { sender, requiredConfirmations, }, ); } } for (const { nonce, transactions, executionStateId, networkInteractionId, } of pendingIgnitionTransactions) { const fetchedTransactions = await Promise.all( transactions.map((tx) => jsonRpcClient.getTransaction(tx)), ); // If at least one transaction for the future is still in the mempool, // we do nothing if (fetchedTransactions.some((tx) => tx !== undefined)) { continue; } // If we are here, all the previously pending transactions for this // future were dropped or replaced. // Case 1: Confirmed transaction with this nonce // There are more transactions up to the latest block than our nonce, // that means that one transaction with our nonce was sent and confirmed // // Example: // // Ignition sends transaction with nonce 5 // It is replaced by the user, with user transaction nonce 5 // The user transaction confirms // That means there is a block that includes it // If we look at the latest transaction count, it will be at least 6 if (latestCount > nonce) { const hasEnoughConfirmations = safeConfirmationsCount !== undefined && safeConfirmationsCount >= nonce; // We know the ignition transaction was replaced, and the replacement // transaction has at least one confirmation. // We don't continue until the user's transactions have enough confirmations if (!hasEnoughConfirmations) { throw new HardhatError( HardhatError.ERRORS.IGNITION.EXECUTION.WAITING_FOR_NONCE, { sender, nonce, requiredConfirmations, }, ); } messages.push({ type: JournalMessageType.ONCHAIN_INTERACTION_REPLACED_BY_USER, futureId: executionStateId, networkInteractionId, }); continue; } // Case 2: There's a pending transaction with this nonce sent by the user // We first handle confirmed transactions, that'w why this check is safe here // // Example: // // Ignition has sent a transaction with nonce 5 // It is replaced by the user, with user transaction nonce 5 // The user transaction is still in the mempool // The pending count will show as larger than the nonce, and we know // from the test above that it has not been confirmed if (pendingCount > nonce) { throw new HardhatError( HardhatError.ERRORS.IGNITION.EXECUTION.WAITING_FOR_NONCE, { sender, nonce, requiredConfirmations, }, ); } // Case 3: There's no transaction sent by the user with this nonce, but ours were still dropped messages.push({ type: JournalMessageType.ONCHAIN_INTERACTION_DROPPED, futureId: executionStateId, networkInteractionId, }); } // Case 4: the user sent additional transactions with nonces higher than // our highest pending nonce. const highestPendingNonce = Math.max( ...pendingIgnitionTransactions.map((t) => t.nonce), ); if (highestPendingNonce + 1 < pendingCount) { // If they have enough confirmation we continue, otherwise we throw // and wait for further confirmations if (hasOnchainUnconfirmedPendingTransactions) { throw new HardhatError( HardhatError.ERRORS.IGNITION.EXECUTION.WAITING_FOR_NONCE, { sender, nonce: pendingCount - 1, requiredConfirmations, }, ); } } } return messages; } function createMapFromSenderToNonceAndTransactions( deploymentState: DeploymentState, ignitionModule: IgnitionModule>, accounts: string[], defaultSender: string, ): { [sender: string]: Array<{ nonce: number; transactions: string[]; executionStateId: string; networkInteractionId: number; }>; } { const pendingTransactionsPerAccount: { [sender: string]: Array<{ nonce: number; transactions: string[]; executionStateId: string; networkInteractionId: number; }>; } = {}; for (const executionState of Object.values(deploymentState.executionStates)) { if ( executionState.type === ExecutionSateType.READ_EVENT_ARGUMENT_EXECUTION_STATE || executionState.type === ExecutionSateType.CONTRACT_AT_EXECUTION_STATE || executionState.type === ExecutionSateType.ENCODE_FUNCTION_CALL_EXECUTION_STATE ) { continue; } if (executionState.status === ExecutionStatus.SUCCESS) { continue; } const onchainInteraction = getPendingOnchainInteraction(executionState); if (onchainInteraction === undefined) { continue; } if (onchainInteraction.nonce === undefined) { continue; } if (pendingTransactionsPerAccount[executionState.from] === undefined) { pendingTransactionsPerAccount[executionState.from] = []; } pendingTransactionsPerAccount[executionState.from].push({ nonce: onchainInteraction.nonce, transactions: onchainInteraction.transactions.map((tx) => tx.hash), executionStateId: executionState.id, networkInteractionId: onchainInteraction.id, }); } const exStateIds = Object.keys(deploymentState.executionStates); const futureSenders = _resolveFutureSenders( ignitionModule, accounts, defaultSender, exStateIds, ); for (const futureSender of futureSenders) { if (pendingTransactionsPerAccount[futureSender] === undefined) { pendingTransactionsPerAccount[futureSender] = []; } } for (const pendingTransactions of Object.values( pendingTransactionsPerAccount, )) { pendingTransactions.sort((a, b) => a.nonce - b.nonce); } return pendingTransactionsPerAccount; } /** * Scan the futures for upcoming account usage, add them to the list, * including the default sender if there are any undefined forms */ function _resolveFutureSenders( ignitionModule: IgnitionModule>, accounts: string[], defaultSender: string, exStateIds: string[], ): string[] { const futures = getFuturesFromModule(ignitionModule); const senders: string[] = futures .filter((f) => !exStateIds.includes(f.id)) .map((f) => _pickFrom(f, accounts, defaultSender)) .filter((x): x is string => x !== null); return uniq(senders); } function _pickFrom( future: Future, accounts: string[], defaultSender: string, ): string | null { if (isNamedContractAtFuture(future)) { return null; } if (isArtifactContractAtFuture(future)) { return null; } if (isReadEventArgumentFuture(future)) { return null; } if (isEncodeFunctionCallFuture(future)) { return null; } return resolveFutureFrom(future.from, accounts, defaultSender); }