离线授权 NFT EIP-4494:ERC721 -Permit

如何将 ERC2612 的 Permit 方式应用到 ERC721 NFT 上。

ERC721-Permit(EIP-4494)让我们避免授权+转账 两步进行转账。

为了真正理解这是如何工作的,我建议你先看以下教程:

但我们也会尝试在这里覆盖基础知识。你可能已经熟悉 ERC20-Permit(EIP-2612)。它添加了一个新的permit函数。用户可以在链下签署 ERC20 approve交易,生成任何人都可以使用并提交给permit函数的签名。当执行permit时,它将执行approve函数。这允许对 ERC20 转账进行元交易支持,但也简单地摆脱了需要两笔交易的麻烦:授权(approve)和转账。现在,你可以将签名提交给智能合约,该智能合约将在同一笔交易中调用permit,然后调用transferFrom

许可证警察

ERC721-Permit:防止滥用和重放

我们面临的主要问题是,有效的签名可能会被多次使用,或者在不打算使用的其他地方使用。为了防止这种情况,我们正在添加几个参数。在幕后,我们正在使用已经存在且广泛使用的 EIP-712 标准。

好了,我知道这变得令人困惑,EIP-712 和 EIP-721 在一个标准中,但请跟上我。我们现在将始终将 EIP-721 称为 ERC-721,以使其更容易理解。

EIP712 vs. EIP721 Meme

1. EIP-712 域哈希

使用 EIP-712,我们为我们的 ERC-721 定义了一个域分隔符。

bytes32 eip712DomainHash = keccak256(
    abi.encode(
        keccak256(
            "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
        ),
        keccak256(bytes(name())), // ERC-721 Name
        keccak256(bytes("1")),    // Version
        block.chainid,
        address(this)
    )
);

这确保了签名仅用于我们给定的代币合约地址和正确的链 ID。chain ID 是在以太坊经典分叉后引入的,该分叉继续使用网络 ID 1。现有链 ID 的列表可以在此处查看。

2. Permit 哈希结构

现在,我们可以创建一个特定的 Permit 签名:

bytes32 hashStruct = keccak256(
    abi.encode(
        keccak256("Permit(address spender,uint256 tokenId,uint256 nonce,uint256 deadline)"),
        spender,
        tokenId,
        nonces[tokenId],
        deadline
    )
);

这个哈希结构将确保签名仅用于

  • permit函数
  • spender授权
  • 授权给定的tokenId
  • 仅在给定的deadline之前有效
  • 仅对给定的nonce有效

nonce 确保某人无法重放签名,即在同一合约上多次使用它。现在,它基于tokenId的基础工作,而不是 ERC721 所有者地址,正如你所看到的,这是与 ERC20-Permit 的第一个真正的区别。而且nonce 仅在转移 ERC721 时递增,而不是在调用 permit 时递增。为什么呢?

因为使用 Permit 和 NFT,你实际上有一个与 ERC20 不可能的独特功能机会。你可以允许用户为同一tokenId创建多个 spender 地址的许可签名。所有 spender 都可以使用相同的 nonce 执行 permit 函数,因为我们在 permit 内部不递增 nonce。只有当 NFT 实际转移时,nonce 才会递增,使旧的签名无效。因此,请确保在你的 ERC721 合约中为每次转移增加 nonce:

function _transfer(address from, address to, uint256 tokenId) internal override {
    _nonces[tokenId]++;
    super._transfer(from, to, tokenId);
}

3. 最终哈希

现在,我们可以构建以 0x1901 开头的最终签名,用于 EIP-191 兼容的 712 哈希:

bytes32 hash = keccak256(
    abi.encodePacked("\x19\x01", eip712DomainHash, hashStruct)
);

4. 验证签名

使用此哈希,我们可以使用 ecrecover 来检索函数的签名者。但现在我们来到了与 ERC20-Permit 的下一个重大区别。这里的签名是一个哈希,而不是通常使用的 v、r、s 签名值。我们将在一秒钟内了解原因,但首先在正常情况下,你需要使用汇编来恢复 v、r、s 值,然后将它们用于 ecrecover:

bytes32 r;
bytes32 s;
uint8 v;
assembly {
    r := mload(add(signature, 0x20))
    s := mload(add(signature, 0x40))
    v := byte(0, mload(add(signature, 0x60)))
}

address signer = ecrecover(hash, v, r, s);
require(signer == owner, "ERC20Permit: invalid signature");
require(signer != address(0), "ECDSA: invalid signature");

无效的签名将产生一个空地址,这就是最后一个检查的目的。那么为什么我们将签名作为字符串传递,而不是作为 v、r、s 值呢?将签名作为字符串传递实际上允许最大的灵活性。如果签名只是一个字符串,那么将其验证扩展到不仅仅是常规的 ecrecover 检查是相当容易的。实际上,在 ERC721-Permit 中已经包括了两个额外的签名验证:

EIP-2098是签名的紧凑形式标准。它利用了数学技巧表示椭圆曲线上的签名。这里的细节并不重要,但基本上,与要求 65 字节的签名相比,你可以用 64 字节表示它。但它需要稍有不同的签名验证。但无论你使用 64 还是 65 字节,显然在这两种情况下,你都可以将签名表示为字符串。太好了。

然后EIP-1271,我们已经在这里看过了用于元交易。但它本质上是智能合约创建签名的一种方式。在我们的情况下,想象一下智能合约是 NFT 的所有者。通常,智能合约无法创建签名,因为没有私钥。在验证签名并且它无效时,我们可以在第二步检查它是否是有效的智能合约签名:

我们使用staticcall调用 NFT 的所有者地址,这确保调用中不会发生进一步的状态修改。如果结果成功并且具有有效的 returnData 长度( 这非常关键,请参阅之前的 0x 漏洞 ),我们可以检查返回值是否与0x1626ba7e匹配,这是 EIP-1271 中的魔术值,意味着“这是有效的签名”。智能合约如何验证签名取决于合约决定。

最后,如果你阅读了我关于ecrecover的帖子这里 ,你将知道它存在一些问题。

完整的签名验证

因此,你应该正确处理这些问题。幸运的是,当使用 Openzeppelin 合约时,执行所有这些操作比听起来要容易。ERC721-Permit 的完整签名验证可能如下所示。

(address signer, ) = ECDSA.tryRecover(hash, signature);
bool isValidEOASignature = signer != address(0) &&
    _isApprovedOrOwner(signer, tokenId);

require(
    isValidEOASignature ||
    _isValidContractERC1271Signature(
        ownerOf(tokenId),
        hash, signature
    ) || _isValidContractERC1271Signature(
        getApproved(tokenId),
        hash,
        signature
    ),
    "ERC721Permit: invalid signature"
);

function _isValidContractERC1271Signature(
    address signer,
    bytes32 hash,
    bytes memory signature
) private view returns (bool) {
    (bool success, bytes memory result) = signer.staticcall(
        abi.encodeWithSelector(
        IERC1271.isValidSignature.selector,
        hash,
        signature
        )
    );
    return (success &&
        result.length == 32 &&
        abi.decode(result,(bytes4))
            == IERC1271.isValidSignature.selector
    );
}

首先,我们使用 ECDSA.tryRecover。这将解决一些问题:

  • 支持 EIP-2098 签名。
  • 解决 ecrecover 可能存在的安全问题。

然后,如果签名验证恢复了一个地址,我们检查它是否是 NFT 的所有者,或者他是否已获授权。当然,你可以只允许所有者,但由于 ERC721 中获得授权的地址应该具有完全控制权,因此最好也为授权的地址授予控制权。

迄今为止验证是不是有效的 EOA,我们最后还可以检查它是否是有效的合约签名。由于合约可以直接是所有者或已获授权,因此我们需要使用 staticcall 功能来检查这两种情况。

请注意,如果合约是通过 ERC721.setApprovalForAll 获得授权的,我们将无法验证其签名并允许其使用 permit。但是,一个已获授权的合约可以首先使用 ERC721.approve 来授权自己。

5. 授权 NFT

最后,我们只需要增加所有者的 nonce 并调用 approve 函数:

_approve(owner, spender, amount);

你可以在此处查看完整的实现示例

Uniswap ERC721-Permit

Uniswap 实现有一些不同之处,请查看实现。在 Uniswap V3 中,仓位包装在 ERC721 接口中,并具有 permit 功能。

然而,值得注意的区别是:

  • 签名通过 v、r、s 值传递
  • 每次 permit 执行时 nonce 会递增,不允许同时执行多个 permit

Uniswap Permit

ERC721-Permit 库

我创建了一个 ERC-721 Permit 库,你可以导入。你可以在 https://github.com/soliditylabs/ERC721-Permit 找到它。

使用参考实现作为参考,以其原始用途使用。

你可以通过 npm 安装它:

$ npm install @soliditylabs/erc721-permit --save-dev

像这样将其导入到你的 ERC-721 合约中:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {ERC721, ERC721Permit} from "@soliditylabs/erc20-permit/contracts/ERC20Permit.sol";

contract MyNFTContract is ERC721Permit("MyNFT", "MNFT") {
  uint256 private _lastTokenId;

  function mint() public {
    _mint(msg.sender, ++_lastTokenId);
  }

  function safeTransferFromWithPermit(
    address from,
    address to,
    uint256 tokenId,
    bytes memory _data,
    uint256 deadline,
    bytes memory signature
  ) external {
    _permit(msg.sender, tokenId, deadline, signature);
    safeTransferFrom(from, to, tokenId, _data);
  }
}

我们还在这里添加了一个safeTransferFromWithPermit函数。这不是 ERC721-Permit 的标准函数,但它仍然可以是一个非常有用的补充。通过这样做,你可以在单个调用中授权和转账,节省一些额外的 gas,并且不需要任何辅助合约。

前端使用

通过 EIP-712 签署数据在许多钱包中得到直接支持。例如,在 MetaMask 中,可以查看 这里 如何集成它。

始终谨慎使用

请注意,EIP4494 标准尚未最终确定。实际上,它处于相对早期的草案阶段。如果标准再次更改,我将保持库的更新。我的库代码也没有经过审计,请自行承担风险。


本翻译由 DeCert.me 协助支持, 在 DeCert 构建可信履历,为自己码一个未来。

点赞 1
收藏 1
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

1 条评论

请先 登录 后评论
翻译小组
翻译小组
0x9e64...7c84
大家看到好的文章可以在 GitHub 提 Issue: https://github.com/lbc-team/Pioneer/issues 欢迎关注我的 Twitter: https://twitter.com/UpchainDAO