3 min read

EnhancedAccessControl in ENSv2

EnhancedAccessControl in ENSv2

I spent some time digging through the EnhancedAccessControl (EAC) system used in ENSv2 noting that at first glance it looks quite strange compared to traditional Solidity access control. After understanding it properly, I actually think it’s a pretty elegant design.

The goal of EAC is essentially:

“How do we efficiently manage permissions for lots of users across lots of independent resources?”

A resource is ultimately just an arbitrary identifier representing some entity for which access control is required. Some examples of resources in ENSv2 include:

  • Specific ENS names
  • An ENS sub-registry
  • A resolver

A role defines what actions an address can perform on a specific resource. The contract architect provides the semantic meaning to a role definition. For example a role defined as CAN_SET_RESOLVER would be validated against within the smart contract code to only allow users with that role to.. set a resolver.

In code

In the current code, roles are stored as a mapping as follows:

mapping(uint256 resource => mapping(address account => uint256 roles)) _roles;

At first glance this can seem inefficient because every assigned account gets a full uint256. Each role occupies a 4-bit nibble but only one bit within that nibble is actually used to represent the role itself. In total 64 different roles can be assigned to any given user for any given resource.

In reality - for the ENSv2 use case - any given resource can have 32 distinct roles because for each role, a corresponding admin role exists that controls the assignment of the associated underlying role.

|<----------- lower 128 bits ----------->|<----------- upper 128 bits ----------->|

 Regular roles                                  Admin roles

Admin roles occupy the upper 128 bits of the bitmap and are derived by simply shifting the underlying role by 128 bits. This allows the contract to mathematically derive which roles an address is allowed to manage.

In practice most resources will likely not have need for 32 independent roles so is this not a waste of space?

Space

In Solidity, mappings are not automatically packed in a similar way to how a struct of independent uint8 values might be. As such a mapping of uint8 would still take up the same amount of space - one storage slot, 32 bytes.

This is clearly an architectural decision that trades off storage usage against the need for multiple storage writes per user with the alternative being something like the following which would require a write for each role that a given user has.

mapping(resource => mapping(role => mapping(user => bool)))

The cool benefit of this architecture is that bitmasks and bitwise operations can be used to efficiently answer questions like "Does Thomas have this role?" and "How many users have this role?"

Bitmasks

EAC deliberately spaces roles out every 4 bits with each role getting its own 4-bit nibble.

0x1000...

Role '4' is assigned, but roles '1' , '2', and '3' are not.

The bitmasks defined in EACBaseRolesLib can then be used to efficiently validate the assignment of specific roles.

uint256 internal constant ALL_ROLES = 0x1111111111111111111111111111111111111111111111111111111111111111;

By utilizing a bitwise & operation the ALL_ROLES bitmask can be used to isolate and validate valid role bits within a bitmap.

Additionally, the counting of role assignments is stored within a mapping as follows:

mapping(uint256 resource => uint256 roleCount) private _roleCount;

This single uint256 stores the number of assignees for all roles on a resource simultaneously. If Role A has 3 assignees, Role B has 7 assignees, and Role C has 2 assignees all of these counts can fit inside one packed uint256. Each nibble acts as a tiny counter that corresponds positionally to the same nibble in the user-specific uint256 role mapping value. The above example counts would be represented as 0x273.

When someone is granted a role, because each role nibble lines up perfectly with its corresponding counter nibble, EAC can simply do:

newlyAssignedRoles = grantedRoles & ~existingRoles;
roleCounts += newlyAssignedRoles;

The only constraint here is that because each nibble is a 4-bit counter supporting values from 0-15, each role can have a maximum of 15 assignees per resource before overflow would occur.

Root Roles

Another important concept is root roles. Resource 0 acts as the root resource meaning that if someone has a specific role on the root resource they effectively have that permission everywhere.

Problems?

One subtle limitation of the design is that it cannot efficiently answer "Who has this role?" because mappings in Solidity are not enumerable.

EAC is seemingly optimized for questions pertaining to access-control (duh.. its in the name), but it is unfortunate that the tradeoff is complexity in exposing that data to end users necessitating reliance on indexers.

I believe that ultimately trusted indexers and APIs will be the entry point to ENS simply because of legacy architectural complexity and the need to make implementation as simple as possible for integration partners.

It would be interested to know and understand the opinions of aftermarket participants and the teams behind platforms like OpenSea, Grails, and Vision.

Conclusion

EAC is essentially a compact, resource-scoped, bitmap-based permission system that also embeds role-assignee counting directly into the bitmap arithmetic itself.

It is technically cool.