Skip to content

Commit

Permalink
🪚 Retry wiring (#174)
Browse files Browse the repository at this point in the history
  • Loading branch information
janjakubnanista authored Jan 10, 2024
1 parent 5236166 commit 161e928
Show file tree
Hide file tree
Showing 2 changed files with 249 additions and 36 deletions.
118 changes: 88 additions & 30 deletions packages/ua-devtools-evm-hardhat/src/tasks/oapp/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,19 @@ 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
logLevel?: string
ci?: boolean
}

const action: ActionType<TaskArgs> = async ({ oappConfig: oappConfigPath, logLevel = 'info', ci = false }) => {
const action: ActionType<TaskArgs> = async ({
oappConfig: oappConfigPath,
logLevel = 'info',
ci = false,
}): Promise<SignAndSendResult> => {
printLogo()

// We only want to be asking users for input if we are not in interactive mode
Expand Down Expand Up @@ -59,7 +64,7 @@ const action: ActionType<TaskArgs> = 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
Expand All @@ -78,49 +83,102 @@ const action: ActionType<TaskArgs> = 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)
Expand Down
167 changes: 161 additions & 6 deletions tests/ua-devtools-evm-hardhat-test/test/task/oapp/wire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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)
Expand Down Expand Up @@ -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()
})

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

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

0 comments on commit 161e928

Please sign in to comment.