From 7ae7122334a75cbbcd3a2b117082ad4e5ddde3d3 Mon Sep 17 00:00:00 2001 From: Piotr Dyraga Date: Sat, 18 Feb 2023 22:57:07 +0100 Subject: [PATCH 1/9] L2 generic token: add and remove minter This is the first step for the implementation of a generic L2/sidechain token contract. The L2/sidechain token will be upgradeable, owned by Threshold Council, will delegate the minting authority to multiple parties, and have a pause functionality for mints and burns. Individual L2/sidechain token implementations will inherit from this contract setting only the token name and symbol. So far, adding and removing minters was implemented. More to come! --- solidity/contracts/l2/L2TBTC.sol | 81 ++++++++++ solidity/test/l2/L2TBTC.test.ts | 261 +++++++++++++++++++++++++++++++ 2 files changed, 342 insertions(+) create mode 100644 solidity/contracts/l2/L2TBTC.sol create mode 100644 solidity/test/l2/L2TBTC.test.ts diff --git a/solidity/contracts/l2/L2TBTC.sol b/solidity/contracts/l2/L2TBTC.sol new file mode 100644 index 000000000..446c0003c --- /dev/null +++ b/solidity/contracts/l2/L2TBTC.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-3.0-only + +// ██████████████ ▐████▌ ██████████████ +// ██████████████ ▐████▌ ██████████████ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ██████████████ ▐████▌ ██████████████ +// ██████████████ ▐████▌ ██████████████ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ + +pragma solidity ^0.8.17; + +import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +// TODO: +// * Be paused by any one of `n` guardians, allowing avoidance of contagion in case +// of a chain- or bridge-specific incident. +// * Mint by minter. +// * Proper documentation. +// * Test initialization +contract L2TBTC is ERC20Upgradeable, OwnableUpgradeable { + /// @notice Indicates if the given address is a Minter. Only Minters can + /// mint the token. + mapping(address => bool) public isMinter; + + /// @notice List of all Minters. + address[] public minters; + + event MinterAdded(address indexed minter); + event MinterRemoved(address indexed minter); + + modifier onlyMinter() { + require(isMinter[msg.sender], "Caller is not a minter"); + _; + } + + function initialize(string memory _name, string memory _symbol) + external + initializer + { + __Ownable_init(); + __ERC20_init(_name, _symbol); + } + + /// @notice Adds the address to the Minter list. + function addMinter(address minter) external onlyOwner { + require(!isMinter[minter], "This address is already a minter"); + isMinter[minter] = true; + minters.push(minter); + emit MinterAdded(minter); + } + + /// @notice Removes the address from the Minter list. + function removeMinter(address minter) external onlyOwner { + require(isMinter[minter], "This address is not a minter"); + delete isMinter[minter]; + + // We do not expect too many Minters so a simple loop is safe. + for (uint256 i = 0; i < minters.length; i++) { + if (minters[i] == minter) { + minters[i] = minters[minters.length - 1]; + // slither-disable-next-line costly-loop + minters.pop(); + break; + } + } + + emit MinterRemoved(minter); + } + + /// @notice Allows to fetch a list of all Minters. + function getMinters() external view returns (address[] memory) { + return minters; + } +} diff --git a/solidity/test/l2/L2TBTC.test.ts b/solidity/test/l2/L2TBTC.test.ts new file mode 100644 index 000000000..b8f0e8cf0 --- /dev/null +++ b/solidity/test/l2/L2TBTC.test.ts @@ -0,0 +1,261 @@ +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" +import { randomBytes } from "crypto" +import { ethers, getUnnamedAccounts, helpers, waffle } from "hardhat" +import { expect } from "chai" +import { ContractTransaction } from "ethers" + +import type { L2TBTC } from "../../typechain" + +const { createSnapshot, restoreSnapshot } = helpers.snapshot + +describe("L2TBTC", () => { + const fixture = async () => { + const { deployer, governance } = await helpers.signers.getNamedSigners() + + const accounts = await getUnnamedAccounts() + const minter = await ethers.getSigner(accounts[1]) + const thirdParty = await ethers.getSigner(accounts[2]) + + const deployment = await helpers.upgrades.deployProxy( + // Hacky workaround allowing to deploy proxy contract any number of times + // without clearing `deployments/hardhat` directory. + // See: https://github.com/keep-network/hardhat-helpers/issues/38 + `L2TBTC_${randomBytes(8).toString("hex")}`, + { + contractName: "L2TBTC", + initializerArgs: ["ArbitrumTBTC", "ArbTBTC"], + factoryOpts: { signer: deployer }, + proxyOpts: { + kind: "transparent", + }, + } + ) + token = deployment[0] as L2TBTC + + await token.connect(deployer).transferOwnership(governance.address) + + return { + governance, + minter, + thirdParty, + token, + } + } + + let token: L2TBTC + + let governance: SignerWithAddress + let minter: SignerWithAddress + let thirdParty: SignerWithAddress + + before(async () => { + // eslint-disable-next-line @typescript-eslint/no-extra-semi + ;({ governance, minter, thirdParty, token } = await waffle.loadFixture( + fixture + )) + }) + + describe("addMinter", () => { + context("when called not by the owner", () => { + it("should revert", async () => { + await expect( + token.connect(thirdParty).addMinter(thirdParty.address) + ).to.be.revertedWith("Ownable: caller is not the owner") + }) + }) + + context("when called by the owner", () => { + context("when address is not a minter", () => { + let tx: ContractTransaction + + before(async () => { + await createSnapshot() + + tx = await token.connect(governance).addMinter(minter.address) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should add address as a minter", async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(await token.isMinter(minter.address)).to.be.true + }) + + it("should emit an event", async () => { + await expect(tx) + .to.emit(token, "MinterAdded") + .withArgs(minter.address) + }) + }) + + context("when address is a minter", () => { + before(async () => { + await createSnapshot() + + await token.connect(governance).addMinter(minter.address) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should revert", async () => { + await expect( + token.connect(governance).addMinter(minter.address) + ).to.be.revertedWith("This address is already a minter") + }) + }) + + context("when there are multiple minters", () => { + const minters = [ + "0x54DeA8194aaF652Cd296B162A2809dd95529f775", + "0x575E6d8802e7b6A7E8F940640804385D8Bbe2ce0", + "0x66ac131D339704902aECCaBDf55e15daAE8B238f", + ] + + before(async () => { + await createSnapshot() + + await token.connect(governance).addMinter(minters[0]) + await token.connect(governance).addMinter(minters[1]) + await token.connect(governance).addMinter(minters[2]) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should add them into the list", async () => { + expect(await token.getMinters()).to.deep.equal(minters) + }) + }) + }) + }) + + describe("removeMinter", () => { + context("when called not by the owner", () => { + it("should revert", async () => { + await expect( + token.connect(thirdParty).removeMinter(minter.address) + ).to.be.revertedWith("Ownable: caller is not the owner") + }) + }) + + context("when called by the owner", () => { + context("when address is not a minter", () => { + it("should revert", async () => { + await expect( + token.connect(governance).removeMinter(thirdParty.address) + ).to.be.revertedWith("This address is not a minter") + }) + }) + + context("when address is a minter", () => { + let tx: ContractTransaction + + before(async () => { + await createSnapshot() + + await token.connect(governance).addMinter(minter.address) + tx = await token.connect(governance).removeMinter(minter.address) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should take minter role from the address", async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(await token.isMinter(minter.address)).to.be.false + }) + + it("should emit an event", async () => { + await expect(tx) + .to.emit(token, "MinterRemoved") + .withArgs(minter.address) + }) + }) + + context("when there are multiple minters", () => { + const minters = [ + "0x54DeA8194aaF652Cd296B162A2809dd95529f775", + "0x575E6d8802e7b6A7E8F940640804385D8Bbe2ce0", + "0x66ac131D339704902aECCaBDf55e15daAE8B238f", + "0xF844A3a4dA34fDDf51A0Ec7A0a89d1ed5A105e40", + ] + + before(async () => { + await createSnapshot() + + await token.connect(governance).addMinter(minters[0]) + await token.connect(governance).addMinter(minters[1]) + await token.connect(governance).addMinter(minters[2]) + await token.connect(governance).addMinter(minters[3]) + }) + + after(async () => { + await restoreSnapshot() + }) + + context("when deleting the first minter", () => { + before(async () => { + await createSnapshot() + await token.connect(governance).removeMinter(minters[0]) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should update the minters list", async () => { + expect(await token.getMinters()).to.deep.equal([ + "0xF844A3a4dA34fDDf51A0Ec7A0a89d1ed5A105e40", + "0x575E6d8802e7b6A7E8F940640804385D8Bbe2ce0", + "0x66ac131D339704902aECCaBDf55e15daAE8B238f", + ]) + }) + }) + + context("when deleting the last minter", () => { + before(async () => { + await createSnapshot() + await token.connect(governance).removeMinter(minters[3]) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should update the minters list", async () => { + expect(await token.getMinters()).to.deep.equal([ + "0x54DeA8194aaF652Cd296B162A2809dd95529f775", + "0x575E6d8802e7b6A7E8F940640804385D8Bbe2ce0", + "0x66ac131D339704902aECCaBDf55e15daAE8B238f", + ]) + }) + }) + + context("when deleting minter from the middle of the list", () => { + before(async () => { + await createSnapshot() + await token.connect(governance).removeMinter(minters[1]) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should update the minters list", async () => { + expect(await token.getMinters()).to.deep.equal([ + "0x54DeA8194aaF652Cd296B162A2809dd95529f775", + "0xF844A3a4dA34fDDf51A0Ec7A0a89d1ed5A105e40", + "0x66ac131D339704902aECCaBDf55e15daAE8B238f", + ]) + }) + }) + }) + }) + }) +}) From 51eb9de24a758a7a54762f6f8238743c83086d58 Mon Sep 17 00:00:00 2001 From: Piotr Dyraga Date: Wed, 22 Feb 2023 15:20:19 +0100 Subject: [PATCH 2/9] Updated OpenZeppelin dependency to version 4.8.1 4.8.1 is the most recent version. In the `main` version of OpenZeppelin, `draft-ERC20PermitUpgradeable` is no longer a draft but this change is not yet present in 4.8.1 version of OpenZeppelin. Once a new version gets released we should update to drop the `draft-` prefix from the import in L2TBTC (see the very next commit). For now, I am updating to the most recent version to be closer to 4.8.2 or 4.9.0 we will be updating to. --- solidity/package.json | 4 ++-- solidity/yarn.lock | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/solidity/package.json b/solidity/package.json index c4c1c9ad2..a008f0720 100644 --- a/solidity/package.json +++ b/solidity/package.json @@ -35,8 +35,8 @@ "@keep-network/ecdsa": "development", "@keep-network/random-beacon": "development", "@keep-network/tbtc": "development", - "@openzeppelin/contracts": "^4.6.0", - "@openzeppelin/contracts-upgradeable": "^4.6.0", + "@openzeppelin/contracts": "^4.8.1", + "@openzeppelin/contracts-upgradeable": "^4.8.1", "@thesis/solidity-contracts": "github:thesis/solidity-contracts#4985bcf" }, "devDependencies": { diff --git a/solidity/yarn.lock b/solidity/yarn.lock index 221f88bce..9710f23f9 100644 --- a/solidity/yarn.lock +++ b/solidity/yarn.lock @@ -2018,6 +2018,11 @@ resolved "https://registry.yarnpkg.com/@openzeppelin/contracts-upgradeable/-/contracts-upgradeable-4.6.0.tgz#1bf55f230f008554d4c6fe25eb165b85112108b0" integrity sha512-5OnVuO4HlkjSCJO165a4i2Pu1zQGzMs//o54LPrwUgxvEO2P3ax1QuaSI0cEHHTveA77guS0PnNugpR2JMsPfA== +"@openzeppelin/contracts-upgradeable@^4.8.1": + version "4.8.1" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts-upgradeable/-/contracts-upgradeable-4.8.1.tgz#363f7dd08f25f8f77e16d374350c3d6b43340a7a" + integrity sha512-1wTv+20lNiC0R07jyIAbHU7TNHKRwGiTGRfiNnA8jOWjKT98g5OgLpYWOi40Vgpk8SPLA9EvfJAbAeIyVn+7Bw== + "@openzeppelin/contracts-upgradeable@~4.5.2": version "4.5.2" resolved "https://registry.yarnpkg.com/@openzeppelin/contracts-upgradeable/-/contracts-upgradeable-4.5.2.tgz#90d9e47bacfd8693bfad0ac8a394645575528d05" @@ -2043,6 +2048,11 @@ resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.6.0.tgz#c91cf64bc27f573836dba4122758b4743418c1b3" integrity sha512-8vi4d50NNya/bQqCmaVzvHNmwHvS0OBKb7HNtuNwEE3scXWrP31fKQoGxNMT+KbzmrNZzatE3QK5p2gFONI/hg== +"@openzeppelin/contracts@^4.8.1": + version "4.8.1" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.8.1.tgz#709cfc4bbb3ca9f4460d60101f15dac6b7a2d5e4" + integrity sha512-xQ6eUZl+RDyb/FiZe1h+U7qr/f4p/SrTSQcTPH2bjur3C5DbuW/zFgCU/b1P/xcIaEqJep+9ju4xDRi3rmChdQ== + "@openzeppelin/contracts@~4.5.0": version "4.5.0" resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.5.0.tgz#3fd75d57de172b3743cdfc1206883f56430409cc" From 9318f12bd4416e3ba308e538ec172bce20672d26 Mon Sep 17 00:00:00 2001 From: Piotr Dyraga Date: Wed, 22 Feb 2023 15:22:56 +0100 Subject: [PATCH 3/9] EIP-1559 support for L2TBTC token --- solidity/contracts/l2/L2TBTC.sol | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/solidity/contracts/l2/L2TBTC.sol b/solidity/contracts/l2/L2TBTC.sol index 446c0003c..285a868e1 100644 --- a/solidity/contracts/l2/L2TBTC.sol +++ b/solidity/contracts/l2/L2TBTC.sol @@ -15,7 +15,10 @@ pragma solidity ^0.8.17; -import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +// EIP-2612 is Final as of 2022-11-01. This file is deprecated in the `main` +// branch of @openzeppelin/contracts-upgradeable. Once a version > 4.8.1 gets +// released, we should drop the `draft-*` prefix from this import. +import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/draft-ERC20PermitUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; // TODO: @@ -24,7 +27,7 @@ import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; // * Mint by minter. // * Proper documentation. // * Test initialization -contract L2TBTC is ERC20Upgradeable, OwnableUpgradeable { +contract L2TBTC is ERC20PermitUpgradeable, OwnableUpgradeable { /// @notice Indicates if the given address is a Minter. Only Minters can /// mint the token. mapping(address => bool) public isMinter; @@ -46,6 +49,7 @@ contract L2TBTC is ERC20Upgradeable, OwnableUpgradeable { { __Ownable_init(); __ERC20_init(_name, _symbol); + __ERC20Permit_init(_name); } /// @notice Adds the address to the Minter list. From 660c47b1b4c1ff77d95c93724f09a567f2a9f24f Mon Sep 17 00:00:00 2001 From: Piotr Dyraga Date: Thu, 23 Feb 2023 10:49:09 +0100 Subject: [PATCH 4/9] Mint and burn functionality for L2TBTC token --- solidity/contracts/l2/L2TBTC.sol | 47 ++++++- solidity/test/l2/L2TBTC.test.ts | 222 ++++++++++++++++++++++++++++++- 2 files changed, 258 insertions(+), 11 deletions(-) diff --git a/solidity/contracts/l2/L2TBTC.sol b/solidity/contracts/l2/L2TBTC.sol index 285a868e1..b43e160c5 100644 --- a/solidity/contracts/l2/L2TBTC.sol +++ b/solidity/contracts/l2/L2TBTC.sol @@ -15,19 +15,21 @@ pragma solidity ^0.8.17; -// EIP-2612 is Final as of 2022-11-01. This file is deprecated in the `main` -// branch of @openzeppelin/contracts-upgradeable. Once a version > 4.8.1 gets -// released, we should drop the `draft-*` prefix from this import. import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/draft-ERC20PermitUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; // TODO: // * Be paused by any one of `n` guardians, allowing avoidance of contagion in case // of a chain- or bridge-specific incident. -// * Mint by minter. // * Proper documentation. -// * Test initialization -contract L2TBTC is ERC20PermitUpgradeable, OwnableUpgradeable { +// * Proper tests, including initialization. +contract L2TBTC is + ERC20Upgradeable, + ERC20BurnableUpgradeable, + ERC20PermitUpgradeable, + OwnableUpgradeable +{ /// @notice Indicates if the given address is a Minter. Only Minters can /// mint the token. mapping(address => bool) public isMinter; @@ -47,12 +49,30 @@ contract L2TBTC is ERC20PermitUpgradeable, OwnableUpgradeable { external initializer { - __Ownable_init(); + // OpenZeppelin upgradeable contracts documentation says: + // + // "Use with multiple inheritance requires special care. Initializer + // functions are not linearized by the compiler like constructors. + // Because of this, each __{ContractName}_init function embeds the + // linearized calls to all parent initializers. As a consequence, + // calling two of these init functions can potentially initialize the + // same contract twice." + // + // Note that ERC20 extensions do not linearize calls to ERC20Upgradeable + // initializer so we call all extension initializers individually. At + // the same time, ERC20PermitUpgradeable does linearize the call to + // EIP712Upgradeable so we are not using the unchained initializer + // versions. __ERC20_init(_name, _symbol); + __ERC20Burnable_init(); __ERC20Permit_init(_name); + __Ownable_init(); } /// @notice Adds the address to the Minter list. + /// @dev Requirements: + /// - The caller must be the contract owner. + /// - `minter` must not be a minter address. function addMinter(address minter) external onlyOwner { require(!isMinter[minter], "This address is already a minter"); isMinter[minter] = true; @@ -61,6 +81,9 @@ contract L2TBTC is ERC20PermitUpgradeable, OwnableUpgradeable { } /// @notice Removes the address from the Minter list. + /// @dev Requirements: + /// - The caller must be the contract owner. + /// - `minter` must be a minter address. function removeMinter(address minter) external onlyOwner { require(isMinter[minter], "This address is not a minter"); delete isMinter[minter]; @@ -78,6 +101,16 @@ contract L2TBTC is ERC20PermitUpgradeable, OwnableUpgradeable { emit MinterRemoved(minter); } + /// @notice Allows one of the minters to mint `amount` tokens and assign + /// them to `account`, increasing the total supply. Emits + /// a `Transfer` event with `from` set to the zero address. + /// @dev Requirements: + /// - The caller must be a minter. + /// - `account` must not be the zero address. + function mint(address account, uint256 amount) external onlyMinter { + _mint(account, amount); + } + /// @notice Allows to fetch a list of all Minters. function getMinters() external view returns (address[] memory) { return minters; diff --git a/solidity/test/l2/L2TBTC.test.ts b/solidity/test/l2/L2TBTC.test.ts index b8f0e8cf0..408a10193 100644 --- a/solidity/test/l2/L2TBTC.test.ts +++ b/solidity/test/l2/L2TBTC.test.ts @@ -1,13 +1,17 @@ import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" -import { randomBytes } from "crypto" +import { randomBytes, Sign } from "crypto" import { ethers, getUnnamedAccounts, helpers, waffle } from "hardhat" import { expect } from "chai" import { ContractTransaction } from "ethers" +import { BigNumber } from "@ethersproject/bignumber" +import { to1e18 } from "../helpers/contract-test-helpers" import type { L2TBTC } from "../../typechain" const { createSnapshot, restoreSnapshot } = helpers.snapshot +const ZERO_ADDRESS = ethers.constants.AddressZero + describe("L2TBTC", () => { const fixture = async () => { const { deployer, governance } = await helpers.signers.getNamedSigners() @@ -15,6 +19,7 @@ describe("L2TBTC", () => { const accounts = await getUnnamedAccounts() const minter = await ethers.getSigner(accounts[1]) const thirdParty = await ethers.getSigner(accounts[2]) + const tokenHolder = await ethers.getSigner(accounts[3]) const deployment = await helpers.upgrades.deployProxy( // Hacky workaround allowing to deploy proxy contract any number of times @@ -38,6 +43,7 @@ describe("L2TBTC", () => { governance, minter, thirdParty, + tokenHolder, token, } } @@ -47,12 +53,12 @@ describe("L2TBTC", () => { let governance: SignerWithAddress let minter: SignerWithAddress let thirdParty: SignerWithAddress + let tokenHolder: SignerWithAddress before(async () => { // eslint-disable-next-line @typescript-eslint/no-extra-semi - ;({ governance, minter, thirdParty, token } = await waffle.loadFixture( - fixture - )) + ;({ governance, minter, thirdParty, tokenHolder, token } = + await waffle.loadFixture(fixture)) }) describe("addMinter", () => { @@ -258,4 +264,212 @@ describe("L2TBTC", () => { }) }) }) + + describe("mint", () => { + const amount = to1e18(50) + + before(async () => { + await createSnapshot() + await token.connect(governance).addMinter(minter.address) + }) + + after(async () => { + await restoreSnapshot() + }) + + context("when called not by a minter", () => { + it("should revert", async () => { + await expect( + token.connect(thirdParty).mint(thirdParty.address, amount) + ).to.be.revertedWith("Caller is not a minter") + }) + }) + + context("when called by a minter", () => { + context("for a zero account", () => { + it("should revert", async () => { + await expect( + token.connect(minter).mint(ZERO_ADDRESS, amount) + ).to.be.revertedWith("ERC20: mint to the zero address") + }) + }) + + context("for a non-zero account", () => { + let mintTx: ContractTransaction + + before(async () => { + await createSnapshot() + mintTx = await token.connect(minter).mint(tokenHolder.address, amount) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should increment totalSupply", async () => { + expect(await token.totalSupply()).to.equal(amount) + }) + + it("should increment recipient balance", async () => { + expect(await token.balanceOf(tokenHolder.address)).to.equal(amount) + }) + + it("should emit Transfer event", async () => { + await expect(mintTx) + .to.emit(token, "Transfer") + .withArgs(ZERO_ADDRESS, tokenHolder.address, amount) + }) + }) + }) + }) + + describe("burn", () => { + const initialSupply = to1e18(36) + const initialBalance = to1e18(18) + + before(async () => { + await createSnapshot() + await token.connect(governance).addMinter(minter.address) + await token.connect(minter).mint(tokenHolder.address, initialBalance) + await token.connect(minter).mint(thirdParty.address, initialBalance) + }) + + after(async () => { + await restoreSnapshot() + }) + + context("when burning more than balance", () => { + it("should revert", async () => { + await expect( + token.connect(tokenHolder).burn(initialBalance.add(1)) + ).to.be.revertedWith("ERC20: burn amount exceeds balance") + }) + }) + + const describeBurn = (description: string, amount: BigNumber) => { + describe(description, () => { + let burnTx: ContractTransaction + + before(async () => { + await createSnapshot() + burnTx = await token.connect(tokenHolder).burn(amount) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should decrement totalSupply", async () => { + const expectedSupply = initialSupply.sub(amount) + expect(await token.totalSupply()).to.equal(expectedSupply) + }) + + it("should decrement account's balance", async () => { + const expectedBalance = initialBalance.sub(amount) + expect(await token.balanceOf(tokenHolder.address)).to.equal( + expectedBalance + ) + }) + + it("should emit Transfer event", async () => { + await expect(burnTx) + .to.emit(token, "Transfer") + .withArgs(tokenHolder.address, ZERO_ADDRESS, amount) + }) + }) + } + + describeBurn("for the entire balance", initialBalance) + describeBurn("for less than the entire balance", initialBalance.sub(1)) + }) + + describe("burnFrom", () => { + const initialSupply = to1e18(36) + const initialBalance = to1e18(18) + + before(async () => { + await createSnapshot() + await token.connect(governance).addMinter(minter.address) + await token.connect(minter).mint(tokenHolder.address, initialBalance) + await token.connect(minter).mint(thirdParty.address, initialBalance) + }) + + after(async () => { + await restoreSnapshot() + }) + + context("when burning more than the balance", () => { + it("should revert", async () => { + await token + .connect(tokenHolder) + .approve(thirdParty.address, initialBalance.add(1)) + await expect( + token + .connect(thirdParty) + .burnFrom(tokenHolder.address, initialBalance.add(1)) + ).to.be.revertedWith("ERC20: burn amount exceeds balance") + }) + }) + + context("when burning more than the allowance", () => { + it("should revert", async () => { + await token + .connect(tokenHolder) + .approve(thirdParty.address, initialBalance.sub(1)) + await expect( + token + .connect(thirdParty) + .burnFrom(tokenHolder.address, initialBalance) + ).to.be.revertedWith("ERC20: insufficient allowance") + }) + }) + + const describeBurnFrom = (description: string, amount: BigNumber) => { + describe(description, () => { + let burnTx: ContractTransaction + + before(async () => { + await createSnapshot() + await token.connect(tokenHolder).approve(thirdParty.address, amount) + burnTx = await token + .connect(thirdParty) + .burnFrom(tokenHolder.address, amount) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should decrement totalSupply", async () => { + const expectedSupply = initialSupply.sub(amount) + expect(await token.totalSupply()).to.equal(expectedSupply) + }) + + it("should decrement account's balance", async () => { + const expectedBalance = initialBalance.sub(amount) + expect(await token.balanceOf(tokenHolder.address)).to.equal( + expectedBalance + ) + }) + + it("should decrement allowance", async () => { + const allowance = await token.allowance( + tokenHolder.address, + thirdParty.address + ) + + expect(allowance).to.equal(0) + }) + + it("should emit Transfer event", async () => { + await expect(burnTx) + .to.emit(token, "Transfer") + .withArgs(tokenHolder.address, ZERO_ADDRESS, amount) + }) + }) + } + + describeBurnFrom("for the entire balance", initialBalance) + describeBurnFrom("for less than the balance", initialBalance.sub(1)) + }) }) From 5d3e56b9ece0d4bbe49f9d9972b23960837185a9 Mon Sep 17 00:00:00 2001 From: Piotr Dyraga Date: Thu, 23 Feb 2023 14:27:32 +0100 Subject: [PATCH 5/9] L2TBTC token tests for ERC20 functionalities Only the functions defined in L2TBTC are fully covered with tests. L2TBTC contract inherits from OpenZeppelin contracts and we do not want to test the framework. The basic tests for functions defined in the OpenZeppelin contracts ensure all expected OpenZeppelin extensions are inherited in L2TBTC contract and that they are properly initialized. --- solidity/contracts/l2/L2TBTC.sol | 2 +- solidity/test/l2/L2TBTC.test.ts | 462 ++++++++++++++++++++++++------- 2 files changed, 356 insertions(+), 108 deletions(-) diff --git a/solidity/contracts/l2/L2TBTC.sol b/solidity/contracts/l2/L2TBTC.sol index b43e160c5..80914333e 100644 --- a/solidity/contracts/l2/L2TBTC.sol +++ b/solidity/contracts/l2/L2TBTC.sol @@ -23,7 +23,7 @@ import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; // * Be paused by any one of `n` guardians, allowing avoidance of contagion in case // of a chain- or bridge-specific incident. // * Proper documentation. -// * Proper tests, including initialization. +// * Misfund recovery. contract L2TBTC is ERC20Upgradeable, ERC20BurnableUpgradeable, diff --git a/solidity/test/l2/L2TBTC.test.ts b/solidity/test/l2/L2TBTC.test.ts index 408a10193..aeb7dc923 100644 --- a/solidity/test/l2/L2TBTC.test.ts +++ b/solidity/test/l2/L2TBTC.test.ts @@ -1,9 +1,8 @@ import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" -import { randomBytes, Sign } from "crypto" +import { randomBytes } from "crypto" import { ethers, getUnnamedAccounts, helpers, waffle } from "hardhat" import { expect } from "chai" -import { ContractTransaction } from "ethers" -import { BigNumber } from "@ethersproject/bignumber" +import { ContractTransaction, Wallet } from "ethers" import { to1e18 } from "../helpers/contract-test-helpers" import type { L2TBTC } from "../../typechain" @@ -12,6 +11,11 @@ const { createSnapshot, restoreSnapshot } = helpers.snapshot const ZERO_ADDRESS = ethers.constants.AddressZero +// Only the functions defined in L2TBTC are fully covered with tests. +// L2TBTC contract inherits from OpenZeppelin contracts and we do not want +// to test the framework. The basic tests for functions defined in the +// OpenZeppelin contracts ensure all expected OpenZeppelin extensions are +// inherited in L2TBTC contract and that they are properly initialized. describe("L2TBTC", () => { const fixture = async () => { const { deployer, governance } = await helpers.signers.getNamedSigners() @@ -28,7 +32,7 @@ describe("L2TBTC", () => { `L2TBTC_${randomBytes(8).toString("hex")}`, { contractName: "L2TBTC", - initializerArgs: ["ArbitrumTBTC", "ArbTBTC"], + initializerArgs: ["Arbitrum TBTC", "ArbTBTC"], factoryOpts: { signer: deployer }, proxyOpts: { kind: "transparent", @@ -48,6 +52,9 @@ describe("L2TBTC", () => { } } + // default Hardhat's networks blockchain, see https://hardhat.org/config/ + const hardhatNetworkId = 31337 + let token: L2TBTC let governance: SignerWithAddress @@ -323,153 +330,394 @@ describe("L2TBTC", () => { }) }) - describe("burn", () => { - const initialSupply = to1e18(36) - const initialBalance = to1e18(18) + // + // The tests below are just very basic tests for ERC20 functionality. + // L2TBTC contract inherits from OpenZeppelin contracts and we do not want + // to test the framework. The basic tests ensure all expected OpenZeppelin + // extensions are inherited in L2TBTC contract and that they are properly + // initialized. + // + + it("should have a name", async () => { + expect(await token.name()).to.equal("Arbitrum TBTC") + }) + it("should have a symbol", async () => { + expect(await token.symbol()).to.equal("ArbTBTC") + }) + + it("should have 18 decimals", async () => { + expect(await token.decimals()).to.equal(18) + }) + + describe("totalSupply", () => { before(async () => { await createSnapshot() await token.connect(governance).addMinter(minter.address) - await token.connect(minter).mint(tokenHolder.address, initialBalance) - await token.connect(minter).mint(thirdParty.address, initialBalance) + + await token.connect(minter).mint(thirdParty.address, to1e18(1)) + await token.connect(minter).mint(tokenHolder.address, to1e18(3)) }) after(async () => { await restoreSnapshot() }) - context("when burning more than balance", () => { - it("should revert", async () => { - await expect( - token.connect(tokenHolder).burn(initialBalance.add(1)) - ).to.be.revertedWith("ERC20: burn amount exceeds balance") - }) + it("should return the total amount of tokens", async () => { + expect(await token.totalSupply()).to.equal(to1e18(4)) // 1+3 }) + }) - const describeBurn = (description: string, amount: BigNumber) => { - describe(description, () => { - let burnTx: ContractTransaction + describe("DOMAIN_SEPARATOR", () => { + it("should be keccak256 of EIP712 domain struct", async () => { + const { keccak256 } = ethers.utils + const { defaultAbiCoder } = ethers.utils + const { toUtf8Bytes } = ethers.utils + + const expected = keccak256( + defaultAbiCoder.encode( + ["bytes32", "bytes32", "bytes32", "uint256", "address"], + [ + keccak256( + toUtf8Bytes( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ) + ), + keccak256(toUtf8Bytes("Arbitrum TBTC")), + keccak256(toUtf8Bytes("1")), + hardhatNetworkId, + token.address, + ] + ) + ) + expect(await token.DOMAIN_SEPARATOR()).to.equal(expected) + }) + }) - before(async () => { - await createSnapshot() - burnTx = await token.connect(tokenHolder).burn(amount) - }) + describe("balanceOf", () => { + const balance = to1e18(7) - after(async () => { - await restoreSnapshot() - }) + before(async () => { + await createSnapshot() + await token.connect(governance).addMinter(minter.address) + await token.connect(minter).mint(tokenHolder.address, balance) + }) - it("should decrement totalSupply", async () => { - const expectedSupply = initialSupply.sub(amount) - expect(await token.totalSupply()).to.equal(expectedSupply) - }) + after(async () => { + await restoreSnapshot() + }) - it("should decrement account's balance", async () => { - const expectedBalance = initialBalance.sub(amount) - expect(await token.balanceOf(tokenHolder.address)).to.equal( - expectedBalance - ) - }) + it("should return the total amount of tokens", async () => { + expect(await token.balanceOf(tokenHolder.address)).to.equal(balance) + }) + }) - it("should emit Transfer event", async () => { - await expect(burnTx) - .to.emit(token, "Transfer") - .withArgs(tokenHolder.address, ZERO_ADDRESS, amount) - }) - }) - } + describe("transfer", () => { + const initialHolderBalance = to1e18(70) + const transferAmount = to1e18(5) + let tx: ContractTransaction + + before(async () => { + await createSnapshot() + await token.connect(governance).addMinter(minter.address) + await token + .connect(minter) + .mint(tokenHolder.address, initialHolderBalance) - describeBurn("for the entire balance", initialBalance) - describeBurn("for less than the entire balance", initialBalance.sub(1)) + tx = await token + .connect(tokenHolder) + .transfer(thirdParty.address, transferAmount) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should transfer the requested amount", async () => { + expect(await token.balanceOf(tokenHolder.address)).to.equal( + initialHolderBalance.sub(transferAmount) + ) + + expect(await token.balanceOf(thirdParty.address)).to.equal(transferAmount) + }) + + it("should emit a transfer event", async () => { + await expect(tx) + .to.emit(token, "Transfer") + .withArgs(tokenHolder.address, thirdParty.address, transferAmount) + }) }) - describe("burnFrom", () => { - const initialSupply = to1e18(36) + describe("transferFrom", () => { + const initialHolderBalance = to1e18(70) + const transferAmount = to1e18(9) + let tx: ContractTransaction + + before(async () => { + await createSnapshot() + await token.connect(governance).addMinter(minter.address) + await token + .connect(minter) + .mint(tokenHolder.address, initialHolderBalance) + + await token + .connect(tokenHolder) + .approve(thirdParty.address, transferAmount) + tx = await token + .connect(thirdParty) + .transferFrom(tokenHolder.address, thirdParty.address, transferAmount) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should transfer the requested amount", async () => { + expect(await token.balanceOf(tokenHolder.address)).to.equal( + initialHolderBalance.sub(transferAmount) + ) + + expect(await token.balanceOf(thirdParty.address)).to.equal(transferAmount) + }) + + it("should emit a transfer event", async () => { + await expect(tx) + .to.emit(token, "Transfer") + .withArgs(tokenHolder.address, thirdParty.address, transferAmount) + }) + }) + + describe("approve", () => { + let tx: ContractTransaction + const allowance = to1e18(888) + + before(async () => { + await createSnapshot() + + tx = await token + .connect(tokenHolder) + .approve(thirdParty.address, allowance) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should approve the requested amount", async () => { + expect( + await token.allowance(tokenHolder.address, thirdParty.address) + ).to.equal(allowance) + }) + + it("should emit an approval event", async () => { + await expect(tx) + .to.emit(token, "Approval") + .withArgs(tokenHolder.address, thirdParty.address, allowance) + }) + }) + + describe("burn", () => { const initialBalance = to1e18(18) + const burnedAmount = to1e18(5) + + let burnTx: ContractTransaction before(async () => { await createSnapshot() await token.connect(governance).addMinter(minter.address) await token.connect(minter).mint(tokenHolder.address, initialBalance) - await token.connect(minter).mint(thirdParty.address, initialBalance) }) after(async () => { await restoreSnapshot() }) - context("when burning more than the balance", () => { - it("should revert", async () => { - await token - .connect(tokenHolder) - .approve(thirdParty.address, initialBalance.add(1)) - await expect( - token - .connect(thirdParty) - .burnFrom(tokenHolder.address, initialBalance.add(1)) - ).to.be.revertedWith("ERC20: burn amount exceeds balance") - }) + before(async () => { + await createSnapshot() + burnTx = await token.connect(tokenHolder).burn(burnedAmount) }) - context("when burning more than the allowance", () => { - it("should revert", async () => { - await token - .connect(tokenHolder) - .approve(thirdParty.address, initialBalance.sub(1)) - await expect( - token - .connect(thirdParty) - .burnFrom(tokenHolder.address, initialBalance) - ).to.be.revertedWith("ERC20: insufficient allowance") - }) + after(async () => { + await restoreSnapshot() }) - const describeBurnFrom = (description: string, amount: BigNumber) => { - describe(description, () => { - let burnTx: ContractTransaction + it("should decrement account's balance", async () => { + const expectedBalance = initialBalance.sub(burnedAmount) + expect(await token.balanceOf(tokenHolder.address)).to.equal( + expectedBalance + ) + }) - before(async () => { - await createSnapshot() - await token.connect(tokenHolder).approve(thirdParty.address, amount) - burnTx = await token - .connect(thirdParty) - .burnFrom(tokenHolder.address, amount) - }) + it("should emit Transfer event", async () => { + await expect(burnTx) + .to.emit(token, "Transfer") + .withArgs(tokenHolder.address, ZERO_ADDRESS, burnedAmount) + }) + }) - after(async () => { - await restoreSnapshot() - }) + describe("burnFrom", () => { + const initialBalance = to1e18(18) + const burnedAmount = to1e18(9) - it("should decrement totalSupply", async () => { - const expectedSupply = initialSupply.sub(amount) - expect(await token.totalSupply()).to.equal(expectedSupply) - }) + let burnTx: ContractTransaction - it("should decrement account's balance", async () => { - const expectedBalance = initialBalance.sub(amount) - expect(await token.balanceOf(tokenHolder.address)).to.equal( - expectedBalance - ) - }) + before(async () => { + await createSnapshot() + await token.connect(governance).addMinter(minter.address) + await token.connect(minter).mint(tokenHolder.address, initialBalance) + }) - it("should decrement allowance", async () => { - const allowance = await token.allowance( - tokenHolder.address, - thirdParty.address - ) + after(async () => { + await restoreSnapshot() + }) - expect(allowance).to.equal(0) - }) + before(async () => { + await createSnapshot() + await token.connect(tokenHolder).approve(thirdParty.address, burnedAmount) + burnTx = await token + .connect(thirdParty) + .burnFrom(tokenHolder.address, burnedAmount) + }) - it("should emit Transfer event", async () => { - await expect(burnTx) - .to.emit(token, "Transfer") - .withArgs(tokenHolder.address, ZERO_ADDRESS, amount) - }) - }) + after(async () => { + await restoreSnapshot() + }) + + it("should decrement account's balance", async () => { + const expectedBalance = initialBalance.sub(burnedAmount) + expect(await token.balanceOf(tokenHolder.address)).to.equal( + expectedBalance + ) + }) + + it("should decrement allowance", async () => { + const allowance = await token.allowance( + tokenHolder.address, + thirdParty.address + ) + + expect(allowance).to.equal(0) + }) + + it("should emit Transfer event", async () => { + await expect(burnTx) + .to.emit(token, "Transfer") + .withArgs(tokenHolder.address, ZERO_ADDRESS, burnedAmount) + }) + }) + + describe("permit", () => { + const initialHolderBalance = to1e18(70) + let permittingHolder: Wallet + + let deadline: number + + let tx: ContractTransaction + + const getApproval = async (amount, spender) => { + // We use ethers.utils.SigningKey for a Wallet instead of + // Signer.signMessage to do not add '\x19Ethereum Signed Message:\n' + // prefix to the signed message. The '\x19` protection (see EIP191 for + // more details on '\x19' rationale and format) is already included in + // EIP2612 permit signed message and '\x19Ethereum Signed Message:\n' + // should not be used there. + const signingKey = new ethers.utils.SigningKey( + permittingHolder.privateKey + ) + + const domainSeparator = await token.DOMAIN_SEPARATOR() + const permitTypehash = ethers.utils.keccak256( + ethers.utils.toUtf8Bytes( + "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" + ) + ) + const nonce = await token.nonces(permittingHolder.address) + + const approvalDigest = ethers.utils.keccak256( + ethers.utils.solidityPack( + ["bytes1", "bytes1", "bytes32", "bytes32"], + [ + "0x19", + "0x01", + domainSeparator, + ethers.utils.keccak256( + ethers.utils.defaultAbiCoder.encode( + [ + "bytes32", + "address", + "address", + "uint256", + "uint256", + "uint256", + ], + [ + permitTypehash, + permittingHolder.address, + spender, + amount, + nonce, + deadline, + ] + ) + ), + ] + ) + ) + + return ethers.utils.splitSignature( + await signingKey.signDigest(approvalDigest) + ) } - describeBurnFrom("for the entire balance", initialBalance) - describeBurnFrom("for less than the balance", initialBalance.sub(1)) + before(async () => { + await createSnapshot() + + permittingHolder = await ethers.Wallet.createRandom() + + await token.connect(governance).addMinter(minter.address) + await token + .connect(minter) + .mint(permittingHolder.address, initialHolderBalance) + + const lastBlockTimestamp = await helpers.time.lastBlockTime() + deadline = lastBlockTimestamp + 86400 // +1 day + + const signature = await getApproval( + initialHolderBalance, + thirdParty.address + ) + + tx = await token + .connect(thirdParty) + .permit( + permittingHolder.address, + thirdParty.address, + initialHolderBalance, + deadline, + signature.v, + signature.r, + signature.s + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should emit an approval event", async () => { + await expect(tx) + .to.emit(token, "Approval") + .withArgs( + permittingHolder.address, + thirdParty.address, + initialHolderBalance + ) + }) + + it("should approve the requested amount", async () => { + expect( + await token.allowance(permittingHolder.address, thirdParty.address) + ).to.equal(initialHolderBalance) + }) }) }) From 6afb40c8a4871774c5db9dd57132ce003bcffcc7 Mon Sep 17 00:00:00 2001 From: Piotr Dyraga Date: Thu, 23 Feb 2023 16:51:30 +0100 Subject: [PATCH 6/9] L2TBTC pause and unpause functionality Allow one of the guardians to pause mints and burns allowing avoidance of contagion in case of a chain- or bridge-specific incident. --- solidity/contracts/l2/L2TBTC.sol | 122 ++++++++++- solidity/test/l2/L2TBTC.test.ts | 347 ++++++++++++++++++++++++++++++- 2 files changed, 455 insertions(+), 14 deletions(-) diff --git a/solidity/contracts/l2/L2TBTC.sol b/solidity/contracts/l2/L2TBTC.sol index 80914333e..00adce7c5 100644 --- a/solidity/contracts/l2/L2TBTC.sol +++ b/solidity/contracts/l2/L2TBTC.sol @@ -18,33 +18,48 @@ pragma solidity ^0.8.17; import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/draft-ERC20PermitUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; // TODO: -// * Be paused by any one of `n` guardians, allowing avoidance of contagion in case -// of a chain- or bridge-specific incident. // * Proper documentation. // * Misfund recovery. contract L2TBTC is ERC20Upgradeable, ERC20BurnableUpgradeable, ERC20PermitUpgradeable, - OwnableUpgradeable + OwnableUpgradeable, + PausableUpgradeable { - /// @notice Indicates if the given address is a Minter. Only Minters can + /// @notice Indicates if the given address is a minter. Only minters can /// mint the token. mapping(address => bool) public isMinter; - /// @notice List of all Minters. + /// @notice List of all minters. address[] public minters; + /// @notice Indicates if the given address is a guardian. Only guardians can + /// pause token mints and burns. + mapping(address => bool) public isGuardian; + + /// @notice List of all guardians. + address[] public guardians; + event MinterAdded(address indexed minter); event MinterRemoved(address indexed minter); + event GuardianAdded(address indexed guardian); + event GuardianRemoved(address indexed guardian); + modifier onlyMinter() { require(isMinter[msg.sender], "Caller is not a minter"); _; } + modifier onlyGuardian() { + require(isGuardian[msg.sender], "Caller is not a guardian"); + _; + } + function initialize(string memory _name, string memory _symbol) external initializer @@ -67,12 +82,13 @@ contract L2TBTC is __ERC20Burnable_init(); __ERC20Permit_init(_name); __Ownable_init(); + __Pausable_init(); } - /// @notice Adds the address to the Minter list. + /// @notice Adds the address to the minters list. /// @dev Requirements: /// - The caller must be the contract owner. - /// - `minter` must not be a minter address. + /// - `minter` must not be a minter address already. function addMinter(address minter) external onlyOwner { require(!isMinter[minter], "This address is already a minter"); isMinter[minter] = true; @@ -80,7 +96,7 @@ contract L2TBTC is emit MinterAdded(minter); } - /// @notice Removes the address from the Minter list. + /// @notice Removes the address from the minters list. /// @dev Requirements: /// - The caller must be the contract owner. /// - `minter` must be a minter address. @@ -88,7 +104,7 @@ contract L2TBTC is require(isMinter[minter], "This address is not a minter"); delete isMinter[minter]; - // We do not expect too many Minters so a simple loop is safe. + // We do not expect too many minters so a simple loop is safe. for (uint256 i = 0; i < minters.length; i++) { if (minters[i] == minter) { minters[i] = minters[minters.length - 1]; @@ -101,18 +117,102 @@ contract L2TBTC is emit MinterRemoved(minter); } + /// @notice Adds the address to the guardians list. + /// @dev Requirements: + /// - The caller must be the contract owner. + /// - `guardian` must not be a guardian address already. + function addGuardian(address guardian) external onlyOwner { + require(!isGuardian[guardian], "This address is already a guardian"); + isGuardian[guardian] = true; + guardians.push(guardian); + emit GuardianAdded(guardian); + } + + /// @notice Removes the address from the guardians list. + /// @dev Requirements: + /// - The caller must be the contract owner. + /// - `guardian` must be a guardian address. + function removeGuardian(address guardian) external onlyOwner { + require(isGuardian[guardian], "This address is not a guardian"); + delete isGuardian[guardian]; + + // We do not expect too many guardians so a simple loop is safe. + for (uint256 i = 0; i < guardians.length; i++) { + if (guardians[i] == guardian) { + guardians[i] = guardians[guardians.length - 1]; + // slither-disable-next-line costly-loop + guardians.pop(); + break; + } + } + + emit GuardianRemoved(guardian); + } + + /// @notice Allows one of the guardians to pause mints and burns allowing + /// avoidance of contagion in case of a chain- or bridge-specific + /// incident. + /// @dev Requirements: + /// - The caller must be a guardian. + /// - The contract must not be already paused. + function pause() external onlyGuardian { + _pause(); + } + + /// @notice Allows the governance to unpause mints and burns previously + /// paused by one of the guardians. + /// @dev Requirements: + /// - The caller must be the contract owner. + /// - The contract must be paused. + function unpause() external onlyOwner { + _unpause(); + } + /// @notice Allows one of the minters to mint `amount` tokens and assign /// them to `account`, increasing the total supply. Emits /// a `Transfer` event with `from` set to the zero address. /// @dev Requirements: /// - The caller must be a minter. /// - `account` must not be the zero address. - function mint(address account, uint256 amount) external onlyMinter { + function mint(address account, uint256 amount) + external + whenNotPaused + onlyMinter + { _mint(account, amount); } - /// @notice Allows to fetch a list of all Minters. + /// @notice Destroys `amount` tokens from the caller. Emits a `Transfer` + /// event with `to` set to the zero address. + /// @dev Requirements: + /// - The caller must have at least `amount` tokens. + function burn(uint256 amount) public override whenNotPaused { + super.burn(amount); + } + + /// @notice Destroys `amount` tokens from `account`, deducting from the + /// caller's allowance. Emits a `Transfer` event with `to` set to + /// the zero address. + /// @dev Requirements: + /// - The che caller must have allowance for `accounts`'s tokens of at + /// least `amount`. + /// - `account` must not be the zero address. + /// - `account` must have at least `amount` tokens. + function burnFrom(address account, uint256 amount) + public + override + whenNotPaused + { + super.burnFrom(account, amount); + } + + /// @notice Allows to fetch a list of all minters. function getMinters() external view returns (address[] memory) { return minters; } + + /// @notice Allows to fetch a list of all guardians. + function getGuardians() external view returns (address[] memory) { + return guardians; + } } diff --git a/solidity/test/l2/L2TBTC.test.ts b/solidity/test/l2/L2TBTC.test.ts index aeb7dc923..64f8173f0 100644 --- a/solidity/test/l2/L2TBTC.test.ts +++ b/solidity/test/l2/L2TBTC.test.ts @@ -22,8 +22,9 @@ describe("L2TBTC", () => { const accounts = await getUnnamedAccounts() const minter = await ethers.getSigner(accounts[1]) - const thirdParty = await ethers.getSigner(accounts[2]) - const tokenHolder = await ethers.getSigner(accounts[3]) + const guardian = await ethers.getSigner(accounts[2]) + const thirdParty = await ethers.getSigner(accounts[3]) + const tokenHolder = await ethers.getSigner(accounts[4]) const deployment = await helpers.upgrades.deployProxy( // Hacky workaround allowing to deploy proxy contract any number of times @@ -46,6 +47,7 @@ describe("L2TBTC", () => { return { governance, minter, + guardian, thirdParty, tokenHolder, token, @@ -59,12 +61,13 @@ describe("L2TBTC", () => { let governance: SignerWithAddress let minter: SignerWithAddress + let guardian: SignerWithAddress let thirdParty: SignerWithAddress let tokenHolder: SignerWithAddress before(async () => { // eslint-disable-next-line @typescript-eslint/no-extra-semi - ;({ governance, minter, thirdParty, tokenHolder, token } = + ;({ governance, minter, guardian, thirdParty, tokenHolder, token } = await waffle.loadFixture(fixture)) }) @@ -272,6 +275,344 @@ describe("L2TBTC", () => { }) }) + describe("addGuardian", () => { + context("when called not by the owner", () => { + it("should revert", async () => { + await expect( + token.connect(thirdParty).addGuardian(thirdParty.address) + ).to.be.revertedWith("Ownable: caller is not the owner") + }) + }) + + context("when called by the owner", () => { + context("when address is not a guardian", () => { + let tx: ContractTransaction + + before(async () => { + await createSnapshot() + + tx = await token.connect(governance).addGuardian(guardian.address) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should add address as a guardian", async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(await token.isGuardian(guardian.address)).to.be.true + }) + + it("should emit an event", async () => { + await expect(tx) + .to.emit(token, "GuardianAdded") + .withArgs(guardian.address) + }) + }) + + context("when address is a guardian", () => { + before(async () => { + await createSnapshot() + + await token.connect(governance).addGuardian(guardian.address) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should revert", async () => { + await expect( + token.connect(governance).addGuardian(guardian.address) + ).to.be.revertedWith("This address is already a guardian") + }) + }) + + context("when there are multiple guardians", () => { + const guardians = [ + "0x54DeA8194aaF652Cd296B162A2809dd95529f775", + "0x575E6d8802e7b6A7E8F940640804385D8Bbe2ce0", + "0x66ac131D339704902aECCaBDf55e15daAE8B238f", + ] + + before(async () => { + await createSnapshot() + + await token.connect(governance).addGuardian(guardians[0]) + await token.connect(governance).addGuardian(guardians[1]) + await token.connect(governance).addGuardian(guardians[2]) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should add them into the list", async () => { + expect(await token.getGuardians()).to.deep.equal(guardians) + }) + }) + }) + }) + + describe("removeGuardian", () => { + context("when called not by the owner", () => { + it("should revert", async () => { + await expect( + token.connect(thirdParty).removeGuardian(guardian.address) + ).to.be.revertedWith("Ownable: caller is not the owner") + }) + }) + + context("when called by the owner", () => { + context("when address is not a guardian", () => { + it("should revert", async () => { + await expect( + token.connect(governance).removeGuardian(thirdParty.address) + ).to.be.revertedWith("This address is not a guardian") + }) + }) + + context("when address is a guardian", () => { + let tx: ContractTransaction + + before(async () => { + await createSnapshot() + + await token.connect(governance).addGuardian(guardian.address) + tx = await token.connect(governance).removeGuardian(guardian.address) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should take guardian role from the address", async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(await token.isGuardian(guardian.address)).to.be.false + }) + + it("should emit an event", async () => { + await expect(tx) + .to.emit(token, "GuardianRemoved") + .withArgs(guardian.address) + }) + }) + + context("when there are multiple guardians", () => { + const guardians = [ + "0x54DeA8194aaF652Cd296B162A2809dd95529f775", + "0x575E6d8802e7b6A7E8F940640804385D8Bbe2ce0", + "0x66ac131D339704902aECCaBDf55e15daAE8B238f", + "0xF844A3a4dA34fDDf51A0Ec7A0a89d1ed5A105e40", + ] + + before(async () => { + await createSnapshot() + + await token.connect(governance).addGuardian(guardians[0]) + await token.connect(governance).addGuardian(guardians[1]) + await token.connect(governance).addGuardian(guardians[2]) + await token.connect(governance).addGuardian(guardians[3]) + }) + + after(async () => { + await restoreSnapshot() + }) + + context("when deleting the first guardian", () => { + before(async () => { + await createSnapshot() + await token.connect(governance).removeGuardian(guardians[0]) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should update the guardians list", async () => { + expect(await token.getGuardians()).to.deep.equal([ + "0xF844A3a4dA34fDDf51A0Ec7A0a89d1ed5A105e40", + "0x575E6d8802e7b6A7E8F940640804385D8Bbe2ce0", + "0x66ac131D339704902aECCaBDf55e15daAE8B238f", + ]) + }) + }) + + context("when deleting the last guardian", () => { + before(async () => { + await createSnapshot() + await token.connect(governance).removeGuardian(guardians[3]) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should update the guardians list", async () => { + expect(await token.getGuardians()).to.deep.equal([ + "0x54DeA8194aaF652Cd296B162A2809dd95529f775", + "0x575E6d8802e7b6A7E8F940640804385D8Bbe2ce0", + "0x66ac131D339704902aECCaBDf55e15daAE8B238f", + ]) + }) + }) + + context("when deleting guardian from the middle of the list", () => { + before(async () => { + await createSnapshot() + await token.connect(governance).removeGuardian(guardians[1]) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should update the guardians list", async () => { + expect(await token.getGuardians()).to.deep.equal([ + "0x54DeA8194aaF652Cd296B162A2809dd95529f775", + "0xF844A3a4dA34fDDf51A0Ec7A0a89d1ed5A105e40", + "0x66ac131D339704902aECCaBDf55e15daAE8B238f", + ]) + }) + }) + }) + }) + }) + + describe("pause", () => { + before(async () => { + await createSnapshot() + await token.connect(governance).addMinter(minter.address) + await token.connect(governance).addGuardian(guardian.address) + + await token.connect(minter).mint(tokenHolder.address, to1e18(10)) + }) + + after(async () => { + await restoreSnapshot() + }) + + context("when called not by a guardian", () => { + it("should revert", async () => { + await expect(token.connect(thirdParty).pause()).to.be.revertedWith( + "Caller is not a guardian" + ) + await expect(token.connect(minter).pause()).to.be.revertedWith( + "Caller is not a guardian" + ) + await expect(token.connect(governance).pause()).to.be.revertedWith( + "Caller is not a guardian" + ) + }) + }) + + context("when called by a guardian", () => { + let tx: ContractTransaction + + before(async () => { + await createSnapshot() + + tx = await token.connect(guardian).pause() + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should emit Paused event", async () => { + await expect(tx).to.emit(token, "Paused").withArgs(guardian.address) + }) + + it("should pause mint functionality", async () => { + await expect( + token.connect(minter).mint(thirdParty.address, to1e18(1)) + ).to.be.revertedWith("Pausable: paused") + }) + + it("should pause burn functionality", async () => { + await expect( + token.connect(tokenHolder).burn(to1e18(1)) + ).to.be.revertedWith("Pausable: paused") + }) + + it("should pause burnFrom functionality", async () => { + await expect( + token.connect(thirdParty).burnFrom(tokenHolder.address, to1e18(1)) + ).to.be.revertedWith("Pausable: paused") + }) + + it("should not pause transfers", async () => { + await expect( + token.connect(tokenHolder).transfer(thirdParty.address, to1e18(1)) + ).to.not.be.reverted + }) + }) + }) + + describe("unpause", () => { + before(async () => { + await createSnapshot() + await token.connect(governance).addMinter(minter.address) + await token.connect(governance).addGuardian(guardian.address) + + await token.connect(minter).mint(tokenHolder.address, to1e18(10)) + await token.connect(tokenHolder).approve(thirdParty.address, to1e18(10)) + + await token.connect(guardian).pause() + }) + + after(async () => { + await restoreSnapshot() + }) + + context("when called not by the owner", () => { + it("should revert", async () => { + await expect(token.connect(thirdParty).unpause()).to.be.revertedWith( + "Ownable: caller is not the owner" + ) + await expect(token.connect(minter).unpause()).to.be.revertedWith( + "Ownable: caller is not the owner" + ) + await expect(token.connect(guardian).unpause()).to.be.revertedWith( + "Ownable: caller is not the owner" + ) + }) + }) + + context("when called by the owner", () => { + let tx: ContractTransaction + + before(async () => { + await createSnapshot() + tx = await token.connect(governance).unpause() + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should emit Unpaused event", async () => { + await expect(tx).to.emit(token, "Unpaused").withArgs(governance.address) + }) + + it("should unpause mint functionality", async () => { + await expect(token.connect(minter).mint(thirdParty.address, to1e18(1))) + .to.not.be.reverted + }) + + it("should unpause burn functionality", async () => { + await expect(token.connect(tokenHolder).burn(to1e18(1))).to.not.be + .reverted + }) + + it("should unpause burnFrom functionality", async () => { + await expect( + token.connect(thirdParty).burnFrom(tokenHolder.address, to1e18(1)) + ).to.not.be.reverted + }) + }) + }) + describe("mint", () => { const amount = to1e18(50) From c710eecaa7b2131374258801c3fa2e6c0b6fc118 Mon Sep 17 00:00:00 2001 From: Piotr Dyraga Date: Thu, 23 Feb 2023 17:15:57 +0100 Subject: [PATCH 7/9] Recover ERC20 and ERC721 functionality for L2TBTC Added functions allowing the governance to recover any ERC20 or ERC721 sent mistakenly to the contract address. --- solidity/contracts/l2/L2TBTC.sol | 30 ++++++++- solidity/test/l2/L2TBTC.test.ts | 103 ++++++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/solidity/contracts/l2/L2TBTC.sol b/solidity/contracts/l2/L2TBTC.sol index 00adce7c5..9b313d73d 100644 --- a/solidity/contracts/l2/L2TBTC.sol +++ b/solidity/contracts/l2/L2TBTC.sol @@ -17,12 +17,13 @@ pragma solidity ^0.8.17; import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/draft-ERC20PermitUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC721/IERC721Upgradeable.sol"; import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; -// TODO: -// * Proper documentation. -// * Misfund recovery. +// TODO: Proper documentation (contract, fields, @params) contract L2TBTC is ERC20Upgradeable, ERC20BurnableUpgradeable, @@ -30,6 +31,8 @@ contract L2TBTC is OwnableUpgradeable, PausableUpgradeable { + using SafeERC20Upgradeable for IERC20Upgradeable; + /// @notice Indicates if the given address is a minter. Only minters can /// mint the token. mapping(address => bool) public isMinter; @@ -149,6 +152,27 @@ contract L2TBTC is emit GuardianRemoved(guardian); } + /// @notice Allows the governance of the token contract to recover any ERC20 + /// sent mistakenly to the token contract address. + function recoverERC20( + IERC20Upgradeable token, + address recipient, + uint256 amount + ) external onlyOwner { + token.safeTransfer(recipient, amount); + } + + /// @notice Allows the governance of the token contract to recover any + /// ERC721 sent mistakenly to the token contract address. + function recoverERC721( + IERC721Upgradeable token, + address recipient, + uint256 tokenId, + bytes calldata data + ) external onlyOwner { + token.safeTransferFrom(address(this), recipient, tokenId, data); + } + /// @notice Allows one of the guardians to pause mints and burns allowing /// avoidance of contagion in case of a chain- or bridge-specific /// incident. diff --git a/solidity/test/l2/L2TBTC.test.ts b/solidity/test/l2/L2TBTC.test.ts index 64f8173f0..e7e7cabf6 100644 --- a/solidity/test/l2/L2TBTC.test.ts +++ b/solidity/test/l2/L2TBTC.test.ts @@ -5,7 +5,7 @@ import { expect } from "chai" import { ContractTransaction, Wallet } from "ethers" import { to1e18 } from "../helpers/contract-test-helpers" -import type { L2TBTC } from "../../typechain" +import type { L2TBTC, TestERC20, TestERC721 } from "../../typechain" const { createSnapshot, restoreSnapshot } = helpers.snapshot @@ -479,6 +479,107 @@ describe("L2TBTC", () => { }) }) + describe("recoverERC20", () => { + const amount = to1e18(725) + + let randomERC20: TestERC20 + + before(async () => { + await createSnapshot() + + const TestERC20 = await ethers.getContractFactory("TestERC20") + randomERC20 = await TestERC20.deploy() + await randomERC20.deployed() + + await randomERC20.mint(token.address, amount) + }) + + after(async () => { + await restoreSnapshot() + }) + + context("when called not by the owner", () => { + it("should revert", async () => { + await expect( + token + .connect(thirdParty) + .recoverERC20(randomERC20.address, thirdParty.address, amount) + ).to.be.revertedWith("Ownable: caller is not the owner") + }) + }) + + context("when called by the contract owner", () => { + before(async () => { + await createSnapshot() + + await token + .connect(governance) + .recoverERC20(randomERC20.address, thirdParty.address, amount) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should transfer tokens to the recipient", async () => { + expect(await randomERC20.balanceOf(thirdParty.address)).to.equal(amount) + }) + }) + }) + + describe("recoverERC721", () => { + const tokenId = 19112 + + let randomERC721: TestERC721 + + before(async () => { + await createSnapshot() + + const TestERC721 = await ethers.getContractFactory("TestERC721") + randomERC721 = await TestERC721.deploy() + await randomERC721.deployed() + + await randomERC721.mint(token.address, tokenId) + }) + + after(async () => { + await restoreSnapshot() + }) + + context("when called not by the owner", () => { + it("should revert", async () => { + await expect( + token + .connect(thirdParty) + .recoverERC721( + randomERC721.address, + thirdParty.address, + tokenId, + [] + ) + ).to.be.revertedWith("Ownable: caller is not the owner") + }) + }) + + context("when called by the owner", () => { + before(async () => { + await createSnapshot() + + await token + .connect(governance) + .recoverERC721(randomERC721.address, thirdParty.address, tokenId, []) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("transfers token to the recipient", async () => { + expect(await randomERC721.ownerOf(tokenId)).to.equal(thirdParty.address) + }) + }) + }) + describe("pause", () => { before(async () => { await createSnapshot() From c12c04254df543c12e60f6ec0f0c43cc4f8e77db Mon Sep 17 00:00:00 2001 From: Piotr Dyraga Date: Fri, 24 Feb 2023 09:48:37 +0100 Subject: [PATCH 8/9] Added a proper L2TBTC contract documentation --- solidity/contracts/l2/L2TBTC.sol | 46 +++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/solidity/contracts/l2/L2TBTC.sol b/solidity/contracts/l2/L2TBTC.sol index 9b313d73d..6f8c054e6 100644 --- a/solidity/contracts/l2/L2TBTC.sol +++ b/solidity/contracts/l2/L2TBTC.sol @@ -23,7 +23,30 @@ import "@openzeppelin/contracts-upgradeable/token/ERC721/IERC721Upgradeable.sol" import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; -// TODO: Proper documentation (contract, fields, @params) +/// @title L2TBTC +/// @notice Canonical L2/sidechain token implementation. tBTC token is minted on +/// L1 and locked there to be moved to L2/sidechain. By deploying +/// a canonical token on each L2/sidechain, we can ensure the supply of +/// tBTC remains sacrosanct, while enabling quick, interoperable +/// cross-chain bridges and localizing ecosystem risk. +/// +/// This contract is flexible enough to: +/// - Delegate minting authority to a native bridge on the chain, if +/// present. +/// - Delegate minting authority to a short list of ecosystem bridges. +/// - Have mints and burns paused by any one of n guardians, allowing +/// avoidance of contagion in case of a chain- or bridge-specific +/// incident. +/// - Be governed and upgradeable. +/// +/// The token is burnable by the token holder and supports EIP2612 +/// permits. Token holder can authorize a transfer of their token with +/// a signature conforming EIP712 standard instead of an on-chain +/// transaction from their address. Anyone can submit this signature on +/// the user's behalf by calling the permit function, paying gas fees, +/// and possibly performing other actions in the same transaction. +/// The governance can recover ERC20 and ERC721 tokens sent mistakenly +/// to L2TBTC token contract. contract L2TBTC is ERC20Upgradeable, ERC20BurnableUpgradeable, @@ -63,6 +86,10 @@ contract L2TBTC is _; } + /// @notice Initializes the token contract. + /// @param _name The name of the token. + /// @param _symbol The symbol of the token, usually a shorter version of the + /// name. function initialize(string memory _name, string memory _symbol) external initializer @@ -92,6 +119,7 @@ contract L2TBTC is /// @dev Requirements: /// - The caller must be the contract owner. /// - `minter` must not be a minter address already. + /// @param minter The address to be added as a minter. function addMinter(address minter) external onlyOwner { require(!isMinter[minter], "This address is already a minter"); isMinter[minter] = true; @@ -103,6 +131,7 @@ contract L2TBTC is /// @dev Requirements: /// - The caller must be the contract owner. /// - `minter` must be a minter address. + /// @param minter The address to be removed from the minters list. function removeMinter(address minter) external onlyOwner { require(isMinter[minter], "This address is not a minter"); delete isMinter[minter]; @@ -124,6 +153,7 @@ contract L2TBTC is /// @dev Requirements: /// - The caller must be the contract owner. /// - `guardian` must not be a guardian address already. + /// @param guardian The address to be added as a guardian. function addGuardian(address guardian) external onlyOwner { require(!isGuardian[guardian], "This address is already a guardian"); isGuardian[guardian] = true; @@ -135,6 +165,7 @@ contract L2TBTC is /// @dev Requirements: /// - The caller must be the contract owner. /// - `guardian` must be a guardian address. + /// @param guardian The address to be removed from the guardians list. function removeGuardian(address guardian) external onlyOwner { require(isGuardian[guardian], "This address is not a guardian"); delete isGuardian[guardian]; @@ -154,6 +185,10 @@ contract L2TBTC is /// @notice Allows the governance of the token contract to recover any ERC20 /// sent mistakenly to the token contract address. + /// @param token The address of the token to be recovered. + /// @param recipient The token recipient address that will receive recovered + /// tokens. + /// @param amount The amount to be recovered. function recoverERC20( IERC20Upgradeable token, address recipient, @@ -164,6 +199,10 @@ contract L2TBTC is /// @notice Allows the governance of the token contract to recover any /// ERC721 sent mistakenly to the token contract address. + /// @param token The address of the token to be recovered. + /// @param recipient The token recipient address that will receive the + /// recovered token. + /// @param tokenId The ID of the ERC721 token to be recovered. function recoverERC721( IERC721Upgradeable token, address recipient, @@ -198,6 +237,8 @@ contract L2TBTC is /// @dev Requirements: /// - The caller must be a minter. /// - `account` must not be the zero address. + /// @param account The address to receive tokens. + /// @param amount The amount of token to be minted. function mint(address account, uint256 amount) external whenNotPaused @@ -210,6 +251,7 @@ contract L2TBTC is /// event with `to` set to the zero address. /// @dev Requirements: /// - The caller must have at least `amount` tokens. + /// @param amount The amount of token to be burned. function burn(uint256 amount) public override whenNotPaused { super.burn(amount); } @@ -222,6 +264,8 @@ contract L2TBTC is /// least `amount`. /// - `account` must not be the zero address. /// - `account` must have at least `amount` tokens. + /// @param account The address owning tokens to be burned. + /// @param amount The amount of token to be burned. function burnFrom(address account, uint256 amount) public override From 9160bd6eef6943e65c675e94ad68277697cdcbe0 Mon Sep 17 00:00:00 2001 From: Piotr Dyraga Date: Mon, 27 Feb 2023 12:34:51 +0100 Subject: [PATCH 9/9] Set of small improvements to L2TBTC unit tests Renames of some test scenarios plus additional assertions for empty guardian and minter lists. --- solidity/test/l2/L2TBTC.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/solidity/test/l2/L2TBTC.test.ts b/solidity/test/l2/L2TBTC.test.ts index e7e7cabf6..1d13f98fb 100644 --- a/solidity/test/l2/L2TBTC.test.ts +++ b/solidity/test/l2/L2TBTC.test.ts @@ -81,7 +81,7 @@ describe("L2TBTC", () => { }) context("when called by the owner", () => { - context("when address is not a minter", () => { + context("when address is a new minter", () => { let tx: ContractTransaction before(async () => { @@ -106,7 +106,7 @@ describe("L2TBTC", () => { }) }) - context("when address is a minter", () => { + context("when address is already a minter", () => { before(async () => { await createSnapshot() @@ -168,7 +168,7 @@ describe("L2TBTC", () => { }) }) - context("when address is a minter", () => { + context("when a minter address is removed", () => { let tx: ContractTransaction before(async () => { @@ -185,6 +185,8 @@ describe("L2TBTC", () => { it("should take minter role from the address", async () => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions expect(await token.isMinter(minter.address)).to.be.false + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(await token.getMinters()).is.empty }) it("should emit an event", async () => { @@ -285,7 +287,7 @@ describe("L2TBTC", () => { }) context("when called by the owner", () => { - context("when address is not a guardian", () => { + context("when address is a new guardian", () => { let tx: ContractTransaction before(async () => { @@ -310,7 +312,7 @@ describe("L2TBTC", () => { }) }) - context("when address is a guardian", () => { + context("when address is already a guardian", () => { before(async () => { await createSnapshot() @@ -372,7 +374,7 @@ describe("L2TBTC", () => { }) }) - context("when address is a guardian", () => { + context("when a guardian address is removed", () => { let tx: ContractTransaction before(async () => { @@ -389,6 +391,8 @@ describe("L2TBTC", () => { it("should take guardian role from the address", async () => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions expect(await token.isGuardian(guardian.address)).to.be.false + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(await token.getGuardians()).to.be.empty }) it("should emit an event", async () => {