import chai from "chai"; import chaiAsPromised from "chai-as-promised"; import { jestSnapshotPlugin } from "mocha-chai-jest-snapshot"; chai.use(jestSnapshotPlugin()); chai.use(chaiAsPromised); chai.should(); import chalk from "chalk"; import ora from "ora"; import hre, { ethers } from "hardhat"; import { BigNumberish, Contract, ContractFactory, ContractReceipt, ContractTransaction, } from "ethers"; import { Interface, ParamType } from "ethers/lib/utils"; import { IERC20 } from "../../typechain"; import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; /** Returns a valid value for a param type. */ export function getValueForParamType(paramType: ParamType) { const baseType = paramType.baseType; if (baseType === "array") { return []; } else if (baseType === "tuple") { let obj = {}; for (const subParam of paramType.components) { obj[subParam.name] = getValueForParamType(subParam); } return obj; } else if (baseType === "address") { return "0xFEEDFACECAFEBEEFFEEDFACECAFEBEEFFEEDFACE"; } else if (baseType === "bool") { return true; } else if (baseType.includes("bytes")) { if (baseType === "bytes") { return "0x00000000"; } const numberOfBytes = parseInt(baseType.replace("bytes", "")); return ethers.utils.hexZeroPad("0x00000000", numberOfBytes); } else if (baseType.includes("uint")) { if (baseType === "uint") { return 1e18; } const numberSize = parseInt(baseType.replace("uint", "")); return Math.min(100000000000, Math.floor(2 ** numberSize / 100)); } else if (baseType.includes("int")) { if (baseType === "int") { return 1e18; } const numberSize = parseInt(baseType.replace("int", "")); return Math.min(100000000000, Math.floor(2 ** numberSize / 100)); } } /** Calls all stateful functions in a contract to check if they revert with unauthorized. */ export async function checkAllFunctionsForAuth( contract: Contract, account: SignerWithAddress, ignoreNames?: string[] ) { const statefulFragments = getAllStatefulFragments(contract.interface); for (const fragment of statefulFragments) { if (ignoreNames?.includes(fragment.name)) { continue; } await contract .connect(account) [fragment.name](...fragment.inputs.map(getValueForParamType)) .should.be.revertedWith("UNAUTHORIZED"); } } /** Returns an array of function fragments that are stateful from an interface. */ export function getAllStatefulFragments(contractInterface: Interface) { return Object.values(contractInterface.functions).filter((f) => !f.constant); } /** Gets an ethers factory for a contract. T should be the typechain factory type of the contract (ie: MockERC20__factory). */ export function getFactory<T>(name: string): Promise<T> { return ethers.getContractFactory(name) as any; } export function getOVMFactory<T extends ContractFactory>( name: string, l2: boolean, path?: string ): T { const artifact = require(`../../artifacts${l2 ? "-ovm" : ""}/contracts/${ path ?? "" }${name}.sol/${name}.json`); return new ethers.ContractFactory(artifact.abi, artifact.bytecode) as any; } /** Increases EVM time by `seconds` and mines a new block. */ export async function increaseTimeAndMine(seconds: BigNumberish) { await ethers.provider.send("evm_increaseTime", [parseInt(seconds.toString())]); await ethers.provider.send("evm_mine", []); } /** * Records the gas usage of a transaction, and checks against the most recent saved Jest snapshot. * If not in CI mode it won't stop tests (just show a console log). * To update the Jest snapshot run `npm run gas-changed` */ export async function snapshotGasCost(tx: Promise<ContractTransaction>) { // Only check gas estimates if we're not in coverage mode, as gas estimates are messed up in coverage mode. if (!process.env.HARDHAT_COVERAGE_MODE_ENABLED) { let receipt: ContractReceipt = await (await tx).wait(); try { receipt.gasUsed.toNumber().should.toMatchSnapshot(); } catch (e) { console.log( chalk.red( "(CHANGE) " + e.message .replace("expected", "used") .replace("to equal", "gas, but the snapshot expected it to use") + " gas" ) ); if (process.env.CI) { return Promise.reject("reverted: Gas consumption changed from expected."); } } } return tx; } /** * Waits for a cross domain message originating on L1 to be relayed on L2. */ export async function waitForL1ToL2Relay(l1Tx: Promise<ContractTransaction>, watcher: any) { console.log(); const loader = ora({ text: chalk.grey(`waiting for L1 -> L2 cross domain message to be relayed\n`), color: "yellow", indent: 6, }).start(); const res = await l1Tx; await res.wait(); const [l1ToL2XDomainMsgHash] = await watcher.getMessageHashesFromL1Tx(res.hash); const receipt: ContractReceipt = await watcher.getL2TransactionReceipt(l1ToL2XDomainMsgHash); loader.stopAndPersist({ symbol: chalk.yellow("✓"), text: chalk.gray( `relay completed on L2 for cross domain message: ${chalk.yellow(receipt.transactionHash)}\n` ), }); loader.indent = 0; loader.stop(); } /** * Deploys a contract and if it's on a network that has etherscan, logs info on how to verify it on Etherscan. */ export async function deployAndLogVerificationInfo<T extends ContractFactory>( factory: T, ...args: Parameters<T["deploy"]> ): Promise<ReturnType<T["deploy"]>> { const chainID = await factory.signer.getChainId(); const [networkName] = Object.entries(hre.config.networks).find( ([, config]) => config.chainId == chainID ); const shouldPrintVerifyInfo = chainID == 1 || chainID == 42 || chainID == 10 || chainID == 69; if (shouldPrintVerifyInfo) { console.log(); } const loader = ora({ text: chalk.gray(`deploying contract on ${chalk.blue(networkName)}\n`), color: "blue", indent: 6, }).start(); const deployed = await (await factory.deploy(...args)).deployed(); if (shouldPrintVerifyInfo) { loader.stopAndPersist({ symbol: chalk.blue("✓"), text: chalk.gray( `npx hardhat verify --network ${networkName} ${chalk.blue(deployed.address)} ${args.join( " " )}\n` ), }); loader.indent = 0; } else { loader.indent = 0; loader.stop(); } return deployed; } /** * Checkpoints `user`'s `token` balance upon calling. * Returns two functions (calcIncrease and calcDecrease, * calling calcIncrease will return the `user`'s new `token` * balance minus the starting balance. Calling calcDecrease * subtracts the final balance from the balance. * */ export async function checkpointERC20Balance(token: IERC20, user: string) { const startingBalance = await token.balanceOf(user); async function calcIncrease() { const finalBalance = await token.balanceOf(user); return finalBalance.sub(startingBalance); } async function calcDecrease() { const finalBalance = await token.balanceOf(user); return startingBalance.sub(finalBalance); } return [calcIncrease, calcDecrease]; } /** * Checkpoints `user`'s ETH balance upon calling. * Returns two functions (calcIncrease and calcDecrease, * calling calcIncrease will return the `user`'s new ETH * balance minus the starting balance. Calling calcDecrease * subtracts the final balance from the balance. * */ export async function checkpointETHBalance(user: string) { const startingBalance = await ethers.provider.getBalance(user); async function calcIncrease() { const finalBalance = await ethers.provider.getBalance(user); return finalBalance.sub(startingBalance); } async function calcDecrease() { const finalBalance = await ethers.provider.getBalance(user); return startingBalance.sub(finalBalance); } return [calcIncrease, calcDecrease]; } /** Get the ETH paid for the gas of a tx (gasPrice * gasUsed) */ export async function getETHPaidForTx(tx: Promise<ContractTransaction>) { const awaitedTx = await tx; const { gasUsed } = await awaitedTx.wait(); return awaitedTx.gasPrice.mul(gasUsed); } /** Gets the latest block's timestamp in seconds. */ export async function latestBlockTimestamp() { return (await ethers.provider.getBlock("latest")).timestamp; } /** Waits for a transaction to be included in a block. */ export async function wait(tx: Promise<ContractTransaction>) { const resolvedTx = await tx; return await resolvedTx.wait(); } export * from "./nova";