From cde99243e0f54bf8e95a280e0469afb8647e5bb3 Mon Sep 17 00:00:00 2001 From: Dean Date: Thu, 12 Oct 2023 04:01:29 -0400 Subject: [PATCH] feat: improved test coverage part three (#115) * renamed folder and changed version * npmignore * npmignore * change version * using include pattern instead. * Fixed most of the things least auhority suggested. * made lint happy * Apply suggestions from code review * fixed some bugs * added events * rename set to transfer for distributor and operator * changed standardized token to always allow token managers to mint/burn it. * using immutable storage for remoteAddressValidator address to save gas * Added some recommended changes * added milap's suggested changes * Fixed some names and some minor gas optimizations * prettier and lint * stash * import .env in hardhat.config * trying to fix .env.example * Added some getters in IRemoteAddressValidator and removed useless check for distributor in the InterchainTokenService. * removed ternary operators * made lint happy * made lint happy * Added a new token manager to handle fee on transfer and added some tests for it as well * fixed the liquidity pool check. * fix a duplication bug * lint * added some more tests * Added more tests * Added proper re-entrancy protection for fee on transfer token managers. * change to tx.origin for refunds * Added support for more kinds of addresses. * some minor gas opts * some more gas optimizations. * Added a getter for chain name to the remote address validator. * moved the tokenManager getter functionality to a separate contract which saves almost a kilobyte of codesize. * made lint happy * Removed tokenManagerGetter and put params into tokenManagers * Added separate tokenManager interfaces * addressed ackeeblockchains's 3.0 report * prettier * added interchain transfer methods to the service and unified receiving tokens a bit. * made lint happy * rename sendToken to interchainTransfer * changed sendToken everywhere * changed from uint256.max to a const * change setting to zero to delete for storage slots. * rearange storage variables to save a bit of gas. * Removed unecesairy casts * made as many event params inexed as possible * Removed unused imports * domain separator is calculated each time. * added some natspec * added an example for using pre-existing custom tokens. * added a comment * feat: improved test coverage part one * feat: live testnet support * fix: remove exclusive mocha test * fix: remove hardcoded gas options * feat: increased test coverage part two * fix: remove comment * feat: add test * feat: increased test coverage part three * fix(InvalidStandardizedToken): imports * feat: address comments * fix: ITS tests * fix: tests * fix: tests * fix: remove broken test * feat: add tests * Update test/tokenService.js --------- Co-authored-by: Foivos Co-authored-by: Milap Sheth Co-authored-by: Dean Amiel Co-authored-by: Kiryl Yermakou --- .../test/utils/AddressBytesUtilsTest.sol | 18 ++ contracts/test/utils/NoReEntrancyTest.sol | 17 ++ test/UtilsTest.js | 43 +++- test/standardizedToken.js | 9 + test/tokenManager.js | 119 ++++++++++ test/tokenService.js | 216 +++++++++++++++++- 6 files changed, 418 insertions(+), 4 deletions(-) create mode 100644 contracts/test/utils/AddressBytesUtilsTest.sol create mode 100644 contracts/test/utils/NoReEntrancyTest.sol create mode 100644 test/tokenManager.js diff --git a/contracts/test/utils/AddressBytesUtilsTest.sol b/contracts/test/utils/AddressBytesUtilsTest.sol new file mode 100644 index 00000000..d25355cb --- /dev/null +++ b/contracts/test/utils/AddressBytesUtilsTest.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import { AddressBytesUtils } from '../../libraries/AddressBytesUtils.sol'; + +contract AddressBytesUtilsTest { + using AddressBytesUtils for address; + using AddressBytesUtils for bytes; + + function toAddress(bytes memory bytesAddress) external pure returns (address addr) { + return bytesAddress.toAddress(); + } + + function toBytes(address addr) external pure returns (bytes memory bytesAddress) { + return addr.toBytes(); + } +} diff --git a/contracts/test/utils/NoReEntrancyTest.sol b/contracts/test/utils/NoReEntrancyTest.sol new file mode 100644 index 00000000..cd43f14f --- /dev/null +++ b/contracts/test/utils/NoReEntrancyTest.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import { NoReEntrancy } from '../../utils/NoReEntrancy.sol'; + +contract NoReEntrancyTest is NoReEntrancy { + uint256 public value; + + function testFunction() external noReEntrancy { + value = 1; + this.callback(); + value = 2; + } + + function callback() external noReEntrancy {} +} diff --git a/test/UtilsTest.js b/test/UtilsTest.js index 6b884c06..397ea7e5 100644 --- a/test/UtilsTest.js +++ b/test/UtilsTest.js @@ -6,7 +6,8 @@ const { ethers } = require('hardhat'); const { time } = require('@nomicfoundation/hardhat-network-helpers'); const { Wallet, Contract } = ethers; const { AddressZero } = ethers.constants; -const { defaultAbiCoder } = ethers.utils; +const { defaultAbiCoder, arrayify, toUtf8Bytes, hexlify } = ethers.utils; + const { expect } = chai; const { getRandomBytes32, expectRevert } = require('./utils'); const { deployContract } = require('../scripts/deploy'); @@ -392,4 +393,44 @@ describe('StandardizedTokenDeployer', () => { 'AlreadyDeployed', ); }); + + describe('AddressBytesUtils', () => { + let addressBytesUtils; + + before(async () => { + addressBytesUtils = await deployContract(ownerWallet, 'AddressBytesUtilsTest'); + }); + + it('Should convert bytes address to address', async () => { + const bytesAddress = arrayify(ownerWallet.address); + const convertedAddress = await addressBytesUtils.toAddress(bytesAddress); + expect(convertedAddress).to.eq(ownerWallet.address); + }); + + it('Should revert on invalid bytes length', async () => { + const bytesAddress = defaultAbiCoder.encode(['bytes'], [toUtf8Bytes(ownerWallet.address)]); + await expectRevert( + (gasOptions) => addressBytesUtils.toAddress(bytesAddress, gasOptions), + addressBytesUtils, + 'InvalidBytesLength', + ); + }); + + it('Should convert address to bytes address', async () => { + const convertedAddress = await addressBytesUtils.toBytes(ownerWallet.address); + expect(convertedAddress).to.eq(hexlify(ownerWallet.address)); + }); + }); + + describe('NoReEntrancy', () => { + let noReEntrancy; + + before(async () => { + noReEntrancy = await deployContract(ownerWallet, 'NoReEntrancyTest'); + }); + + it('Should revert on reentrancy', async function () { + await expect(noReEntrancy.testFunction()).to.be.revertedWithCustomError(noReEntrancy, 'ReEntrancy'); + }); + }); }); diff --git a/test/standardizedToken.js b/test/standardizedToken.js index 06e34238..798d3c76 100644 --- a/test/standardizedToken.js +++ b/test/standardizedToken.js @@ -67,6 +67,15 @@ describe('StandardizedToken', () => { ).to.be.reverted; }); + it('should revert if standardized token setup fails', async () => { + const params = '0x1234'; + await expectRevert( + (gasOptions) => deployContract(owner, 'StandardizedTokenProxy', [standardizedToken.address, params, gasOptions]), + tokenProxy, + 'SetupFailed', + ); + }); + it('should return the correct contract ID', async () => { const contractID = await token.contractId(); const hash = keccak256(toUtf8Bytes('standardized-token')); diff --git a/test/tokenManager.js b/test/tokenManager.js new file mode 100644 index 00000000..264d69e6 --- /dev/null +++ b/test/tokenManager.js @@ -0,0 +1,119 @@ +'use strict'; + +const chai = require('chai'); +const { ethers } = require('hardhat'); +const { + utils: { toUtf8Bytes, defaultAbiCoder }, + constants: { AddressZero }, +} = ethers; +const { expect } = chai; +const { expectRevert } = require('./utils'); +const { deployContract } = require('../scripts/deploy'); + +describe('Token Manager', () => { + let owner, user, token, service, liquidityPool; + let tokenManagerLockUnlock, tokenManagerMintBurn, tokenManagerLiquidityPool, tokenManagerLockUnlockFeeOnTransfer; + + before(async () => { + [owner, user, token, service, liquidityPool] = await ethers.getSigners(); + + tokenManagerLockUnlock = await deployContract(owner, `TokenManagerLockUnlock`, [service.address]); + tokenManagerMintBurn = await deployContract(owner, `TokenManagerMintBurn`, [service.address]); + tokenManagerLiquidityPool = await deployContract(owner, `TokenManagerLiquidityPool`, [service.address]); + tokenManagerLockUnlockFeeOnTransfer = await deployContract(owner, `TokenManagerLockUnlockFee`, [service.address]); + }); + + it('Should revert on token manager deployment with invalid service address', async () => { + await expectRevert( + (gasOptions) => deployContract(owner, `TokenManagerLockUnlock`, [AddressZero, gasOptions]), + tokenManagerLockUnlock, + 'TokenLinkerZeroAddress', + ); + }); + + it('Should revert on setup if not called by the proxy', async () => { + const params = '0x'; + await expectRevert((gasOptions) => tokenManagerLockUnlock.setup(params, gasOptions), tokenManagerLockUnlock, 'NotProxy'); + }); + + it('Should revert on transmitInterchainTransfer if not called by the token', async () => { + const sender = owner.address; + const destinationChain = 'Dest Chain'; + const destinationAddress = toUtf8Bytes(user.address); + const amount = 10; + const metadata = '0x00000000'; + + await expectRevert( + (gasOptions) => + tokenManagerLockUnlock.transmitInterchainTransfer( + sender, + destinationChain, + destinationAddress, + amount, + metadata, + gasOptions, + ), + tokenManagerLockUnlock, + 'NotToken', + ); + }); + + it('Should revert on giveToken if not called by the service', async () => { + const destinationAddress = user.address; + const amount = 10; + + await expectRevert( + (gasOptions) => tokenManagerLockUnlock.giveToken(destinationAddress, amount, gasOptions), + tokenManagerLockUnlock, + 'NotService', + ); + }); + + it('Should revert on takeToken if not called by the service', async () => { + const sourceAddress = user.address; + const amount = 10; + + await expectRevert( + (gasOptions) => tokenManagerLockUnlock.takeToken(sourceAddress, amount, gasOptions), + tokenManagerLockUnlock, + 'NotService', + ); + }); + + it('Should revert on setFlowLimit if not called by the operator', async () => { + const flowLimit = 100; + + await expectRevert( + (gasOptions) => tokenManagerLockUnlock.setFlowLimit(flowLimit, gasOptions), + tokenManagerLockUnlock, + 'NotOperator', + ); + }); + + it('Should return the correct parameters for lock/unlock token manager', async () => { + const expectedParams = defaultAbiCoder.encode(['bytes', 'address'], [toUtf8Bytes(owner.address), token.address]); + const params = await tokenManagerLockUnlock.getParams(toUtf8Bytes(owner.address), token.address); + expect(expectedParams).to.eq(params); + }); + + it('Should return the correct parameters for mint/burn token manager', async () => { + const expectedParams = defaultAbiCoder.encode(['bytes', 'address'], [toUtf8Bytes(owner.address), token.address]); + const params = await tokenManagerMintBurn.getParams(toUtf8Bytes(owner.address), token.address); + expect(expectedParams).to.eq(params); + }); + + it('Should return the correct parameters for liquidity pool token manager', async () => { + const expectedParams = defaultAbiCoder.encode( + ['bytes', 'address', 'address'], + [toUtf8Bytes(owner.address), token.address, liquidityPool.address], + ); + const params = await tokenManagerLiquidityPool.getParams(toUtf8Bytes(owner.address), token.address, liquidityPool.address); + expect(expectedParams).to.eq(params); + }); + + it('Should return the correct parameters for fee on transfer token manager', async () => { + const expectedParams = defaultAbiCoder.encode(['bytes', 'address'], [toUtf8Bytes(owner.address), token.address]); + const params = await tokenManagerLockUnlockFeeOnTransfer.getParams(toUtf8Bytes(owner.address), token.address); + expect(expectedParams).to.eq(params); + }); +}); diff --git a/test/tokenService.js b/test/tokenService.js index 0c1c1735..2748206d 100644 --- a/test/tokenService.js +++ b/test/tokenService.js @@ -8,6 +8,7 @@ const { AddressZero, MaxUint256 } = ethers.constants; const { defaultAbiCoder, solidityPack, keccak256, arrayify } = ethers.utils; const { Contract, Wallet } = ethers; const TokenManager = require('../artifacts/contracts/token-manager/TokenManager.sol/TokenManager.json'); +const TokenManagerLiquidityPool = require('../artifacts/contracts/token-manager/implementations/TokenManagerLiquidityPool.sol/TokenManagerLiquidityPool.json'); const Token = require('../artifacts/contracts/interfaces/IStandardizedToken.sol/IStandardizedToken.json'); const { getCreate3Address } = require('@axelar-network/axelar-gmp-sdk-solidity'); const { approveContractCall } = require('../scripts/utils'); @@ -35,7 +36,7 @@ const LOCK_UNLOCK_FEE_ON_TRANSFER = 3; const LIQUIDITY_POOL = 4; describe('Interchain Token Service', () => { - let wallet, liquidityPool; + let wallet, liquidityPool, otherWallet; let service, gateway, gasService; const deployFunctions = {}; @@ -170,6 +171,7 @@ describe('Interchain Token Service', () => { const wallets = await ethers.getSigners(); wallet = wallets[0]; liquidityPool = wallets[1]; + otherWallet = wallets[2]; [service, gateway, gasService] = await deployAll(wallet, 'Test', [sourceChain, destinationChain]); }); @@ -279,6 +281,26 @@ describe('Interchain Token Service', () => { ); }); + it('Should revert on invalid gateway', async () => { + await expectRevert( + (gasOptions) => + deployInterchainTokenService( + wallet, + create3Deployer.address, + tokenManagerDeployer.address, + standardizedTokenDeployer.address, + AddressZero, + gasService.address, + remoteAddressValidator.address, + tokenManagerImplementations.map((impl) => impl.address), + deploymentKey, + gasOptions, + ), + service, + 'ZeroAddress', + ); + }); + it('Should revert on invalid token manager implementation length', async () => { tokenManagerImplementations.push(wallet); @@ -373,6 +395,26 @@ describe('Interchain Token Service', () => { 'InvalidTokenManagerImplementation', ); }); + + it('Should revert if setup fails on TokenManager implementation deployment', async () => { + const salt = getRandomBytes32(); + const tokenId = await service.getCustomTokenId(wallet.address, salt); + const validParams = defaultAbiCoder.encode(['bytes', 'address'], ['0x', wallet.address]); + const tokenManagerProxy = await deployContract(wallet, `TokenManagerProxy`, [ + service.address, + LOCK_UNLOCK, + tokenId, + validParams, + ]); + const invalidParams = '0x1234'; + + await expectRevert( + (gasOptions) => + deployContract(wallet, `TokenManagerProxy`, [service.address, LOCK_UNLOCK, tokenId, invalidParams, gasOptions]), + tokenManagerProxy, + 'SetupFailed', + ); + }); }); describe('Register Canonical Token', () => { @@ -724,6 +766,35 @@ describe('Interchain Token Service', () => { await txPaused.wait(); }); + it('Should revert on receiving a remote standardized token depoloyment if not approved by the gateway', async () => { + const tokenId = getRandomBytes32(); + const distributor = wallet.address; + const operator = wallet.address; + const mintTo = '0x'; + const mintAmount = 1234; + const commandId = getRandomBytes32(); + const payload = defaultAbiCoder.encode( + ['uint256', 'bytes32', 'string', 'string', 'uint8', 'bytes', 'bytes', 'uint256', 'bytes'], + [ + SELECTOR_DEPLOY_AND_REGISTER_STANDARDIZED_TOKEN, + tokenId, + tokenName, + tokenSymbol, + tokenDecimals, + distributor, + mintTo, + mintAmount, + operator, + ], + ); + + await expectRevert( + (gasOptions) => service.execute(commandId, sourceChain, sourceAddress, payload, gasOptions), + service, + 'NotApprovedByGateway', + ); + }); + it('Should be able to receive a remote standardized token depoloyment with a lock/unlock token manager', async () => { const tokenId = getRandomBytes32(); const distributor = wallet.address; @@ -872,6 +943,19 @@ describe('Interchain Token Service', () => { expect(await tokenManager.tokenAddress()).to.equal(tokenAddress); expect(await tokenManager.operator()).to.equal(service.address); }); + + it('Should revert on execute with token', async () => { + const commandId = getRandomBytes32(); + const sourceAddress = 'Source Address'; + const payload = '0x'; + const amount = 123; + + await expectRevert( + (gasOptions) => service.executeWithToken(commandId, sourceChain, sourceAddress, payload, tokenSymbol, amount, gasOptions), + service, + 'ExecuteWithTokenNotSupported', + ); + }); }); describe('Custom Token Manager Deployment', () => { @@ -899,6 +983,9 @@ describe('Interchain Token Service', () => { const tokenManager = new Contract(tokenManagerAddress, TokenManager.abi, wallet); expect(await tokenManager.operator()).to.equal(wallet.address); + + const tokenAddress = await service.getTokenAddress(tokenId); + expect(tokenAddress).to.eq(token.address); }); it('Should deploy a mint/burn token manager', async () => { @@ -918,6 +1005,31 @@ describe('Interchain Token Service', () => { const tokenManager = new Contract(tokenManagerAddress, TokenManager.abi, wallet); expect(await tokenManager.operator()).to.equal(wallet.address); + + const tokenAddress = await service.getTokenAddress(tokenId); + expect(tokenAddress).to.eq(token.address); + }); + + it('Should deploy a mint/burn_from token manager', async () => { + const tokenName = 'Token Name'; + const tokenSymbol = 'TN'; + const tokenDecimals = 13; + const salt = getRandomBytes32(); + const tokenId = await service.getCustomTokenId(wallet.address, salt); + const tokenManagerAddress = await service.getTokenManagerAddress(tokenId); + const token = await deployContract(wallet, 'InterchainTokenTest', [tokenName, tokenSymbol, tokenDecimals, tokenManagerAddress]); + const params = defaultAbiCoder.encode(['bytes', 'address'], [wallet.address, token.address]); + + const tx = service.deployCustomTokenManager(salt, MINT_BURN_FROM, params); + await expect(tx).to.emit(service, 'TokenManagerDeployed').withArgs(tokenId, MINT_BURN_FROM, params); + + expect(tokenManagerAddress).to.not.equal(AddressZero); + const tokenManager = new Contract(tokenManagerAddress, TokenManager.abi, wallet); + + expect(await tokenManager.operator()).to.equal(wallet.address); + + const tokenAddress = await service.getTokenAddress(tokenId); + expect(tokenAddress).to.eq(token.address); }); it('Should deploy a lock/unlock with fee on transfer token manager', async () => { @@ -942,6 +1054,9 @@ describe('Interchain Token Service', () => { const tokenManager = new Contract(tokenManagerAddress, TokenManager.abi, wallet); expect(await tokenManager.operator()).to.equal(wallet.address); + + const tokenAddress = await service.getTokenAddress(tokenId); + expect(tokenAddress).to.eq(token.address); }); it('Should deploy a liquidity pool token manager', async () => { @@ -961,6 +1076,37 @@ describe('Interchain Token Service', () => { const tokenManager = new Contract(tokenManagerAddress, TokenManager.abi, wallet); expect(await tokenManager.operator()).to.equal(wallet.address); + + const tokenAddress = await service.getTokenAddress(tokenId); + expect(tokenAddress).to.eq(token.address); + }); + + it('Should allow operator to change the liquidity pool address', async () => { + const tokenName = 'Token Name'; + const tokenSymbol = 'TN'; + const tokenDecimals = 13; + const salt = getRandomBytes32(); + const tokenId = await service.getCustomTokenId(wallet.address, salt); + const tokenManagerAddress = await service.getTokenManagerAddress(tokenId); + const token = await deployContract(wallet, 'InterchainTokenTest', [tokenName, tokenSymbol, tokenDecimals, tokenManagerAddress]); + const params = defaultAbiCoder.encode(['bytes', 'address', 'address'], [wallet.address, token.address, liquidityPool.address]); + + const tx = service.deployCustomTokenManager(salt, LIQUIDITY_POOL, params); + await expect(tx).to.emit(service, 'TokenManagerDeployed').withArgs(tokenId, LIQUIDITY_POOL, params); + + expect(tokenManagerAddress).to.not.equal(AddressZero); + const tokenManagerLiquidityPool = new Contract(tokenManagerAddress, TokenManagerLiquidityPool.abi, wallet); + + expectRevert( + (gasOptions) => tokenManagerLiquidityPool.connect(otherWallet).setLiquidityPool(otherWallet.address, gasOptions), + tokenManagerLiquidityPool, + 'NotOperator', + ); + + await tokenManagerLiquidityPool.setLiquidityPool(otherWallet.address).then((tx) => tx.wait()); + + const newLiquidityPool = await tokenManagerLiquidityPool.liquidityPool(); + expect(newLiquidityPool).to.eq(otherWallet.address); }); it('Should revert when deploying a custom token manager twice', async () => { @@ -1650,7 +1796,73 @@ describe('Interchain Token Service', () => { .to.emit(service, 'TokenSent') .withArgs(tokenId, destinationChain, destAddress, sendAmount); }); + + it(`Should be able to initiate an interchain token transfer using interchainTransferFrom [${type}]`, async () => { + const [token, tokenManager, tokenId] = await deployFunctions[type](`Test Token ${type}`, 'TT', 12, amount, true); + const sendAmount = type === 'lockUnlockFee' ? amount - 10 : amount; + const payload = defaultAbiCoder.encode( + ['uint256', 'bytes32', 'bytes', 'uint256'], + [SELECTOR_SEND_TOKEN, tokenId, destAddress, sendAmount], + ); + const payloadHash = keccak256(payload); + + let transferToAddress = AddressZero; + + if (type === 'lockUnlock' || type === 'lockUnlockFee') { + transferToAddress = tokenManager.address; + } else if (type === 'liquidityPool') { + transferToAddress = liquidityPool.address; + } + + const sender = wallet; + const spender = otherWallet; + await token.approve(spender.address, amount).then((tx) => tx.wait()); + + await expect( + token + .connect(spender) + .interchainTransferFrom(sender.address, destinationChain, destAddress, amount, metadata, { value: gasValue }), + ) + .and.to.emit(token, 'Transfer') + .withArgs(wallet.address, transferToAddress, amount) + .and.to.emit(gateway, 'ContractCall') + .withArgs(service.address, destinationChain, service.address.toLowerCase(), payloadHash, payload) + .and.to.emit(gasService, 'NativeGasPaidForContractCall') + .withArgs(service.address, destinationChain, service.address.toLowerCase(), payloadHash, gasValue, spender.address) + .to.emit(service, 'TokenSent') + .withArgs(tokenId, destinationChain, destAddress, sendAmount); + }); } + + it(`Should be able to initiate an interchain token transfer using interchainTransferFrom with max possible allowance`, async () => { + const [token, tokenManager, tokenId] = await deployFunctions.lockUnlock(`Test Token LockUnlock`, 'TT', 12, amount, true); + const sendAmount = amount; + const payload = defaultAbiCoder.encode( + ['uint256', 'bytes32', 'bytes', 'uint256'], + [SELECTOR_SEND_TOKEN, tokenId, destAddress, sendAmount], + ); + const payloadHash = keccak256(payload); + + const transferToAddress = tokenManager.address; + + const sender = wallet; + const spender = otherWallet; + await token.approve(spender.address, MaxUint256).then((tx) => tx.wait()); + + await expect( + token + .connect(spender) + .interchainTransferFrom(sender.address, destinationChain, destAddress, amount, metadata, { value: gasValue }), + ) + .and.to.emit(token, 'Transfer') + .withArgs(wallet.address, transferToAddress, amount) + .and.to.emit(gateway, 'ContractCall') + .withArgs(service.address, destinationChain, service.address.toLowerCase(), payloadHash, payload) + .and.to.emit(gasService, 'NativeGasPaidForContractCall') + .withArgs(service.address, destinationChain, service.address.toLowerCase(), payloadHash, gasValue, spender.address) + .to.emit(service, 'TokenSent') + .withArgs(tokenId, destinationChain, destAddress, sendAmount); + }); }); describe('Send Interchain Token With Data', () => { @@ -1990,8 +2202,6 @@ describe('Interchain Token Service', () => { await (await tokenManager.setFlowLimit(flowLimit)).wait(); }); - // These tests will fail every once in a while since the two transactions will happen in different epochs. - // LMK of any fixes to this that do not involve writing a new contract to facilitate a multicall. it('Should be able to send token only if it does not trigger the mint limit', async () => { await (await tokenManager.interchainTransfer(destinationChain, destinationAddress, sendAmount, '0x')).wait(); await expectRevert(