diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f06448d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,148 @@ +name: L2 contracts ci + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +env: + FOUNDRY_PROFILE: ci + +jobs: + build: + defaults: + run: + working-directory: ./l2-contracts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Install the dependencies + run: npm install + + - name: Build contracts + run: npm run compile + + test: + defaults: + run: + working-directory: ./l2-contracts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install Foundry + uses: dutterbutter/foundry-zksync-toolchain@v1 + + - name: Install the dependencies + run: npm install + + - name: Run tests + run: forge test --no-match-path .integration.t.sol --zksync + + - name: Run Era test node + uses: dutterbutter/era-test-node-action@v0.1.3 + + - name: Run tests + run: npx hardhat test + + ## coverage: + ## defaults: + ## run: + ## working-directory: ./l2-contracts + ## runs-on: ubuntu-latest + ## env: + ## SKIP_SAFETY_CHECK_IN_UPGRADE_TEST: true + ## steps: + ## - uses: actions/checkout@v3 + + ## - name: Install Foundry + ## uses: dutterbutter/foundry-zksync-toolchain@v1 + + ## - name: Run coverage + ## run: FOUNDRY_PROFILE=default npm run foundry-test && FOUNDRY_PROFILE=default forge coverage --report summary --report lcov --no-match-path .integration.t.sol --zksync + + ## # To ignore coverage for certain directories modify the paths in this step as needed. The + ## # below default ignores coverage results for the test and script directories. Alternatively, + ## # to include coverage in all directories, comment out this step. Note that because this + ## # filtering applies to the lcov file, the summary table generated in the previous step will + ## # still include all files and directories. + ## # The `--rc lcov_branch_coverage=1` part keeps branch info in the filtered report, since lcov + ## # defaults to removing branch info. + ## - name: Filter directories + ## run: | + ## sudo apt update && sudo apt install -y lcov + ## lcov --remove lcov.info 'test/*' 'script/*' 'src/lib/*' --output-file lcov.info --rc lcov_branch_coverage=1 + + ## # This step posts a detailed coverage report as a comment and deletes previous comments on + ## # each push. The below step is used to fail coverage if the specified coverage threshold is + ## # not met. The below step can post a comment (when it's `github-token` is specified) but it's + ## # not as useful, and this action cannot fail CI based on a minimum coverage threshold, which + ## # is why we use both in this way. + ## - name: Post coverage report + ## if: github.event_name == 'pull_request' # This action fails when ran outside of a pull request. + ## uses: romeovs/lcov-reporter-action@v0.3.1 + ## with: + ## delete-old-comments: true + ## lcov-file: ./lcov.info + ## github-token: ${{ secrets.GITHUB_TOKEN }} # Adds a coverage summary comment to the PR. + + ## - name: Verify minimum coverage + ## uses: zgosalvez/github-actions-report-lcov@v2 + ## with: + ## coverage-files: ./lcov.info + ## minimum-coverage: 83 # Set coverage threshold. + + lint: + defaults: + run: + shell: bash + working-directory: ./l2-contracts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Install scopelint + uses: engineerd/configurator@v0.0.8 + with: + name: scopelint + repo: ScopeLift/scopelint + fromGitHubReleases: true + version: latest + pathInArchive: scopelint-x86_64-linux/scopelint + urlTemplate: https://github.com/ScopeLift/scopelint/releases/download/{{version}}/scopelint-x86_64-linux.tar.xz + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Check formatting + run: | + scopelint --version + scopelint check + # DISABLED WHILE REPO IS PRIVATE + # slither-analyze: + # runs-on: ubuntu-latest + # permissions: + # contents: read + # security-events: write + # steps: + # - uses: actions/checkout@v3 + + # - name: Run Slither + # uses: crytic/slither-action@v0.3.0 + # id: slither # Required to reference this step in the next step. + # with: + # fail-on: none # Required to avoid failing the CI run regardless of findings. + # sarif: results.sarif + # slither-args: --filter-paths "./lib|./test" --exclude naming-convention,solc-version + + # - name: Upload SARIF file + # uses: github/codeql-action/upload-sarif@v2 + # with: + # sarif_file: ${{ steps.slither.outputs.sarif }} diff --git a/l2-contracts/foundry.toml b/l2-contracts/foundry.toml index 409177e..99eb898 100644 --- a/l2-contracts/foundry.toml +++ b/l2-contracts/foundry.toml @@ -1,5 +1,6 @@ [profile.default] evm_version = "paris" + fs_permissions = [{ access = "read", path = "./zkout" }] fuzz = { runs = 50 } optimizer = true optimizer_runs = 10_000_000 @@ -11,11 +12,10 @@ ] solc_version = "0.8.24" verbosity = 3 - fs_permissions = [{ access = "read", path = "./zkout" }] [profile.ci] - fuzz = { runs = 5000 } - invariant = { runs = 1000 } + fuzz = { runs = 1000 } + invariant = { runs = 500 } [profile.lite] fuzz = { runs = 50 } diff --git a/l2-contracts/package.json b/l2-contracts/package.json index cce5548..29aab2a 100644 --- a/l2-contracts/package.json +++ b/l2-contracts/package.json @@ -6,7 +6,7 @@ "compile": "forge build && npx hardhat compile", "local-node": "npx hardhat node-zksync", "script": "node script/RunScript.js", - "foundry-test": "forge test --no-match-path .integration.t.sol", + "foundry-test": "forge test --no-match-path .integration.t.sol --zksync", "test": "forge test && npx hardhat test", "lint": "scopelint check", "lint:fix": "scopelint fmt" diff --git a/l2-contracts/script/Counter.s.sol b/l2-contracts/script/Counter.s.sol deleted file mode 100644 index df9ee8b..0000000 --- a/l2-contracts/script/Counter.s.sol +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Script, console} from "forge-std/Script.sol"; - -contract CounterScript is Script { - function setUp() public {} - - function run() public { - vm.broadcast(); - } -} diff --git a/l2-contracts/src/Counter.sol b/l2-contracts/src/Counter.sol deleted file mode 100644 index aded799..0000000 --- a/l2-contracts/src/Counter.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - number = newNumber; - } - - function increment() public { - number++; - } -} diff --git a/l2-contracts/src/ZkCappedMinterV2.sol b/l2-contracts/src/ZkCappedMinterV2.sol new file mode 100644 index 0000000..7d01758 --- /dev/null +++ b/l2-contracts/src/ZkCappedMinterV2.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {IMintableAndDelegatable} from "src/interfaces/IMintableAndDelegatable.sol"; + +/// @title ZkCappedMinterV2 +/// @author [ScopeLift](https://scopelift.co) +/// @notice A contract to allow a permissioned entity to mint ZK tokens up to a given amount (the cap). +/// @custom:security-contact security@zksync.io +contract ZkCappedMinterV2 { + /// @notice The contract where the tokens will be minted by an authorized minter. + IMintableAndDelegatable public immutable TOKEN; + + /// @notice The address that is allowed to mint tokens. + address public immutable ADMIN; + + /// @notice The maximum number of tokens that may be minted by the ZkCappedMinter. + uint256 public immutable CAP; + + /// @notice The cumulative number of tokens that have been minted by the ZkCappedMinter. + uint256 public minted = 0; + + /// @notice Error for when the cap is exceeded. + error ZkCappedMinterV2__CapExceeded(address minter, uint256 amount); + + /// @notice Error for when the caller is not the admin. + error ZkCappedMinterV2__Unauthorized(address account); + + /// @notice Constructor for a new ZkCappedMinter contract + /// @param _token The token contract where tokens will be minted. + /// @param _admin The address that is allowed to mint tokens. + /// @param _cap The maximum number of tokens that may be minted by the ZkCappedMinter. + constructor(IMintableAndDelegatable _token, address _admin, uint256 _cap) { + TOKEN = _token; + ADMIN = _admin; + CAP = _cap; + } + + /// @notice Mints a given amount of tokens to a given address, so long as the cap is not exceeded. + /// @param _to The address that will receive the new tokens. + /// @param _amount The quantity of tokens, in raw decimals, that will be created. + function mint(address _to, uint256 _amount) external { + _revertIfUnauthorized(); + _revertIfCapExceeded(_amount); + minted += _amount; + TOKEN.mint(_to, _amount); + } + + /// @notice Reverts if msg.sender is not the contract admin. + function _revertIfUnauthorized() internal view { + if (msg.sender != ADMIN) { + revert ZkCappedMinterV2__Unauthorized(msg.sender); + } + } + + /// @notice Reverts if the amount of new tokens will increase the minted tokens beyond the mint cap. + /// @param _amount The quantity of tokens, in raw decimals, that will checked against the cap. + function _revertIfCapExceeded(uint256 _amount) internal view { + if (minted + _amount > CAP) { + revert ZkCappedMinterV2__CapExceeded(msg.sender, _amount); + } + } +} diff --git a/l2-contracts/src/ZkCappedMinterV2Factory.sol b/l2-contracts/src/ZkCappedMinterV2Factory.sol new file mode 100644 index 0000000..032db98 --- /dev/null +++ b/l2-contracts/src/ZkCappedMinterV2Factory.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {L2ContractHelper} from "src/lib/L2ContractHelper.sol"; +import {ZkCappedMinterV2} from "src/ZkCappedMinterV2.sol"; +import {IMintableAndDelegatable} from "src/interfaces/IMintableAndDelegatable.sol"; + +/// @title ZkCappedMinterV2Factory +/// @author [ScopeLift](https://scopelift.co) +/// @notice Factory contract to deploy ZkCappedMinterV2 contracts using CREATE2. +contract ZkCappedMinterV2Factory { + /// @dev Bytecode hash should be updated with the correct value from + /// ./zkout/ZkCappedMinterV2.sol/ZkCappedMinterV2.json. + bytes32 public immutable BYTECODE_HASH; + + constructor(bytes32 _bytecodeHash) { + BYTECODE_HASH = _bytecodeHash; + } + + /// @notice Emitted when a new ZkCappedMinterV2 is created. + /// @param minterAddress The address of the newly deployed ZkCappedMinterV2. + /// @param token The token contract where tokens will be minted. + /// @param admin The address authorized to mint tokens. + /// @param cap The maximum number of tokens that may be minted. + event CappedMinterV2Created(address indexed minterAddress, IMintableAndDelegatable token, address admin, uint256 cap); + + /// @notice Deploys a new ZkCappedMinterV2 contract using CREATE2. + /// @param _token The token contract where tokens will be minted. + /// @param _admin The address authorized to mint tokens. + /// @param _cap The maximum number of tokens that may be minted. + /// @param _saltNonce A user-provided nonce for salt calculation. + /// @return minterAddress The address of the newly deployed ZkCappedMinterV2. + function createCappedMinter(IMintableAndDelegatable _token, address _admin, uint256 _cap, uint256 _saltNonce) + external + returns (address minterAddress) + { + bytes memory saltArgs = abi.encode(_token, _admin, _cap); + bytes32 salt = _calculateSalt(saltArgs, _saltNonce); + ZkCappedMinterV2 instance = new ZkCappedMinterV2{salt: salt}(_token, _admin, _cap); + minterAddress = address(instance); + + emit CappedMinterV2Created(minterAddress, _token, _admin, _cap); + } + + /// @notice Computes the address of a ZkCappedMinterV2 deployed via this factory. + /// @param _token The token contract where tokens will be minted. + /// @param _admin The address authorized to mint tokens. + /// @param _cap The maximum number of tokens that may be minted. + /// @param _saltNonce The nonce used for salt calculation. + /// @return addr The address of the ZkCappedMinterV2. + function getMinter(IMintableAndDelegatable _token, address _admin, uint256 _cap, uint256 _saltNonce) + external + view + returns (address addr) + { + bytes memory saltArgs = abi.encode(_token, _admin, _cap); + bytes32 salt = _calculateSalt(saltArgs, _saltNonce); + addr = L2ContractHelper.computeCreate2Address( + address(this), salt, BYTECODE_HASH, keccak256(abi.encode(_token, _admin, _cap)) + ); + } + + /// @notice Calculates the salt for CREATE2 deployment. + /// @param _args The encoded arguments for the salt calculation. + /// @param _saltNonce A user-provided nonce for additional uniqueness. + /// @return The calculated salt as a bytes32 value. + function _calculateSalt(bytes memory _args, uint256 _saltNonce) internal view returns (bytes32) { + return keccak256(abi.encode(_args, block.chainid, _saltNonce)); + } +} diff --git a/l2-contracts/test/Counter.t.sol b/l2-contracts/test/Counter.t.sol deleted file mode 100644 index 54b724f..0000000 --- a/l2-contracts/test/Counter.t.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Test, console} from "forge-std/Test.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterTest is Test { - Counter public counter; - - function setUp() public { - counter = new Counter(); - counter.setNumber(0); - } - - function test_Increment() public { - counter.increment(); - assertEq(counter.number(), 1); - } - - function testFuzz_SetNumber(uint256 x) public { - counter.setNumber(x); - assertEq(counter.number(), x); - } -} diff --git a/l2-contracts/test/ZkCappedMinterFactory.t.sol b/l2-contracts/test/ZkCappedMinterFactory.t.sol index dc1845a..f82b7c3 100644 --- a/l2-contracts/test/ZkCappedMinterFactory.t.sol +++ b/l2-contracts/test/ZkCappedMinterFactory.t.sol @@ -15,7 +15,7 @@ contract ZkCappedMinterFactoryTest is ZkTokenTest { function setUp() public virtual override { super.setUp(); - + // Read the bytecode hash from the JSON file string memory root = vm.projectRoot(); string memory path = string.concat(root, "/zkout/ZkCappedMinter.sol/ZkCappedMinter.json"); diff --git a/l2-contracts/test/ZkCappedMinterV2.t.sol b/l2-contracts/test/ZkCappedMinterV2.t.sol new file mode 100644 index 0000000..91aa90e --- /dev/null +++ b/l2-contracts/test/ZkCappedMinterV2.t.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {ZkTokenTest} from "test/utils/ZkTokenTest.sol"; +import {IMintableAndDelegatable} from "src/interfaces/IMintableAndDelegatable.sol"; +import {ZkCappedMinterV2} from "src/ZkCappedMinterV2.sol"; +import {console2} from "forge-std/Test.sol"; + +contract ZkCappedMinterV2Test is ZkTokenTest { + function setUp() public virtual override { + super.setUp(); + } + + function createCappedMinter(address _admin, uint256 _cap) internal returns (ZkCappedMinterV2) { + ZkCappedMinterV2 cappedMinter = new ZkCappedMinterV2(IMintableAndDelegatable(address(token)), _admin, _cap); + vm.prank(admin); + token.grantRole(MINTER_ROLE, address(cappedMinter)); + return cappedMinter; + } +} + +contract Constructor is ZkCappedMinterV2Test { + function testFuzz_InitializesTheCappedMinterForAssociationAndFoundation(address _cappedMinterAdmin, uint256 _cap) + public + { + _cap = bound(_cap, 0, MAX_MINT_SUPPLY); + ZkCappedMinterV2 cappedMinter = createCappedMinter(_cappedMinterAdmin, _cap); + assertEq(address(cappedMinter.TOKEN()), address(token)); + assertEq(cappedMinter.ADMIN(), _cappedMinterAdmin); + assertEq(cappedMinter.CAP(), _cap); + } +} + +contract Mint is ZkCappedMinterV2Test { + function testFuzz_MintsNewTokensWhenTheAmountRequestedIsBelowTheCap( + address _cappedMinterAdmin, + address _receiver, + uint256 _cap, + uint256 _amount + ) public { + _cap = bound(_cap, 0, MAX_MINT_SUPPLY); + _amount = bound(_amount, 1, MAX_MINT_SUPPLY); + vm.assume(_cap > _amount); + vm.assume(_receiver != address(0) && _receiver != initMintReceiver); + ZkCappedMinterV2 cappedMinter = createCappedMinter(_cappedMinterAdmin, _cap); + vm.prank(_cappedMinterAdmin); + cappedMinter.mint(_receiver, _amount); + assertEq(token.balanceOf(_receiver), _amount); + } + + function testFuzz_MintsNewTokensInSuccessionToDifferentAccountsWhileRemainingBelowCap( + address _cappedMinterAdmin, + address _receiver1, + address _receiver2, + uint256 _cap, + uint256 _amount1, + uint256 _amount2 + ) public { + _cap = bound(_cap, 0, MAX_MINT_SUPPLY); + vm.assume(_amount1 < MAX_MINT_SUPPLY / 2); + vm.assume(_amount2 < MAX_MINT_SUPPLY / 2); + vm.assume(_amount1 + _amount2 < _cap); + vm.assume(_receiver1 != address(0) && _receiver1 != initMintReceiver); + vm.assume(_receiver2 != address(0) && _receiver2 != initMintReceiver); + vm.assume(_receiver1 != _receiver2); + ZkCappedMinterV2 cappedMinter = createCappedMinter(_cappedMinterAdmin, _cap); + vm.startPrank(_cappedMinterAdmin); + cappedMinter.mint(_receiver1, _amount1); + cappedMinter.mint(_receiver2, _amount2); + vm.stopPrank(); + assertEq(token.balanceOf(_receiver1), _amount1); + assertEq(token.balanceOf(_receiver2), _amount2); + } + + function testFuzz_RevertIf_MintAttemptedByNonAdmin(address _cappedMinterAdmin, uint256 _cap, address _nonAdmin) + public + { + _cap = bound(_cap, 0, MAX_MINT_SUPPLY); + vm.assume(_nonAdmin != _cappedMinterAdmin); + + ZkCappedMinterV2 cappedMinter = createCappedMinter(_cappedMinterAdmin, _cap); + vm.expectRevert(abi.encodeWithSelector(ZkCappedMinterV2.ZkCappedMinterV2__Unauthorized.selector, _nonAdmin)); + vm.startPrank(_nonAdmin); + cappedMinter.mint(_nonAdmin, _cap); + } + + function testFuzz_RevertIf_CapExceededOnMint(address _cappedMinterAdmin, address _receiver, uint256 _cap) public { + _cap = bound(_cap, 4, MAX_MINT_SUPPLY); + vm.assume(_receiver != address(0) && _receiver != initMintReceiver); + ZkCappedMinterV2 cappedMinter = createCappedMinter(_cappedMinterAdmin, _cap); + vm.prank(_cappedMinterAdmin); + cappedMinter.mint(_receiver, _cap); + assertEq(token.balanceOf(_receiver), _cap); + vm.expectRevert( + abi.encodeWithSelector(ZkCappedMinterV2.ZkCappedMinterV2__CapExceeded.selector, _cappedMinterAdmin, _cap) + ); + vm.prank(_cappedMinterAdmin); + cappedMinter.mint(_receiver, _cap); + } +} diff --git a/l2-contracts/test/ZkCappedMinterV2Factory.t.sol b/l2-contracts/test/ZkCappedMinterV2Factory.t.sol new file mode 100644 index 0000000..d111e82 --- /dev/null +++ b/l2-contracts/test/ZkCappedMinterV2Factory.t.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {ZkTokenTest} from "test/utils/ZkTokenTest.sol"; +import {ZkCappedMinterV2Factory} from "src/ZkCappedMinterV2Factory.sol"; +import {ZkCappedMinterV2} from "src/ZkCappedMinterV2.sol"; +import {IMintableAndDelegatable} from "src/interfaces/IMintableAndDelegatable.sol"; +import {Create2} from "@openzeppelin/contracts/utils/Create2.sol"; +import {console2} from "forge-std/console2.sol"; +import {stdJson} from "forge-std/StdJson.sol"; + +contract ZkCappedMinterV2FactoryTest is ZkTokenTest { + bytes32 bytecodeHash; + ZkCappedMinterV2Factory factory; + + function setUp() public virtual override { + super.setUp(); + + // Read the bytecode hash from the JSON file + string memory root = vm.projectRoot(); + string memory path = string.concat(root, "/zkout/ZkCappedMinterV2.sol/ZkCappedMinterV2.json"); + string memory json = vm.readFile(path); + bytecodeHash = bytes32(stdJson.readBytes(json, ".hash")); + + factory = new ZkCappedMinterV2Factory(bytecodeHash); + } + + function _assumeValidAddress(address _addr) internal view { + vm.assume(_addr != address(0) && _addr != address(factory)); + } + + function _boundToReasonableCap(uint256 _cap) internal view returns (uint256) { + return bound(_cap, 1, MAX_MINT_SUPPLY); + } +} + +contract CreateCappedMinter is ZkCappedMinterV2FactoryTest { + function testFuzz_CreatesNewCappedMinter(address _cappedMinterAdmin, uint256 _cap, uint256 _saltNonce) public { + _assumeValidAddress(_cappedMinterAdmin); + _cap = _boundToReasonableCap(_cap); + + address minterAddress = + factory.createCappedMinter(IMintableAndDelegatable(address(token)), _cappedMinterAdmin, _cap, _saltNonce); + + ZkCappedMinterV2 minter = ZkCappedMinterV2(minterAddress); + assertEq(address(minter.TOKEN()), address(token)); + assertEq(minter.ADMIN(), _cappedMinterAdmin); + assertEq(minter.CAP(), _cap); + } + + function testFuzz_EmitsCappedMinterCreatedEvent(address _cappedMinterAdmin, uint256 _cap, uint256 _saltNonce) public { + _assumeValidAddress(_cappedMinterAdmin); + _cap = _boundToReasonableCap(_cap); + + vm.expectEmit(); + emit ZkCappedMinterV2Factory.CappedMinterV2Created( + factory.getMinter(IMintableAndDelegatable(address(token)), _cappedMinterAdmin, _cap, _saltNonce), + IMintableAndDelegatable(address(token)), + _cappedMinterAdmin, + _cap + ); + + factory.createCappedMinter(IMintableAndDelegatable(address(token)), _cappedMinterAdmin, _cap, _saltNonce); + } + + function testFuzz_RevertIf_CreatingDuplicateMinter(address _cappedMinterAdmin, uint256 _cap, uint256 _saltNonce) + public + { + _assumeValidAddress(_cappedMinterAdmin); + _cap = _boundToReasonableCap(_cap); + + factory.createCappedMinter(IMintableAndDelegatable(address(token)), _cappedMinterAdmin, _cap, _saltNonce); + + vm.expectRevert("Code hash is non-zero"); + factory.createCappedMinter(IMintableAndDelegatable(address(token)), _cappedMinterAdmin, _cap, _saltNonce); + } +} + +contract GetMinter is ZkCappedMinterV2FactoryTest { + function testFuzz_ReturnsCorrectMinterAddress(address _cappedMinterAdmin, uint256 _cap, uint256 _saltNonce) public { + _assumeValidAddress(_cappedMinterAdmin); + _cap = _boundToReasonableCap(_cap); + + address expectedMinterAddress = + factory.getMinter(IMintableAndDelegatable(address(token)), _cappedMinterAdmin, _cap, _saltNonce); + + address minterAddress = + factory.createCappedMinter(IMintableAndDelegatable(address(token)), _cappedMinterAdmin, _cap, _saltNonce); + + assertEq(minterAddress, expectedMinterAddress); + } + + function testFuzz_GetMinterWithoutDeployment(address _cappedMinterAdmin, uint256 _cap, uint256 _saltNonce) public { + _assumeValidAddress(_cappedMinterAdmin); + _cap = _boundToReasonableCap(_cap); + + address expectedMinterAddress = + factory.getMinter(IMintableAndDelegatable(address(token)), _cappedMinterAdmin, _cap, _saltNonce); + + uint256 codeSize; + assembly { + codeSize := extcodesize(expectedMinterAddress) + } + assertEq(codeSize, 0); + + address minterAddress = + factory.createCappedMinter(IMintableAndDelegatable(address(token)), _cappedMinterAdmin, _cap, _saltNonce); + + assembly { + codeSize := extcodesize(expectedMinterAddress) + } + assertGt(codeSize, 0); + assertEq(minterAddress, expectedMinterAddress); + } +}