diff --git a/packages/ua-devtools-evm-hardhat/src/tasks/oapp/wire.ts b/packages/ua-devtools-evm-hardhat/src/tasks/oapp/wire.ts index 99657980c..e9dd816f7 100644 --- a/packages/ua-devtools-evm-hardhat/src/tasks/oapp/wire.ts +++ b/packages/ua-devtools-evm-hardhat/src/tasks/oapp/wire.ts @@ -19,6 +19,7 @@ import { import { createSignAndSend, OmniTransaction } from '@layerzerolabs/devtools' import { createProgressBar, printLogo, printRecords, render } from '@layerzerolabs/io-devtools/swag' import { validateAndTransformOappConfig } from '@/utils/taskHelpers' +import type { SignAndSendResult } from '@layerzerolabs/devtools' interface TaskArgs { oappConfig: string @@ -26,7 +27,11 @@ interface TaskArgs { ci?: boolean } -const action: ActionType = async ({ oappConfig: oappConfigPath, logLevel = 'info', ci = false }) => { +const action: ActionType = async ({ + oappConfig: oappConfigPath, + logLevel = 'info', + ci = false, +}): Promise => { printLogo() // We only want to be asking users for input if we are not in interactive mode @@ -59,7 +64,7 @@ const action: ActionType = async ({ oappConfig: oappConfigPath, logLev if (transactions.length === 0) { logger.info(`The OApp is wired, no action is necessary`) - return [] + return [[], [], []] } // Tell the user about the transactions @@ -78,49 +83,102 @@ const action: ActionType = async ({ oappConfig: oappConfigPath, logLev if (previewTransactions) printRecords(transactions.map(formatOmniTransaction)) // Now ask the user whether they want to go ahead with signing them + // + // If they don't, we'll just return the list of pending transactions const shouldSubmit = isInteractive ? await promptToContinue(`Would you like to submit the required transactions?`) : true - if (!shouldSubmit) return logger.verbose(`User cancelled the operation, exiting`), undefined + if (!shouldSubmit) return logger.verbose(`User cancelled the operation, exiting`), [[], [], transactions] // The last step is to execute those transactions // // For now we are only allowing sign & send using the accounts confgiured in hardhat config const signAndSend = createSignAndSend(createSignerFactory()) - // Now we render a progressbar to monitor the task progress - const progressBar = render(createProgressBar({ before: 'Signing... ', after: ` 0/${transactions.length}` })) - - logger.verbose(`Sending the transactions`) - const results = await signAndSend(transactions, (result, results) => { - // We'll keep updating the progressbar as we sign the transactions - progressBar.rerender( - createProgressBar({ - progress: results.length / transactions.length, - before: 'Signing... ', - after: ` ${results.length}/${transactions.length}`, - }) + // We'll use this variable to store the transactions to be signed + // + // In case of an error, when a user decides to retry, we'll update this array + // with the transactions yet to be signed + let transactionsToSign = transactions + + // We will run an infinite retry loop when signing the transactions + // + // This loop will be broken in these scenarios: + // - if all the transactions succeed + // - if some of the transactions fail + // - in the interactive mode, if the user decides not to retry the failed transactions + // - in the non-interactive mode + // + // eslint-disable-next-line no-constant-condition + while (true) { + // Now we render a progressbar to monitor the task progress + const progressBar = render( + createProgressBar({ before: 'Signing... ', after: ` 0/${transactionsToSign.length}` }) + ) + + logger.verbose(`Sending the transactions`) + const [successful, errors, pendingTransactions] = await signAndSend(transactionsToSign, (result, results) => { + // We'll keep updating the progressbar as we sign the transactions + progressBar.rerender( + createProgressBar({ + progress: results.length / transactionsToSign.length, + before: 'Signing... ', + after: ` ${results.length}/${transactionsToSign.length}`, + }) + ) + }) + + // And finally we drop the progressbar and continue + progressBar.clear() + + logger.verbose(`Sent the transactions`) + logger.debug(`Successfully sent the following transactions:\n\n${printJson(successful)}`) + logger.debug(`Failed to send the following transactions:\n\n${printJson(errors)}`) + + logger.info( + pluralizeNoun( + successful.length, + `Successfully sent 1 transaction`, + `Successfully sent ${successful.length} transactions` + ) ) - }) - // And finally we drop the progressbar and continue - progressBar.clear() + // If there are no errors, we break out of the loop immediatelly + if (errors.length === 0) { + logger.info(`${printBoolean(true)} Your OApp is now configured`) - logger.verbose(`Sent the transactions`) - logger.debug(`Received the following output:\n\n${printJson(results)}`) + return [successful, errors, pendingTransactions] + } - // FIXME We need to check whether we got any errors and display those to the user - logger.info( - pluralizeNoun( - transactions.length, - `Successfully sent 1 transaction`, - `Successfully sent ${transactions.length} transactions` + // Now we bring the bad news to the user + logger.error( + pluralizeNoun(errors.length, `Failed to send 1 transaction`, `Failed to send ${errors.length} transactions`) ) - ) - logger.info(`${printBoolean(true)} Your OApp is now configured`) - // FIXME We need to return the results - return [] + const previewErrors = isInteractive + ? await promptToContinue(`Would you like to preview the failed transactions?`) + : true + if (previewErrors) + printRecords( + errors.map(({ error, transaction }) => ({ + error: String(error), + ...formatOmniTransaction(transaction), + })) + ) + + // We'll ask the user if they want to retry if we're in interactive mode + // + // If they decide not to, we exit, if they want to retry we start the loop again + const retry = isInteractive ? await promptToContinue(`Would you like to retry?`, true) : false + if (!retry) { + logger.error(`${printBoolean(false)} Failed to configure the OApp`) + + return [successful, errors, pendingTransactions] + } + + // If we are retrying, we'll update the array of pendingTransactions with the failed transactions plus the pending transactions + transactionsToSign = pendingTransactions + } } task(TASK_LZ_WIRE_OAPP, 'Wire LayerZero OApp') .addParam('oappConfig', 'Path to your LayerZero OApp config', './layerzero.config.js', types.string) diff --git a/tests/ua-devtools-evm-hardhat-test/test/task/oapp/wire.test.ts b/tests/ua-devtools-evm-hardhat-test/test/task/oapp/wire.test.ts index 8d9ad0586..9df222331 100644 --- a/tests/ua-devtools-evm-hardhat-test/test/task/oapp/wire.test.ts +++ b/tests/ua-devtools-evm-hardhat-test/test/task/oapp/wire.test.ts @@ -4,6 +4,7 @@ import { relative, resolve } from 'path' import { TASK_LZ_WIRE_OAPP } from '@layerzerolabs/ua-devtools-evm-hardhat' import { deployOAppFixture } from '../../__utils__/oapp' import { cwd } from 'process' +import { JsonRpcSigner } from '@ethersproject/providers' jest.mock('@layerzerolabs/io-devtools', () => { const original = jest.requireActual('@layerzerolabs/io-devtools') @@ -17,6 +18,12 @@ jest.mock('@layerzerolabs/io-devtools', () => { const promptToContinueMock = promptToContinue as jest.Mock describe('task/oapp/wire', () => { + // Helper matcher object that checks for OmniPoint objects + const expectOmniPoint = { address: expect.any(String), eid: expect.any(Number) } + // Helper matcher object that checks for OmniTransaction objects + const expectTransaction = { data: expect.any(String), point: expectOmniPoint } + const expectTransactionWithReceipt = { receipt: expect.any(Object), transaction: expectTransaction } + const CONFIGS_BASE_DIR = resolve(__dirname, '__data__', 'configs') const configPathFixture = (fileName: string): string => { const path = resolve(CONFIGS_BASE_DIR, fileName) @@ -123,7 +130,7 @@ describe('task/oapp/wire', () => { const result = await hre.run(TASK_LZ_WIRE_OAPP, { oappConfig, ci: true }) - expect(result).toEqual([]) + expect(result).toEqual([[expectTransactionWithReceipt, expectTransactionWithReceipt], [], []]) expect(promptToContinueMock).not.toHaveBeenCalled() }) @@ -137,14 +144,16 @@ describe('task/oapp/wire', () => { expect(promptToContinueMock).toHaveBeenCalledTimes(2) }) - it('should return undefined if the user decides not to continue', async () => { + it('should return a list of pending transactions if the user decides not to continue', async () => { const oappConfig = configPathFixture('valid.config.connected.js') promptToContinueMock.mockResolvedValue(false) - const result = await hre.run(TASK_LZ_WIRE_OAPP, { oappConfig }) + const [successful, errors, pending] = await hre.run(TASK_LZ_WIRE_OAPP, { oappConfig }) - expect(result).toBeUndefined() + expect(successful).toEqual([]) + expect(errors).toEqual([]) + expect(pending).toHaveLength(2) expect(promptToContinueMock).toHaveBeenCalledTimes(2) }) @@ -155,9 +164,155 @@ describe('task/oapp/wire', () => { .mockResolvedValueOnce(false) // We don't want to see the list .mockResolvedValueOnce(true) // We want to continue - const result = await hre.run(TASK_LZ_WIRE_OAPP, { oappConfig }) + const [successful, errors] = await hre.run(TASK_LZ_WIRE_OAPP, { oappConfig }) + + const expectTransactionWithReceipt = { receipt: expect.any(Object), transaction: expect.any(Object) } + + expect(successful).toEqual([expectTransactionWithReceipt, expectTransactionWithReceipt]) + expect(errors).toEqual([]) + }) - expect(result).toEqual([]) + describe('if a transaction fails', () => { + let sendTransactionMock: jest.SpyInstance + + beforeEach(() => { + sendTransactionMock = jest.spyOn(JsonRpcSigner.prototype, 'sendTransaction') + }) + + afterEach(() => { + sendTransactionMock.mockRestore() + }) + + it.only('should return a list of failed transactions in the CI mode', async () => { + const error = new Error('Oh god dammit') + + // We want to make the fail + sendTransactionMock.mockRejectedValue(error) + + const oappConfig = configPathFixture('valid.config.connected.js') + const [successful, errors, pending] = await hre.run(TASK_LZ_WIRE_OAPP, { oappConfig, ci: true }) + + expect(errors).toEqual([ + { + error, + transaction: expectTransaction, + }, + ]) + + // Since we failed on the first transaction, we expect + // all the transaction to still be pending and none of them to be successful + expect(successful).toEqual([]) + expect(pending).toEqual([expectTransaction, expectTransaction]) + }) + + it('should ask the user to retry if not in the CI mode', async () => { + const error = new Error('Oh god dammit') + + // Mock the first sendTransaction call to reject, the rest should use the original implementation + // + // This way we simulate a situation in which the first call would fail but then the user retries, it would succeed + sendTransactionMock.mockRejectedValueOnce(error) + + // In the non-CI mode we need to answer the prompts + promptToContinueMock + .mockResolvedValueOnce(false) // We don't want to see the list of transactions + .mockResolvedValueOnce(true) // We want to continue + .mockResolvedValueOnce(true) // We want to see the list of failed transactions + .mockResolvedValueOnce(true) // We want to retry + + const oappConfig = configPathFixture('valid.config.connected.js') + const [successful, errors, pending] = await hre.run(TASK_LZ_WIRE_OAPP, { oappConfig }) + + // Check that the user has been asked to retry + expect(promptToContinueMock).toHaveBeenCalledWith(`Would you like to preview the failed transactions?`) + expect(promptToContinueMock).toHaveBeenCalledWith(`Would you like to retry?`, true) + + // After retrying, the signer should not fail anymore + expect(successful).toEqual([expectTransactionWithReceipt, expectTransactionWithReceipt]) + expect(errors).toEqual([]) + expect(pending).toEqual([]) + }) + + it('should not retry if the user decides not to if not in the CI mode', async () => { + const error = new Error('Oh god dammit') + + // Mock the first sendTransaction call to reject, the rest should use the original implementation + // + // This way we simulate a situation in which the first call would fail but then the user retries, it would succeed + sendTransactionMock.mockRejectedValueOnce(error) + + // In the non-CI mode we need to answer the prompts + promptToContinueMock + .mockResolvedValueOnce(false) // We don't want to see the list of transactions + .mockResolvedValueOnce(true) // We want to continue + .mockResolvedValueOnce(false) // We don't want to see the list of failed transactions + .mockResolvedValueOnce(false) // We don't want to retry + + const oappConfig = configPathFixture('valid.config.connected.js') + const [successful, errors, pending] = await hre.run(TASK_LZ_WIRE_OAPP, { oappConfig }) + + // Check that the user has been asked to retry + expect(promptToContinueMock).toHaveBeenCalledWith(`Would you like to preview the failed transactions?`) + expect(promptToContinueMock).toHaveBeenCalledWith(`Would you like to retry?`, true) + + // Check that we got the failures back + expect(errors).toEqual([ + { + error, + transaction: expectTransaction, + }, + ]) + + // Since we failed on the first transaction, we expect + // all the transaction to still be pending and none of them to be successful + expect(successful).toEqual([]) + expect(pending).toEqual([expectTransaction, expectTransaction]) + }) + + it('should not retry successful transactions', async () => { + const error = new Error('Oh god dammit') + + // Mock the second & third sendTransaction call to reject + // + // This way we simulate a situation in which the first call goes through, + // then the second and third calls reject + sendTransactionMock + .mockImplementationOnce(sendTransactionMock.getMockImplementation()!) + .mockRejectedValueOnce(error) + .mockRejectedValueOnce(error) + + // In the non-CI mode we need to answer the prompts + promptToContinueMock + .mockResolvedValueOnce(false) // We don't want to see the list of transactions + .mockResolvedValueOnce(true) // We want to continue + .mockResolvedValueOnce(true) // We want to see the list of failed transactions + .mockResolvedValueOnce(true) // We want to retry + .mockResolvedValueOnce(true) // We want to see the list of failed transactions + .mockResolvedValueOnce(true) // We want to retry + + const oappConfig = configPathFixture('valid.config.connected.js') + const [successful, errors, pending] = await hre.run(TASK_LZ_WIRE_OAPP, { oappConfig }) + + // Check that the user has been asked to retry + expect(promptToContinueMock).toHaveBeenCalledWith(`Would you like to preview the failed transactions?`) + expect(promptToContinueMock).toHaveBeenCalledWith(`Would you like to retry?`, true) + + // After retrying, the signer should not fail anymore + expect(successful).toEqual([expectTransactionWithReceipt, expectTransactionWithReceipt]) + expect(errors).toEqual([]) + expect(pending).toEqual([]) + + expect(sendTransactionMock).toHaveBeenCalledTimes( + // The first successful call + 1 + + // The first failed call + 1 + + // The retry of the failed call + 1 + + // The retry of the failed call + 1 + ) + }) }) }) })