如何将 ERC2612 的 Permit 方式应用到 ERC721 NFT 上。
- 原文链接:https://soliditydeveloper.com/erc721-permit
- 译文出自:登链翻译计划
- 译者:翻译小组 > 校对:Tiny 熊
- 本文永久链接:learnblockchain.cn/article…
ERC721-Permit(EIP-4494)让我们避免授权+转账 两步进行转账。
为了真正理解这是如何工作的,我建议你先看以下教程:
但我们也会尝试在这里覆盖基础知识。你可能已经熟悉 ERC20-Permit(EIP-2612)。它添加了一个新的permit
函数。用户可以在链下签署 ERC20 approve
交易,生成任何人都可以使用并提交给permit
函数的签名。当执行permit
时,它将执行approve
函数。这允许对 ERC20 转账进行元交易支持,但也简单地摆脱了需要两笔交易的麻烦:授权(approve)和转账。现在,你可以将签名提交给智能合约,该智能合约将在同一笔交易中调用permit
,然后调用transferFrom
。
我们面临的主要问题是,有效的签名可能会被多次使用,或者在不打算使用的其他地方使用。为了防止这种情况,我们正在添加几个参数。在幕后,我们正在使用已经存在且广泛使用的 EIP-712 标准。
好了,我知道这变得令人困惑,EIP-712 和 EIP-721 在一个标准中,但请跟上我。我们现在将始终将 EIP-721 称为 ERC-721,以使其更容易理解。
使用 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 的列表可以在此处查看。
现在,我们可以创建一个特定的 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);
}
现在,我们可以构建以 0x1901 开头的最终签名,用于 EIP-191 兼容的 712 哈希:
bytes32 hash = keccak256(
abi.encodePacked("\x19\x01", eip712DomainHash, hashStruct)
);
使用此哈希,我们可以使用 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。这将解决一些问题:
然后,如果签名验证恢复了一个地址,我们检查它是否是 NFT 的所有者,或者他是否已获授权。当然,你可以只允许所有者,但由于 ERC721 中获得授权的地址应该具有完全控制权,因此最好也为授权的地址授予控制权。
迄今为止验证是不是有效的 EOA,我们最后还可以检查它是否是有效的合约签名。由于合约可以直接是所有者或已获授权,因此我们需要使用 staticcall 功能来检查这两种情况。
请注意,如果合约是通过 ERC721.setApprovalForAll 获得授权的,我们将无法验证其签名并允许其使用 permit。但是,一个已获授权的合约可以首先使用 ERC721.approve 来授权自己。
最后,我们只需要增加所有者的 nonce 并调用 approve 函数:
_approve(owner, spender, amount);
你可以在此处查看完整的实现示例。
Uniswap 实现有一些不同之处,请查看实现。在 Uniswap V3 中,仓位包装在 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 构建可信履历,为自己码一个未来。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!