Search code examples
ethereumsoliditysmartcontracts

Checking and granting role in Solidity


I'm trying to create a factory contract which is used to mint ERC721 tokens, at two different prices depending on whether it's during presale.

I'm using the Access library from OpenZeppelin, and have my contract set up with two roles (plus the default administrator role). Some lines are excluded for brevity:

import "@openzeppelin/contracts/access/AccessControl.sol";
import "./Example.sol";

contract ExampleFactory is AccessControl {
  // ...

  bool public ONLY_WHITELISTED = true;
  uint256 public PRESALE_COST = 6700000 gwei;
  uint256 public SALE_COST = 13400000 gwei;
  uint256 MAX_PRESALE_MINT = 2;
  uint256 MAX_LIVE_MINT = 10;
  uint256 TOTAL_SUPPLY = 100;

  // ...

  bytes32 public constant ROLE_MINTER = keccak256("ROLE_MINTER");
  bytes32 public constant ROLE_PRESALE = keccak256("ROLE_PRESALE");
  
  // ...

  constructor(address _nftAddress) {
    nftAddress = _nftAddress;

    // Grant the contract deployer the default admin role: it will be able
    // to grant and revoke any roles
    _setupRole(DEFAULT_ADMIN_ROLE, msg.sender);
    _setupRole(ROLE_MINTER, msg.sender);
    _setupRole(ROLE_PRESALE, msg.sender);
  }

  function mint(uint256 _mintAmount, address _toAddress) public payable {
    // If the user doesn't have the minter role then require payment
    if (hasRole(ROLE_MINTER, msg.sender) == false) {
        if (ONLY_WHITELISTED == true) {
            // If still in whitelist mode then require presale role & enough value
            require(hasRole(ROLE_PRESALE, msg.sender), "address is not whitelisted");
            require(msg.value >= PRESALE_COST * _mintAmount, "tx value too low for quantity");
        } else {
            require(msg.value >= SALE_COST * _mintAmount, "tx value too low for quantity");
        }
    }

    // Check there are enough tokens left to mint
    require(canMint(_mintAmount), "remaining supply too low");

    Example token = Example(nftAddress);
    for (uint256 i = 0; i < _mintAmount; i++) {
        token.mintTo(_toAddress);
    }
  }

  function canMint(uint256 _mintAmount) public view returns (bool) {
    if (hasRole(ROLE_MINTER, msg.sender) == false) {
        if (ONLY_WHITELISTED == true) {
            require((_mintAmount <= MAX_PRESALE_MINT), "max 2 tokens can be minted during presale");
        } else {
            require((_mintAmount <= MAX_LIVE_MINT), "max 10 tokens can be minted during sale");
        }
    }

    Example token = Example(nftAddress);
    uint256 issuedSupply = token.totalSupply();
    return issuedSupply < (TOTAL_SUPPLY - _mintAmount);
  }
}

There are a couple of different paths to mint:

  • If the user has ROLE_MINTER, they can mint without payment or limits
  • If ONLY_WHITELISTED is true, the transaction must have enough value for presale price, and they must have ROLE_PRESALE
  • If ONLY_WHITELISTED is false, anyone can mint

I've written a script to test minting:

const factoryContract = new web3Instance.eth.Contract(
  FACTORY_ABI,
  FACTORY_CONTRACT_ADDRESS,
  { gasLimit: '1000000' }
);

console.log('Testing mint x3 from minter role')
try {
  const result = await factoryContract.methods
    .mint(3, OWNER_ADDRESS)
    .send({ from: OWNER_ADDRESS });
  console.log('  ✅  Minted 3x. Transaction: ' + result.transactionHash);
} catch (err) {
  console.log('  🚨  Mint failed')
  console.log(err)
}

Running this successfully mints 3 tokens to the factory owner. No value is attached to this call, and it's minting more than the maximum, so in order for it to be successful it has to follow the ROLE_MINTER path.

However, if I call hasRole from the same address, the result is false which doesn't make sense.

const minterHex = web3.utils.fromAscii('ROLE_MINTER')
const result = await factoryContract.methods.hasRole(minterHex, OWNER_ADDRESS).call({ from: OWNER_ADDRESS });
// result = false

If I try to run the test mint script from another address (with no roles) it failed as expected, which suggests roles are working but I'm using hasRole wrong?


Solution

  • const minterHex = web3.utils.fromAscii('ROLE_MINTER')
    

    This JS snippet returns the hex representation of the ROLE_MINTER string: 0x524f4c455f4d494e544552

    bytes32 public constant ROLE_MINTER = keccak256("ROLE_MINTER");
    

    But this Solidity snippet returns the keccak256 hash of the ROLE_MINTER string: 0xaeaef46186eb59f884e36929b6d682a6ae35e1e43d8f05f058dcefb92b601461

    So when you're querying the contract if the OWNER_ADDRESS has the role 0x524f4c455f4d494e544552, it returns false because this address doesn't have this role.


    You can calculate the hash using the web3.utils.soliditySha3() function (docs).

    const minterHash = web3.utils.soliditySha3('ROLE_MINTER');
    const result = await factoryContract.methods.hasRole(minterHash, OWNER_ADDRESS).call();
    

    Also note that the OpenZeppelin hasRole() function doesn't check for msg.sender, so you don't need to specify the caller inside the call() function. Just the 2nd argument of the hasRole() as the account that you're asking about their role.