什么是元交易
简单来说,元交易(meta transaction)就是由第三方代理用户发送的交易。
元交易的流程如下:
- 用户构建交易参数,对交易参数签名
- 第三方将交易签名发送至 Relay/Forwarder 合约
- Relay/Forwarder 合约验证用户的签名是否相符
- Relay/Forwarder 调用最终的合约
普通的交易如下图所示:
元交易的如下图所示:
注: 图中的 Relayer 就是本文的 Forwarder 合约
元交易可以用来做什么
元交易大体有两个用途:
- 我们知道,去中心化的体验门槛很高,普通用户要玩去中心化,起码需要: a. 下载交易所,充币,认证,购买以太坊 b. 安装metamask,记住助记词,生吃地址; c. 交易所提币; d. 了解去中心化dapp, 理解原理, 使用dapp
上述任何一个步骤都非常麻烦,一整套流程走下来,半天就过去了
因此,使用元交易,可以让用户没有以太,就可以体验 dapp
- 安全
假如你有冷钱包,里面有很多钱,你不想让冷钱包直接触网,这时,你可以使用这种方式,让 Relayer/Forwarder 合约作为你的代理发送交易。
如何实现元交易
我们构建这样的一个数据结构:
struct ForwardRequest {
// 用户地址
address from;
// 用户要调用的合约地址
address to;
// 交易发送的以太数量
uint256 value;
// 设置的gas费, 可以不需要
uint256 gas;
// Forwarder合约中记录的用户的nonce, 防止重放攻击
uint256 nonce;
// 用户调用的函数和参数
bytes data;
}
用户签名
待签名数据的构建基于 EIP-712
, 如下:
/// @notice Returns a hash of the given data, prepared using EIP712 typed data hashing rules.
/// @param from origin sender
/// @param to contract to call
/// @param value send ETH value
/// @param nonce from's nonce
/// @param data encodewithselector contract call params
/// @return digest hash digest
function getDigest(
address from,
address to,
uint256 value,
uint256 nonce,
bytes memory data
) public view returns (bytes32 digest) {
digest = _hashTypedDataV4(
keccak256(abi.encode(_TYPE_HASH, from, to, value, nonce, keccak256(data))));
}
函数 _hashTypedDataV4
是根据 EIP-712
实现的, 代码如下:
function _hashTypedDataV4(bytes32 structHash) internal view virtual returns (bytes32) {
return ECDSAUpgradeable.toTypedDataHash(_domainSeparatorV4(), structHash);
}
function _domainSeparatorV4() internal view returns (bytes32) {
return _buildDomainSeparator(_TYPE_HASH, _EIP712NameHash(), _EIP712VersionHash());
}
function _buildDomainSeparator(
bytes32 typeHash,
bytes32 nameHash,
bytes32 versionHash
) private view returns (bytes32) {
return keccak256(abi.encode(typeHash, nameHash, versionHash, block.chainid, address(this)));
}
其中,_EIP712NameHash _EIP712VersionHash 是合约创建时设置的
然后, 使用用户的私钥对上面算出来的 digest
签名:
import { utils } from 'ethers'
const userkey = new utils.SigningKey(privateKey)
, sig = userkey.signDigest(digest)
, sigs = utils.joinSignature(sig)
合约验证签名合法性
验证签名的合法性基于两点:
ecrecover 从digest,签名中解出的地址与用户地址一致
nonce ForwardRequest.req 结构体中的 nonce 与合约中记录的nonce一致
验签代码如下:
using ECDSAUpgradeable for bytes32;
function verify(ForwardRequest calldata req, bytes calldata signature) public view returns (bool) {
bytes32 digest = getDigest(req.from, req.to, req.value, req.nonce, req.data);
address signer = digest.recover(signature);
return _nonces[req.from] == req.nonce && signer == req.from;
}
msg.sender 的问题
当 Forwarder 合约最终调用 to 合约时,to 合约中使用 msg.sender
时, msg.sender
值为 Forwarder 合约地址。如果需要在 to 合约中使用用户地址,则需要做一些修改。
首先,Forwarder 合约调用 to 合约时,已经将用户的地址附加在调用参数的后面。to 合约的被调用函数并不需要知道这个参数的存在,因为 to 合约的函数取哪些参数,如何获取这些参数是在合约编译时,已经确定了, to 合约只是按照偏移量去 sload 数据. 在最后增加一个参数不会影响原来参数的获取,也不会像c/c++那样破坏堆栈。
Forwarder合约的执行:
(bool success, bytes memory returndata) = req.to.call{value: req.value}(
abi.encodePacked(req.data, req.from)
);
这样,Forwarder 合约就把用户的地址传给了 to 合约,剩下的就交给 to 合约了。
然后,to 合约要怎么得到用户地址呢?
我们先来看看 openzeppelin 的 Context
合约:
abstract contract Context {
function _msgSender() internal view virtual returns (address) {
return msg.sender;
}
function _msgData() internal view virtual returns (bytes calldata) {
return msg.data;
}
}
这是一个非常基础的合约,很多合约就是基于 Context
合约. 最开始看到这两个函数时,我非常迷惑, _msgSender()
就是简单的返回 msg.sender
, 有什么作用呢?实际上,绝大部分时候,我们都是直接使用 msg.sender
, 很少调用 _msgSender()
。
在处理元交易时, _msgSender()
的作用就体现出来了, 我们可以重写 _msgSender
函数,来得到 用户地址,当然,前提是 to 合约需要配置 Forwarder 合约的地址。
在 to 合约中, 重写 _msgSender
函数代码如下:
address private _trustedForwarder;
function isTrustedForwarder(address forwarder) public view virtual returns (bool) {
return forwarder == _trustedForwarder;
}
function _msgSender() internal view virtual override(ContextUpgradeable) returns (address sender) {
if (isTrustedForwarder(msg.sender)) {
// The assembly code is more direct than the Solidity version using `abi.decode`.
assembly {
sender := shr(96, calldataload(sub(calldatasize(), 20)))
}
} else {
return msg.sender;
}
}
function _msgData() internal view virtual override(ContextUpgradeable) returns (bytes calldata) {
if (isTrustedForwarder(msg.sender)) {
return msg.data[:msg.data.length - 20];
} else {
return msg.data;
}
}
这样, 就完成了对 to
合约的修改。 openzeppelin 的代码都是使用 _msgSender
来获取 msg.sender
, 如果 to 合约继承了 openzeppelin 合约,那么继承的函数就直接支持了元交易。
此外,还有一种解决方式,就是在 to 合约中提供一个函数来设置用户的地址,Forwarder 调用 to 合约前,调用该函数设置用户地址;to合约执行时,从临时变量中读取用户地址; 执行完成后, Forwarder 合约在把地址重置。
显然这种方案没有第一种方案优雅,而且需要一个storage变量来存储用户地址,也增加了 gas 费用。
安全问题
重放攻击
例如,一个转账交易,如果没有检查,再次用同样的参数调用,就有可能再次转账。
解决的方式有很多种,一种是在 合约中为用户记录nonce
值,每次交易自增nonce
;另一种是记录交易hash,不允许重复的交易hash
样例代码
https://github.com/guotie/meta-tx
包含了使用, 部署,测试,Proxy 代理的实现。