之前学习的时候遇到了 permit 这个ERC20 方法,可以实现无gas 转账和离线交易。

听起来很神奇,但是实际上的原理并不复杂,在这里来进行一个总结

正文

什么是 Permit

permit 在 EIP-2612 中被引入到 ERC20 的协议中。子标题是 signed approvals ,另外从字面意思上看permit 在文字含义上是许可的意思,也正是与经典的erc20 协议里面 allowance 有关。

其功能从简单来讲,就是A在需要给B进行授权的时候,不需要主动的去调用 approval函数来给B进行授权。而是给这个approval 函数一个合法的签名,得到的签名提供给B,B用这个签名来调用 permit来获得对应额度的 allowance,从而可以对指定金额来进行消费或者转账。

这里用伪代码的approve函数以及 permit 函数进行对比,可见其核心的功能就是set一个对应的allowance

function approve(address usr, uint wad) external returns (bool)
{
  allowance[msg.sender][usr] = wad;
  …
}
function permit(
  address holder, address spender,
  uint256 nonce, uint256 expiry, bool allowed,
  uint8 v, bytes32 r, bytes32 s
) external {
  …
  allowance[holder][spender] = wad;
  …
}

permit应用场景

前面讲到permit,使用对approval的调用来进行签名,给到收款方来获得一个预先设置的 allowance的额度。

那么由此过程我们可以衍生出下面的用途

  1. 离线支付

    场景是A在离线的情况下用钱包签名 approval的方法,在签名的时候,带有 token/数量/收款人/deadline。签名之后把签出的内容给到B(可公开),B在有网络的情况下使用 permit的方法获取对应额度完成转账操作。

    但是值得注意的是:这里的转账过程不是可靠的,因为你拿到的只是对应的 allowence 的额度,实际上需要B进行permit 获得 allowance之后,再进行transferFrom 才可以得到对应的数量token。如果A在B执行这个权利之前把账号资产转走,那么B只是得到这个授权额度但是无法获得任何资产。这里可以类比于开出了一张空头支票

  2. 无gas转账

    不过需要提前明确的是,这里的无gas 不是指的没有gas 消耗,而是A方不需要为授权和转账来付出gas。一般的ERC20 的转账形式是,调用方调用 approval 和 transfer 来进行转账。在使用了 permit 的情况下,A 可以签名 Approval 函数,之后发送给B,b在获得签名之后去调用 Permit 来获得 allowance 使用 transferFrom 来进行转账。全部过程中不需要A支付任何GAS

实现原理

合约验签逻辑

在实现这里,直接找到 EIP-2612的commit 这里实现了 permit 函数

这里把参数分开

  • address owner // A地址
  • address spender // B地址
  • uint256 amount // 总额度
  • uint256 deadline // 过期时间
  • uint8 v, bytes32 r, bytes32 s // secp256k1(ECDSA) 的恢复ID 以及 RS 的签名输出。
    function permit(address owner, address spender, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s) public virtual override {
        // solhint-disable-next-line not-rely-on-time
        require(block.timestamp <= deadline, "ERC20Permit: expired deadline");
        // 对全部提供的内容按格式进行打包之后进行hash
        bytes32 structHash = keccak256(
            abi.encode(
            		// 这里限制了签名的类型,避免任意签名。
                _PERMIT_TYPEHASH,
                owner,
                spender,
                amount,
                _nonces[owner].current(),
                deadline
            )
        );
       	// 这里进行hash结构的转化
        bytes32 hash = _hashTypedDataV4(structHash);
				// 恢复对原始的签名数据来进行验签,看此内容验签之后的 signer 是不是传入数据的 owner
        address signer = ECDSA.recover(hash, v, r, s);
        require(signer == owner, "ERC20Permit: invalid signature");
				// 如果是
        _nonces[owner].increment();
        // 给permit调用者对应的授权额度
        _approve(owner, spender, amount);
    }

客户端签名逻辑

参考链接: Permit-712签名

这里有两个点

  • DOMAIN_SEPARATOR // 定义域分隔符
  • _PERMIT_TYPEHASH // Permit 的参数格式的hash

_PERMIT_TYPEHASH 定义见下:


bytes32 private immutable _PERMIT_TYPEHASH = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");

在客户端中使用,下面代码来获取合约的PERMIT_TYPEHASH 和 DOMAIN_SEPARATOR

const contract = new ClientContract(abi, '0x6b175474e89094c44da98b954eedeac495271d0f', 1)
    const calls = [
        contract.PERMIT_TYPEHASH(),
        contract.DOMAIN_SEPARATOR(),
    ]
    const [PERMIT_TYPEHASH, DOMAIN_SEPARATOR] = await multicallClient(calls)
	//DOMAIN_SEPARATOR: 0xdbb8cf42e1ecb028be3f3dbc922e1d878b963f411dc388ced501601c60f7c6f7
	//PERMIT_TYPEHASH: 0xea2aa0a1be11a07ed86d755c93467f4f82362b452371d1ba94d1715123511acb

之后构造签名合约里用到的structureHash

const digestHash = web3.utils.keccak256(web3.eth.abi.encodeParameters(['bytes32', 'address', 'address', 'uint256', 'uint256', 'uint256'], [
          PERMIT_TYPEHASH,
          holder,//你的地址
          spender,//授权给目标地址
		      amount,//你要授权的数量
          nonce,//你在DAI里面的nonce
          expiry,//授权到期时间
		  ]
      ))

对这个结构进行签名,之后发送给B,就完成了这个 permit 的签发。

const signatureHash = await web3.eth.sign(digest, account);

对Permit 原理上有大概的理解,些许有些复杂,不过好在应用上 openzeppelin 做了很大程度的封装。

在调用前端有 EIP712 的helper 直接引用即可,合约部分默认的 ERC20的合约已经包含了Permit 的方法。

研究底层的实现还算有趣。