前
之前学习的时候遇到了 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的额度。
那么由此过程我们可以衍生出下面的用途
-
离线支付
场景是A在离线的情况下用钱包签名 approval的方法,在签名的时候,带有 token/数量/收款人/deadline。签名之后把签出的内容给到B(可公开),B在有网络的情况下使用
permit
的方法获取对应额度完成转账操作。但是值得注意的是:这里的转账过程不是可靠的,因为你拿到的只是对应的 allowence 的额度,实际上需要B进行permit 获得 allowance之后,再进行transferFrom 才可以得到对应的数量token。如果A在B执行这个权利之前把账号资产转走,那么B只是得到这个授权额度但是无法获得任何资产。这里可以类比于开出了一张空头支票。
-
无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 的方法。
研究底层的实现还算有趣。