Skip to content
On this page

L2-12: NFT Permissions(NFT 权限管理)

1. 问题

NFT 不仅仅是数字收藏品——它们是链上"会员卡"、"通行证"和"能力凭证"。当一个 DApp 想要根据用户持有的 NFT 来授予不同级别的访问权限时,核心问题是如何在 Solidity 合约中可靠地验证"调用者是否持有某个 ERC-721 NFT"?这看似简单——调用 balanceOf(msg.sender)——但实际涉及的细节比这复杂得多。

首先,权限不是二元的是/否——不同资源可能需要不同的持有量下限。例如:持有 1 个 NFT 可以进入普通区,持有 5 个可以进入 VIP 区。其次,权限需要动态管理——管理员应该能够创建新资源、修改权限要求,而不需要重新部署合约。第三,权限系统需要与合约的修饰符(modifier)集成,在函数入口处透明地进行检查。最后,权限系统的错误信息必须足够清晰,让被拒绝的用户知道缺少了什么。

本挑战(参考 src/level2/NFTPermissions.sol)要求实现一个完整的 token-gated 访问控制系统,通过 ERC-721 余额检查来实现基于 NFT 持有量的权限验证。

2. 原因

Token-gating(代币门控)是现代 Web3 应用的基础设施之一。从代币化社区(如 Friends With Benefits)到链上游戏(如 "持有 Sword NFT 才能进入地下城")到内容平台(如 "持有创世 NFT 才能阅读高级文章"),基于 NFT 所有权的权限控制无处不在。

在技术层面,NFTPermissions 是理解"合约间交互"的绝佳练习——你的权限合约需要调用外部的 ERC-721 合约。这种跨合约调用涉及 AB 问题:A 合约调用 B 合约的 balanceOf,B 合约可能返回错误的数据、可能消耗大量 gas、甚至可能 revert。你需要防御性编程:明确 ERC-721 接口、限制 Gas 消耗(虽然 Solidity 的 view 调用在 staticcall 下不消耗 gas 但有限制)、以及处理外部合约不存在的边界情况。

更重要的是,NFTPermissions 展示了"修饰器作为权限中间件"的设计模式:权限逻辑被封装在 modifier 中,业务逻辑函数体保持简洁,权限检查在函数执行前自动触发。这种关注点分离是 Solidity 合约架构设计的核心实践。

3. 方案

NFTPermissions 合约(参考 src/level2/NFTPermissions.sol)采用资源 + 修饰器的双层架构:

solidity
contract NFTPermissions {
    struct Resource {
        IERC721 nftContract;      // 要检查的 NFT 合约
        uint256 requiredBalance;  // 最低持有量
        bool exists;              // 资源是否存在
    }

    // 资源映射: resourceId → Resource 配置
    mapping(bytes32 => Resource) private _resources;

    // 创建受 NFT 保护的新资源
    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);
    }

    // 核心权限检查
    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;
    }

    // 权限修饰器——在函数入口处透明检查
    modifier onlyNFTHolder(bytes32 _resourceId) {
        if (!checkBalanceAccess(msg.sender, _resourceId)) {
            revert UnauthorizedAccess(msg.sender, _resourceId);
        }
        _;
    }

    // 受保护的功能——修饰器自动执行权限检查
    function accessProtected(bytes32 resourceId)
        external onlyNFTHolder(resourceId) returns (bool success)
    {
        emit ResourceAccessed(resourceId, msg.sender);
        return true;
    }
}

权限验证的三层模型

第 1 层: 谁访问?           → msg.sender
第 2 层: 访问什么?          → resourceId → Resource(nftContract, requiredBalance)
第 3 层: 满足条件吗?        → nftContract.balanceOf(msg.sender) >= requiredBalance

资源 ID 的设计

使用 bytes32 作为资源 ID 而非 uint256string,有多个优势:

  • keccak256("VIP_ROOM") 生成确定性的 bytes32,方便跨合约引用
  • bytes32 在 storage 中占用一个完整的 32 字节槽,gas 计算简单
  • 可以预计算(链下生成后硬编码到前端),不依赖合约部署后的返回值

4. 遭遇的陷阱

  • 外部合约调用无防护resource.nftContract.balanceOf(user) 是一个外部调用——如果 nftContract 地址不是真正的 ERC-721 合约(或是恶意合约),它会 revert 或返回错误数据
  • 资源覆盖:创建资源时未检查 exists 标志,导致已存在的资源被意外覆盖——旧的权限配置丢失
  • resourceId 冲突:使用简单的字符串哈希作为 ID 时,不同管理员可能意外创建相同的资源 ID(如都用了 keccak256("VIP")
  • 仅检查余额。未检查特定 token ID:有些场景需要"持有 NFT #42"而非"持有至少 1 个任意 NFT"——基于 balanceOf 的检查无法满足这个需求
  • NFT 转移导致权限变更:用户铸造 NFT → 获得访问权 → 转出 NFT → 访问权未撤销。权限检查只在用户发起交易时进行(同步检查),而非持续监控(异步吊销)

5. 陷阱的原因

外部合约调用的风险源于 Solidity 的"信任外部接口"假设。当你调用 IERC721(nftContract).balanceOf(user) 时,实际执行的是目标地址的代码——这个地址可能是一个恶意合约,在 balanceOf 中执行任意代码。虽然 checkBalanceAccessview 函数(staticcall 上下文,不能修改状态),但恶意合约仍然可以:

  • 消耗大量 gas(DoS 攻击)
  • 返回精心构造但合法的数据(如永远返回 requiredBalance 或更高)
  • 在某些边条件下 revert(导致调用者无法通过权限检查)

资源覆盖是典型的缺少"幂等性保护"的问题。在分布式系统中,资源创建应该是幂等的——重复创建同一个资源 ID 应该要么无操作、要么明确报错。不检查 exists 意味着最后一次写入自动覆盖之前的,这可能导致权限意外降低(或提高)。

仅基于 balanceOf 的权限模型无法区分"我持有 1 个 #99(普通)"和"我持有 1 个 #1(传奇)"——两者在 balanceOf 看来是等价的。需要特定 token ID 的场景(如凭证 NFT,每个 token ID 代表一个特定的证书)需要 ownerOf(tokenId) 或使用 ERC-1155 的 balanceOf(account, id)

6. 如何解决陷阱

对外部合约调用增加防御层。在生产环境中,可以在资源创建时验证目标合约是否实现了 ERC-721 接口(通过 ERC-165 supportsInterface):

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

    // 可选:验证 ERC-721 兼容性(ERC-165 检查)
    // require(nftContract.supportsInterface(0x80ac58cd), "Not ERC721");

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

对于资源覆盖问题,使用 exists 标志位是实现幂等性的标准方法:

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

这确保了每个资源 ID 只能被创建一次。如果确实需要更新,应提供单独的 updateResource 函数并要求额外权限。

对于特定 token ID 的检查,可以扩展权限模型以支持两种模式:

solidity
struct Resource {
    IERC721 nftContract;
    uint256 requiredBalance;
    uint256 specificTokenId;   // 0 表示不检查特定 ID
    bool exists;
}

function checkAccess(address user, bytes32 resourceId) public view returns (bool) {
    Resource storage r = _resources[resourceId];
    if (r.specificTokenId != 0) {
        // 检查是否持有特定 token ID
        return r.nftContract.ownerOf(r.specificTokenId) == user;
    }
    // 否则检查余额
    return r.nftContract.balanceOf(user) >= r.requiredBalance;
}

对于 NFT 转移导致的权限变更,可以在 accessProtected 被调用时实时检查——这是纯同步权限模型的自然行为。如果需要"持续持有"的要求,可以添加持有时间记录和最小持有期限的检查。但要注意,链上时间戳只能精确到区块级别(~12 秒)。

7. 技术要点

要点说明
Token-gating 模式balanceOf(user) >= requiredBalance 判断权限
修饰器作为中间件onlyNFTHolder(resourceId) 封装权限逻辑,业务代码无侵入
资源存储mapping(bytes32 → Resource) — O(1) 查找
ERC-721 接口依赖通过 IERC721 接口调用,不依赖具体实现
外部调用风险目标合约可能恶意/不存在——考虑 ERC-165 验证
同步权限检查在函数调用时实时检查,不支持"持续监控"
resourceId 命名空间keccak256("MY_RESOURCE") 生成确定性 ID
跨合约组合权限合约 + NFT 合约 = 访问控制系统

Built with AiAda