Skip to content
On this page

L2-12: NFT Permissions (NFT Access Control)

1. Problem

NFTs are not just digital collectibles -- they are on-chain "membership cards," "passes," and "capability credentials." When a DApp wants to grant different levels of access based on which NFTs a user holds, the core question is: how do you reliably verify in a Solidity contract that "the caller holds a certain ERC-721 NFT"? This seems simple -- just call balanceOf(msg.sender) -- but the details involved are far more complex than that.

First, permissions are not a binary yes/no -- different resources may require different minimum holding amounts. For example: holding 1 NFT grants access to the general area, holding 5 grants access to the VIP area. Second, permissions need dynamic management -- administrators should be able to create new resources and modify permission requirements without redeploying the contract. Third, the permission system needs to integrate with the contract's modifiers, performing checks transparently at function entry points. Finally, the permission system's error messages must be clear enough that rejected users know what they are missing.

This challenge (refer to src/level2/NFTPermissions.sol) requires implementing a complete token-gated access control system that verifies permissions based on NFT holdings through ERC-721 balance checks.

2. Why

Token-gating is one of the foundational building blocks of modern Web3 applications. From tokenized communities (like Friends With Benefits) to on-chain games (like "must hold Sword NFT to enter the dungeon") to content platforms (like "must hold Genesis NFT to read premium articles"), permission control based on NFT ownership is everywhere.

At the technical level, NFTPermissions is an excellent exercise for understanding "inter-contract interaction" -- your permission contract needs to call an external ERC-721 contract. This cross-contract calling involves the A-B problem: contract A calls contract B's balanceOf, and contract B might return incorrect data, might consume excessive gas, or might even revert. You need defensive programming: explicit ERC-721 interfaces, limiting gas consumption (though Solidity view calls under staticcall don't consume gas, they do have limits), and handling edge cases where the external contract doesn't exist.

More importantly, NFTPermissions demonstrates the "modifier as permission middleware" design pattern: permission logic is encapsulated in a modifier, the business logic function body remains clean, and permission checks fire automatically before function execution. This separation of concerns is a core practice in Solidity contract architecture design.

3. Solution

The NFTPermissions contract (refer to src/level2/NFTPermissions.sol) uses a two-layer architecture of resources + modifiers:

solidity
contract NFTPermissions {
    struct Resource {
        IERC721 nftContract;      // The NFT contract to check
        uint256 requiredBalance;  // Minimum holding amount
        bool exists;              // Whether the resource exists
    }

    // Resource mapping: resourceId -> Resource config
    mapping(bytes32 => Resource) private _resources;

    // Create a new NFT-protected resource
    function createResource(
        bytes32 resourceId,
        IERC721 nftContract,
        uint256 requiredBalance
    ) external {
        if (_resources[resourceId].exists) revert ResourceAlreadyExists(resourceId);

        _resources[resourceId] = Resource({
            nftContract: nftContract,
            requiredBalance: requiredBalance,
            exists: true
        });

        emit ResourceCreated(resourceId, address(nftContract), requiredBalance);
    }

    // Core permission check
    function checkBalanceAccess(address user, bytes32 resourceId)
        public view returns (bool hasAccess)
    {
        Resource storage resource = _resources[resourceId];
        if (!resource.exists) revert ResourceNotFound(resourceId);

        return resource.nftContract.balanceOf(user) >= resource.requiredBalance;
    }

    // Permission modifier -- transparently checks at function entry
    modifier onlyNFTHolder(bytes32 _resourceId) {
        if (!checkBalanceAccess(msg.sender, _resourceId)) {
            revert UnauthorizedAccess(msg.sender, _resourceId);
        }
        _;
    }

    // Protected function -- modifier automatically enforces permission check
    function accessProtected(bytes32 resourceId)
        external onlyNFTHolder(resourceId) returns (bool success)
    {
        emit ResourceAccessed(resourceId, msg.sender);
        return true;
    }
}

Three-Layer Permission Verification Model

Layer 1: Who is accessing?      -> msg.sender
Layer 2: What are they accessing? -> resourceId -> Resource(nftContract, requiredBalance)
Layer 3: Is the condition met?  -> nftContract.balanceOf(msg.sender) >= requiredBalance

Resource ID Design

Using bytes32 as the resource ID rather than uint256 or string has several advantages:

  • keccak256("VIP_ROOM") generates a deterministic bytes32, convenient for cross-contract referencing
  • bytes32 occupies exactly one 32-byte slot in storage, making gas calculation simple
  • Can be pre-computed (generated off-chain and hardcoded in the frontend), not dependent on post-deployment return values

4. Pitfalls Encountered

  • Unprotected external contract calls: resource.nftContract.balanceOf(user) is an external call -- if the nftContract address is not a real ERC-721 contract (or is malicious), it will revert or return incorrect data
  • Resource overwriting: Not checking the exists flag when creating a resource, causing existing resources to be accidentally overwritten -- the old permission config is lost
  • resourceId collisions: When using simple string hashes as IDs, different administrators may accidentally create the same resource ID (e.g., both using keccak256("VIP"))
  • Checking only balance, not specific token IDs: Some scenarios require "holding NFT #42" rather than "holding at least 1 arbitrary NFT" -- balanceOf-based checks cannot satisfy this requirement
  • NFT transfers causing permission changes: A user mints an NFT -> gains access -> transfers the NFT out -> access is not revoked. Permission checks happen only when the user initiates a transaction (synchronous check), not via continuous monitoring (asynchronous revocation)

5. Why the Pitfalls Exist

The risk of external contract calls stems from Solidity's "trust the external interface" assumption. When you call IERC721(nftContract).balanceOf(user), what actually executes is the code at the target address -- and that address could be a malicious contract executing arbitrary code inside balanceOf. Although checkBalanceAccess is a view function (staticcall context, cannot modify state), a malicious contract can still:

  • Consume excessive gas (DoS attack)
  • Return carefully crafted but technically valid data (e.g., always returning requiredBalance or higher)
  • Revert under certain edge conditions (preventing callers from passing permission checks)

Resource overwriting is a typical case of missing "idempotency protection." In distributed systems, resource creation should be idempotent -- repeatedly creating the same resource ID should either be a no-op or explicitly error. Not checking exists means the last write silently overwrites the previous one, which can lead to permissions being unexpectedly lowered (or raised).

A permission model based solely on balanceOf cannot distinguish between "I hold 1 of #99 (common)" and "I hold 1 of #1 (legendary)" -- both appear equivalent to balanceOf. Scenarios requiring specific token IDs (such as credential NFTs, where each token ID represents a specific certificate) need ownerOf(tokenId) or ERC-1155's balanceOf(account, id).

6. How to Resolve the Pitfalls

Add a defensive layer for external contract calls. In production, you can verify at resource creation time that the target contract implements the ERC-721 interface (via ERC-165 supportsInterface):

solidity
function createResource(bytes32 resourceId, IERC721 nftContract, uint256 requiredBalance) external {
    if (_resources[resourceId].exists) revert ResourceAlreadyExists(resourceId);

    // Optional: verify ERC-721 compatibility (ERC-165 check)
    // require(nftContract.supportsInterface(0x80ac58cd), "Not ERC721");

    _resources[resourceId] = Resource(nftContract, requiredBalance, true);
    emit ResourceCreated(resourceId, address(nftContract), requiredBalance);
}

For the resource overwriting problem, using the exists flag is the standard method for achieving idempotency:

solidity
if (_resources[resourceId].exists) revert ResourceAlreadyExists(resourceId);

This ensures each resource ID can only be created once. If updates are genuinely needed, provide a separate updateResource function requiring additional permissions.

For specific token ID checks, extend the permission model to support both modes:

solidity
struct Resource {
    IERC721 nftContract;
    uint256 requiredBalance;
    uint256 specificTokenId;   // 0 means no specific ID check
    bool exists;
}

function checkAccess(address user, bytes32 resourceId) public view returns (bool) {
    Resource storage r = _resources[resourceId];
    if (r.specificTokenId != 0) {
        // Check if holding a specific token ID
        return r.nftContract.ownerOf(r.specificTokenId) == user;
    }
    // Otherwise check balance
    return r.nftContract.balanceOf(user) >= r.requiredBalance;
}

For permission changes caused by NFT transfers, checks are performed in real time when accessProtected is called -- this is the natural behavior of a purely synchronous permission model. If a "continuous holding" requirement is needed, you can add holding time records and minimum holding duration checks. Note, however, that on-chain timestamps are only accurate to the block level (~12 seconds).

7. Key Technical Points

PointDescription
Token-gating patternbalanceOf(user) >= requiredBalance determines access
Modifier as middlewareonlyNFTHolder(resourceId) encapsulates permission logic; business code is non-invasive
Resource storagemapping(bytes32 -> Resource) -- O(1) lookup
ERC-721 interface dependencyCall via IERC721 interface, not dependent on concrete implementation
External call riskTarget contract may be malicious/nonexistent -- consider ERC-165 verification
Synchronous permission checkChecks in real time at function call; does not support "continuous monitoring"
resourceId namespacekeccak256("MY_RESOURCE") generates deterministic IDs
Cross-contract compositionPermission contract + NFT contract = access control system

Built with AiAda