Using signatures for access control in smart contracts

Using signatures for access control in smart contracts

One of the most important things in web3 is the decentralized nature of it all. The idea that everything is transparent in a public ledger means that entries, or in blockchain terms transactions, can be accessed in the future and scrutinized.

This though, does not mean that everyone can do anything and everything. Controlling your smart contracts is important to minimize the effect of bad citizens and provide the service you promise to the community.

At Tribz, we try to bring your contribution on-chain. This is done by substantiating a work effort token using data from different sources. This could be source-control code, email attestation, token gifting or otherwise. We save this contribution in a non-transferrable ERC721 (soulbound) token together with a proof of this contribution. In this article, I’ll go over how we control our contracts not only to prevent bad actors from minting whatever they want, but also to help users mint at appropriate moments where the created token fully represents their contribution to a particular project.

Possibilities

There are many ways to control who can run certain functions in smart contracts. A few examples include:

  • Ownership of contract
  • Role
  • Saved assignment
  • Signatures

Ownership of contract

The simplest way of making sure only authorized wallets can run a specific function is to have a contract owner. This contract owner is usually assigned on contract creation. Certain functions can then have an onlyOwner modifier associated to them where only said wallet can run that function. *OpenZeppelin *offers an abstract contract that could be inherited to do this out of the box here. More information on implementation can be found here.

The advantage of this type of control is that it is extremely simple to implement and use. The calls that need control only need to have a simple address matching between msg.sender and the owner address. This is useful in cases where simple contracts only need one owner (keep in mind that the owner can also be a Gnosis safe).

However, this method lacks flexibility when it comes to having different users needing to have the ability to run certain functions and not others. That is where Access Control Roles come into play.

Access Control through Roles

Access control using roles is where we have predefined roles in the contract and we can grant that role to certain users using a grantRole function or remove it using a revokeRole function. Many times an admin is given all roles when creating the contract and then that admin grants all other roles to the users that need them. Once again, OpenZeppelin offers their own abstract contract here for this while also explaining how to use it in code here.

This structure adds flexibility since the roles can be as granular as they need to be and a user can have one or multiple roles. These roles and the user-to-role mapping would need to be saved to the contract where gas is needed and mapping memory is used, but if the number of users that need a certain role is small, this should be feasible.

Since the assignment of a user to a role is saved in the contract and needs to be added or removed by an admin, or the users themselves, this means that it is semi-permanent. This is not ideal when many users would want to run a particular function and not ideal when we want to allow users to run a certain function just once.

Saved assignment

The simplest way of solving the semi-permanent issue above is to save an address to boolean mapping on whether a user can run a specific function or not before every authorized transact call to the contract. This is the edge case of Access control where a specific role is present just to run a function.

When the user then runs the function, the mapping would be removed effectively making sure that the user cannot run the function again. This would help in cases where:

  • A user would need to be allowed to run the function once
  • There is a large list of possible users that would want to run a particular function since no mapping is kept after the function is run

One can argue that this is exactly like Access Control and also this would get messy quickly if there is a variety of methods that would need to be controlled. Furthermore, this would require more gas since a transaction needs to be placed to add the user to the mapping every time the user would want to run a function. It is important to note that none of the methods mentioned so far allow control over the argument values themselves. This is also dangerous when dealing with bad actors.

Control through signatures

The last method that I’ll define here is control through EIP712 signed typed data structures. This controls both the function being run and the parameters used as an input which adds an extra layer of flexibility. Furthermore, it reduces the number of transactions on the chain as there is no need for an initial call to save the user in a mapping.

As all methods, it does have its issues. One of the most critical ones, is that in order for this method to be used, there needs to be at least one of the parameters that are used to construct the signature that is saved in the contract. If not, the signature could be reused multiple times and the function run multiple times. An example where we use this method at Tribz, is to mint tokens. Since the tokenId is saved in a contract mapping by default. Therefore, we can safely assume that if the same parameters and signature are used twice, this would then in turn fail due to duplicate tokenId.

Another drawback, is that the EIP712 data structure type is more complicated, and debugging is more tedious since when the signature is invalid, we get no information on what when wrong except that the recovered and given addresses didn’t match.

Despite these caveats, this control type is exceptionally useful especially when needing to keep a backend server and a contract in sync.

In the rest of the article, I’ll go through this control through the signatures method in detail.

Implementation

The high-level overview comprises of 3 blocks with a few sub-tasks for each block. The user asks for the signature, an admin or a server creates the signature, then the user passes that signature to the function which gets validated.

After the server gets the request from the user to run a particular function and confirms the user is authorized, it first creates a data structure of parameters that the user should use. This can be omitted if we are going to allow the user to run any type of parameters. On our side, we wanted to give the user the ability to mint a token with a specific tokenId and tokenUri. The data structure here needs to follow the EIP712 standard. Apart from the data itself this standard requires the need for a domainSeparator and encodeType. The domainSeparator defines information about the chain, and the version of the app and the contract itself. These are mainly used so that the signature cannot be used on different contracts and different chains. The encodeType is used to define the data-types of the variables themselves. This is handy as when then building the solidity code, all you’d need to make sure is that types match.

const approvalType = {
  Approval: [
    {
      name: 'to',
      type: 'address',
    },
    {
      name: 'tokenUri',
      type: 'string',
    },
    {
      name: 'tokenId',
      type: 'uint256',
    },
  ],
};

const domain = {
  name: this._domainName,
  version: this._domainVersion,
  verifyingContract = contract.address
  chainId
};

The signature can then be obtained using a *typed data sign *method. In our case, we use ethers.js which has its own implementation of this function which signs the data using the approver’s wallet. This signature can then be returned to the user which can then use it in the contract.

async signTypedData(types, data, chainId, contract) {
  const types = approvalType;
	const domain = domain;
  const signature = await wallet._signTypedData(domain, types, data);
  return signature;
}

In the contract this signature would need to be passed to the controlled function. In our case the mint method. (We omit the to parameter from the requirement since we’re assuming that the receiver is the sender).

function mint(
    uint256 tokenId,
    string memory tokenUri,
    bytes memory signature
) public payable withApprovalSignature(tokenId, tokenUri, signature) {
    _mintToken(msg.sender, tokenId, tokenUri);
}

A modifier can then be used to verify that the correct method with the correct parameters was called by an authorized user. To make our life easier, we are inheriting from EIP712.sol contract by OpenZeppelin so we can call the _hashTypedDataV4 function to hash the typed data together with the types and domainSeperator. Unfortunately, this contract is still in draft mode so stability is not guaranteed. However, we tested this extensively, reviewed the code ourselves and we couldn’t find issues to raise.

The tricky part when creating the digest in Solidity is that everything needs to be hashed manually, and there are a few layers of hashing and encoding that you need to remember to do.

In our case when the parameter was a string, we needed that to be converted to bytes and then hashed, but not when it is a *uint256 *or address data type. These parameters would then be passed to the abi.encode function and then hashed again before passing it to the actual _hashTypedDataV4 function which combines the types and domain separator before hashing again.

modifier withApprovalSignature(
	uint256 tokenId,
	string memory tokenUri,
	bytes memory signature
) {
	
	bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
	  keccak256("Approval(address to,string tokenUri,uint256 tokenId)"),
    msg.sender,
    keccak256(bytes(tokenUri)),
    tokenId
  )));
        
	bool validRequest = _validSignature(digest, signature, _approver);
  require(validRequest, "Request not Authorized");
	_;
}

Once the digest is created we need to extract the signing address from this digest and the input signature. This is done using ECDSA recover method. If everything is correct and matching the returned address should match the one that created the signature itself, else it would be different.

function _validSignature(bytes32 digest, bytes memory signature, address account)
	internal pure returns (bool)
{
	address recovered = ECDSA.recover(digest, signature);
  return recovered == account;
}

Conclusion

And that should be it. In this article we have gone through different methods to authenticate users when running certain functions in smart contracts. Some methods are more suited for certain scenarios than others. I described using signatures to control contracts in detail as we found it was one of the methods with least content around.

I hope this helped you on your journey to building better contract access control for your contracts. Here at Tribz, we’re proud members of the community so feel free to reach out if you run into issues.

Chat to us on Discord or join us on Twitter, we love to hear your feedback!