如何实现 Permit2

  • cyfrin
  • 更新于 3天前
  • 阅读 261

这是一种与所有 ERC-20 代币兼容的代币批准系统,简化了用户体验并减少了他们的经济负担。

学习如何实现 Permit2,这是一种与所有 ERC-20 代币兼容的代币批准系统,简化了用户体验并减少了他们的经济负担。

本文旨在回顾代币批准系统的历史,并介绍现代的 Permit2 技术。回顾过去的系统是理解和欣赏 Permit2 提供的功能的前提。如果读者已经对代币批准和 EIP-2612 感到熟悉,他们可能想直接跳到 Permit2 部分 。

在加密领域,用户很难在不与需要权限以代表他人转移代币的 dApp 互动的情况下走得太远。授予此权限的唯一方法是通过批准。所有权限解决方案都需要将用户特定代币和受信任的支出者的 allowance 映射更改为非零金额。这最终通过执行所有 ERC20 代币中固有的内部 _approve() 函数来完成。

Permit2 是一种与所有 ERC-20 代币兼容的代币批准系统,简化了用户体验并减少了他们的经济负担。它将繁重的工作转移到智能合约上。用户只需签署一条无 gas 的链下消息以表达他们修改权限的意图。这意味着 dApp 可以处理用户转移代币所需的所有内部批准机制。

权限的谦卑起源

图示早期的许可功能就像生活在石器时代。

石器时代 - 在 Permit 之前

一个 解决方案无需额外的代码或特殊技术。它是标准的代币批准过程,工作原理如下:

当与需要权限转移代币的 dApp 互动时,用户将调用其代币的公共 approve() 函数,以增加 dApp 合约(支出者)对其代币的 allowance 值。

用户必须是执行此交易的地址,因为公共 approve() 函数将所有者设置为 msg.sender。只有在交易在链上确认后,dApp 支出者才能成功调用 transferFrom() 函数以代表用户转移资金。

由于需要两个不同的参与者来执行这两个交易,因此该过程明确要求完成两个链上交易。

//OpenZeppelin ERC20
//https://github.com/OpenZeppelin/openzeppelin-contracts/blob/c304b6710b4b5fcf2a319ad28c36c49df6caef14/contracts/token/ERC20/ERC20.sol#L128
function approve(
    address spender,
    uint256 value
) public virtual returns (bool) {
    // explicitly sets the owner as msg.sender
    // for the actual `_approve()` call.
    address owner = _msgSender();
    // `allowance` mapping gets updated for
    // the spender and amount on behalf of the owner.
    _approve(owner, spender, value);  
    return true;
}

这种方法很简单,但转移资金需要两个交易,这很麻烦。此外,对于与许多 dApp 互动的用户来说,存在较大的攻击面。

用户授予权限的每个 dApp 都将有一个非零的 allowance 映射,持续时间无限;除非他们通过另一个链上 approve() 调用手动撤销该 dApp 的权限回到零。

如果任何已批准的 dApp 在用户批准的代币方面受到损害,用户将失去与该地址相关的所有代币。

EIP-2612 的一小步

图示随着 permit() 函数的引入,许可功能进入了铁器时代。

铁器时代 - 接近 permit2

两个 解决方案通过 EIP-2612 扩展了标准的代币批准标准。

值得注意的是,EIP-2612 引入了 permit() 函数。

正如读者可能猜到的,理解 permit() 的工作原理至关重要。它的输入可以分为两部分:允许参数 {owner, spender, value, deadline} 和签名参数 {v, r, s},它们表示表达消息数据与在签名过程中使用的私钥之间加密关系的 椭圆曲线 点。

具体来说,

  • `r` 与用户的私钥和签名过程中生成的随机数相关。
  • `s` 与私钥、`r` 和消息哈希的组合相关。
  • `v` 是一个单字节,防止签名可变性,通过指定使用两个有效椭圆曲线解中的哪一个。

permit() 函数应执行四项操作:

  1. 验证签名的截止日期未过期。
  2. 提取与签名点 (v, r, s) 相关的签名者地址,以及通过 ecrecover() 打包的允许详细信息。
  3. 验证输入的 owner 参数与提取的签名者地址匹配。请求允许的签名者必须是代币的所有者!
  4. 代表用户调用代币的内部 _approve() 函数,以满足他们对受信任支出者和金额的意图。
// OpenZeppelin ERC20Permit.sol
// https://github.com/OpenZeppelin/openzeppelin-contracts/blob/c304b6710b4b5fcf2a319ad28c36c49df6caef14/contracts/token/ERC20/extensions/ERC20Permit.sol#L44
function permit(
    address owner,
    address spender,
    uint256 value,
    uint256 deadline,
    uint8 v,
    bytes32 r,
    bytes32 s
) public virtual {

    // deadline check
    if (block.timestamp > deadline) revert ERC2612ExpiredSignature(deadline);

    // hash of approval data
    bytes32 structHash = keccak256(abi.encode(
        PERMIT_TYPEHASH,
        owner,
        spender,
        value,
        _useNonce(owner),
        deadline
    ));

    // EIP-712 hash of hashed approval data
    bytes32 hash = _hashTypedDataV4(structHash);

    // signer extraction
    address signer = ECDSA.recover(hash, v, r, s);

    // signer must be owner
    if (signer != owner) revert ERC2612InvalidSigner(signer, owner);

    // internal approval call
    _approve(owner, spender, value);
}

如果没有高效的密码曲线和从消息数据及签名点恢复签名者地址的能力,这一切都不可能实现。

那么真正的问题来了……为什么这些技术人员如此兴奋?其中的美在哪里?

值得注意的是,完成批准和转移过程所需的链上参与者总数从 两个 减少到 一个。因为用户通过链下签名表达修改其 allowance 映射的意图,他们无需接触链,且其批准操作是 无 gas 的

用户的唯一责任是生成所需的批准数据,并将其传递给支出者,以便支出者能够迅速处理一切。

重申一下,用户(所有者)不需要是调用 approve() 的行为者。支出者可以利用 permit() 代表用户处理给定的批准数据,然后调用 transferFrom() 转移资金,所有这些都在一个交易中完成。

由于 owner 参数在 permit() 中被明确清理为具有正确批准数据的 signer,任何人都可以调用 permit(),但只有有效的授权更新才能成功执行。

不仅将交易数量从两个减少到一个,而且该技术还包括可能过期的授权截止日期。这降低了在 dApp 被利用的情况下资金损失的可能性,并消除了在最初表达意图后很久可能发生的意外支出者交易。

缺点是该技术缺乏向后兼容性,因为它是 ERC-20 标准的扩展。只有包含 EIP 的未来代币或选择升级的历史代币才能受益于该功能。

Permit2 - 欢迎加入派对

图示说明引入 Permit2 后,开发者们现在生活在现代时代。

现代 Permit2

第三个解决方案,最初称为 PermitEverywhere,是由 MerkleJerk 创建的。

Uniswap 注意到了这个想法,调整了一个解决方案,并将其命名为 Permit2。Permit2 享有与 EIP-2612 相同的所有好处,使用 permit() 概念作为核心,同时解决了向后兼容性的问题。这个 扩展了所有与集成了 Permit2 的 dApp 交互的 ERC-20 代币的能力。

Permit2 并不强迫 ERC-20 代币本身扩展 EIP-2612 以享受这些好处,而是将概念抽象为一个独立的合约系统。这 允许 进行 通用的授权跟踪和签名验证,使一切成为可能。

Permit2 的名称反映了与 Permit2 合约交互以实现授权转移的两种方式。尽管授权和基于签名的转移之间存在区别,但这两种交互类型都使用签名:

  1. 基于授权的转移:通过签名处理代币授权,转移检查允许的金额。这是在预期多次转移时更高效的解决方案。
  2. 基于签名的转移:直接通过签名处理代币转移。对于一次性转移更高效。

permit2 是如何工作的?

  1. 用户的先决步骤。
    • 他们必须对其代币进行传统的批准,以便将其发送到 Permit2 合约。
    • 通常以 uint256 最大值进行,只需用户为其代币执行一次。
    • 一旦完成,任何与 Permit2 集成的 dApp 只需请求用户的链下签名即可利用已授予的权限。
  2. Permit2 获得用户代币批准后的操作。
    • 用户通过链下签名表达其允许特定 dApp 支出者合约移动其代币的意图。
    • 支出者充当快递员,将意图传递给 Permit2 合约,可以视为支出者与用户之间的守门人或中介。
    • 如果 Permit2 合约验证签名并明确要求正确的数据,则将使用预先批准的授权代表用户将代币转移给支出者。
    • 一旦支出者收到代币,它可以执行用户请求的必要操作。

Permit2 有一些独特的缺点。首先,先决步骤迫使用户批准其代币到 Permit2 合约,这对用户体验和采用造成了静态摩擦。其次,攻击面较窄,直接指向 Permit2 合约。好消息是这些合约简洁、编写良好、经过测试和审计。

与 Permit2 集成

银河系的图像,以有趣的方式说明读者是一个小点,正在这个大宇宙中“学习晦涩的代币批准技术”。

这是一个 Sepolia 模拟集成,演示了与 Permit2 集成的不同方式。所有 Solidity 代码将明确显示,而前端 JavaScript 代码将仅简要说明。整个代码库可以在这里找到:https://github.com/alexbabits/permit2-example

注意:Uniswap Permit2 SDK 如果与 ethers.js v6 一起使用,将无法实例化 AllowanceProvider。如果使用 SDK,必须使用 ethers v5.7.2。值得注意的是,ethers v5 不支持与 Alchemy RPC 提供者的 Sepolia,因此需要另一个支持 Sepolia 的提供者。此示例使用 Infura 作为 RPC 提供者。

首先通过 Foundry 安装 permit2 Uniswap GitHub 仓库作为依赖项,导入必要的接口,并通过构造函数设置对 Permit2 的引用。

// Permit2App.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;

import {IPermit2, IAllowanceTransfer, ISignatureTransfer } from "permit2/src/interfaces/IPermit2.sol";

contract Permit2App {

    IPermit2 public immutable permit2;

    error InvalidSpender();

    constructor(address _permit2) {
        permit2 = IPermit2(_permit2);
    }

// ...
}

通过授权转移实现 permit2

授权转移技术要求我们在可以代表用户转移资金之前,通过调用 permit2.permit() 更新 Permit2 合约中的 allowance 映射。一旦完成,支出者可以根据需要自由调用 permit2.transferFrom() 来移动代币,只要原始权限未过期且转移金额的总和不超过允许的授权。

重申一下,在对特定用户调用了 permit2.permit() 及特定授权数据后,除非需要,否则再次调用是多余的。值得注意的是,permit() 调用会为每个签名增加与特定所有者、代币和支出者相关联的 nonce,以防止双重支出类型的攻击。

以下演示了授权转移集成,包含和不包含调用 permit() 的情况。

// Permit2App.sol (继续)

    // 当尚未调用 permit 或需要刷新时的授权转移。
    function allowanceTransferWithPermit(
        IAllowanceTransfer.PermitSingle calldata permitSingle,
        bytes calldata signature,
        uint160 amount
    ) public {
        _permitWithPermit2(permitSingle, signature);
        _receiveUserTokens(permitSingle.details.token, amount);
    }
        /**
         * 允许在已调用 permit 且未过期且在允许金额内时进行的转账。
         * 注意:`permit2._transfer()` 执行
         * 所有必要的安全检查,以确保
         * 支出者的允许映射
         * 未过期且在允许金额内。
         */
        function allowanceTransferWithoutPermit(address token, uint160 amount) public {
            _receiveUserTokens(token, amount);
        }

        // 调用 `permit2.permit()` 的辅助函数
        function _permitWithPermit2(
            IAllowanceTransfer.PermitSingle calldata permitSingle,
            bytes calldata signature
        ) internal {
            // 本合约必须拥有用户的支出权限。
            if (permitSingle.spender != address(this)) revert InvalidSpender();

            // owner 明确为 msg.sender
            permit2.permit(msg.sender, permitSingle, signature);
        }

        // 调用 `permit2.transferFrom()` 的辅助函数
        // 将允许的代币从用户转移到支出者(我们的合约)
        function _receiveUserTokens(address token, uint160 amount) internal {
            permit2.transferFrom(msg.sender, address(this), amount, token);
        }

        // 注意:有批量版本的允许转账,允许在一个交易中处理多个代币和/或目标。

相应的前端设置如下:

  • Uniswap Permit2 SDK 安装和有用文件的导入:AllowanceTransfer, SignatureTransfer, PERMIT2_ADDRESS, MaxAllowanceTransferAmount。
  • 签名者的实例化(使用开发者私钥)。
  • 必须实例化 Permit2App 合约、示例代币合约和 Permit2 合约。 
  • 必须通过简单的 approve() 调用执行一次性初始化步骤,以批准 Permit2 合约使用用户的示例代币。

一旦用户批准了他们的代币用于 Permit2 合约,我们必须准备调用我们的 allowanceTransferWithPermit() 函数所需的参数:

  • 构建 permitSingle 对象,详细说明所有的允许数据。
  • 使用 AllowanceTransfer.getPermitData() 从我们的 permitSingle 对象获取 EIP-712 结构化返回数据。
  • 通过 _signeTypedData() 签署返回的结构化许可数据。
  • 使用 permitSingle 对象、签名和用户想要转移的金额调用我们的函数。

对于调用 allowanceTransferWithoutPermit(),不需要 permitSingle 对象。只需直接调用所需金额即可。

通过签名转账实现 permit2

签名转账技术提供了一种不同的集成 Permit2 的方法。我们可以立即调用 permitTransferFrom(),只要签名和许可数据成功验证,而不是在 Permit2 中更改 allowance 映射。这在状态更新较少的情况下更节省 gas,并且最适合不期望进行多次转账的情况。

重要的是,与特定权限请求相关的签名不能重复使用,因为在转账完成后,相关的 nonce 会从 0 翻转为 1。 

值得注意的是,没有方法可以像允许转账那样“获取当前 nonce”,因为 nonce 以无序的方式存储为位于位图中。你可以在前端以任何方式生成 nonce,只要生成技术不会导致冲突。递增或随机(具有足够大的范围)是两种有效的方法。 

此外,可以将称为“见证”的自定义数据添加到签名中。见证数据可以通过 permitWitnessTransferFrom() 函数传递。这在使用中继者或指定自定义订单详细信息时非常有用。见证数据的额外复杂性在于它必须非常精确地处理。见证数据需要创建一个自定义见证结构,以及相关的类型字符串和类型哈希。

下面展示了一个普通的签名转账函数和一个包含额外见证数据的函数。

    /// 普通签名转账
        function signatureTransfer(
            address token,
            uint256 amount,
            uint256 nonce,
            uint256 deadline,
            bytes calldata signature
        ) public {
            permit2.permitTransferFrom(
                // 许可消息。支出者是调用者(本合约)
                ISignatureTransfer.PermitTransferFrom({
                    permitted: ISignatureTransfer.TokenPermissions({
                        token: token,
                        amount: amount
                    }),
                    nonce: nonce,
                    deadline: deadline
                }),
                ISignatureTransfer.SignatureTransferDetails({
                    to: address(this),
                    requestedAmount: amount
                }),
                msg.sender, // 代币的所有者必须是签名者
                signature // 根据 EIP-712 标准签署许可数据哈希后生成的签名
            );
        }

        // `signatureTransferWithWitness()` 所需的状态。
        // 不常规地放置在这里,以免使其他示例混乱。
        struct Witness {
            address user;
        }

        // 带见证的完整类型字符串,
        // 注意结构体是按字母顺序排列的:
        // "PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,Witness witness)TokenPermissions(address token,uint256 amount)Witness(address user)"

        // 然而,我们只想保留剩余的 EIP-712 结构化类型定义,
        // 从见证开始。
        string constant WITNESS_TYPE_STRING = "Witness witness)TokenPermissions(address token,uint256 amount)Witness(address user)";

        // 类型哈希必须对我们创建的见证结构进行哈希。
        bytes32 constant WITNESS_TYPEHASH = keccak256("Witness(address user)");

        // 带额外见证数据的签名转账技术
        function signatureTransferWithWitness(
            address token,
            uint256 amount,
            uint256 nonce,
            uint256 deadline,
            address user, // 示例额外见证数据
            bytes calldata signature
        ) public {
            bytes32 witness = keccak256(abi.encode(WITNESS_TYPEHASH, Witness(user)));

            permit2.permitWitnessTransferFrom(
                ISignatureTransfer.PermitTransferFrom({
                    permitted: ISignatureTransfer.TokenPermissions({
                        token: token,
                        amount: amount
                    }),
                    nonce: nonce,
                    deadline: deadline
                }),
                ISignatureTransfer.SignatureTransferDetails({
                    to: address(this),
                    requestedAmount: amount
                }),
                msg.sender, // 代币的所有者必须是签名者
                witness, // 检查签名时要包含的额外数据
                WITNESS_TYPE_STRING, // 剩余字符串存根的 EIP-712 类型定义
                signature // 根据 EIP-712 标准签署许可数据哈希后生成的签名
            );
        }

// 注意:有批量版本的签名转移,允许在一个交易中处理多个代币和/或目标。

相应的前端设置如下:

  • 需要与允许转移前端部分中找到的相同实例化和 Permit2 代币批准。在此之后,大多数核心任务与允许转移相似。
  • 构建 permit 对象,详细说明所有的允许数据。必须生成非重复的随机数。
  • 如果需要额外的见证数据,则构建 witness 对象。
  • 使用 SignatureTransfer.getPermitData() 从我们的 permit 对象获取 EIP-712 结构化返回数据。
  • 使用 _signeTypedData() 签署返回的结构化许可数据。
  • 如果适用,使用权限数据、签名和见证数据调用我们的函数。

随着这个 Permit2 代码集成示例的结束,今天的旅程也到此为止。希望这能对 Permit2 的内部工作原理提供足够的了解,以便开发者能够为用户提供更好的代币批准体验,而白帽子可以保持集成协议的安全。

祝你黑客愉快,构建顺利!

我是 AI 翻译官,为大家转译优秀英文文章,如有翻译不通的地方,在这里修改,还请包涵~

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

0 条评论

请先 登录 后评论
cyfrin
cyfrin
Securing the blockchain and its users. Industry-leading smart contract audits, tools, and education.