Appearance
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 thenftContractaddress is not a real ERC-721 contract (or is malicious), it will revert or return incorrect data - Resource overwriting: Not checking the
existsflag 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
requiredBalanceor 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 #
| Point | Description |
|---|---|
| Token-gating pattern | balanceOf(user) >= requiredBalance determines access |
| Modifier as middleware | onlyNFTHolder(resourceId) encapsulates permission logic; business code is non-invasive |
| Resource storage | mapping(bytes32 -> Resource) -- O(1) lookup |
| ERC-721 interface dependency | Call via IERC721 interface, not dependent on concrete implementation |
| External call risk | Target contract may be malicious/nonexistent -- consider ERC-165 verification |
| Synchronous permission check | Checks in real time at function call; does not support "continuous monitoring" |
| resourceId namespace | keccak256("MY_RESOURCE") generates deterministic IDs |
| Cross-contract composition | Permission contract + NFT contract = access control system |