如何实现广义的元交易(Meta Transaction)
探索元交易的强大设计
> * 原文:https://soliditydeveloper.com/meta-transactions > * 译文出自:[登链翻译计划](https://github.com/lbc-team/Pioneer) > * 译者:[翻译小组](https://learnblockchain.cn/people/412) > * 校对:[Tiny 熊](https://learnblockchain.cn/people/15) > * 本文永久链接:[learnblockchain.cn/article…](https://learnblockchain.cn/article/2731) 在合约内启用元交易是一个强大的补充。要求用户持有ETH来支付Gas一直以来都是而且仍然是新用户进入的最大挑战之一。如果只是简单的点击,谁知道现在会有多少人在使用以太坊? 但有时,解决方案可以在你的合约中加入元交易能力。实现起来可能比你想象的要容易。 ![Meta XKCD](https://img.learnblockchain.cn/pics/20210709144843.png) ## 什么是元交易? 元交易是一个普通的以太坊交易,它包含另一个交易,即实际交易。实际交易由用户签署,然后发送给运营商(或类似的操作者),用户不需要Gas和区块链交互。而是由运营商支付费用签署交易,提交给区块链。 合约确保在实际交易上有一个有效的签名,然后执行它。 ## 概述 如果我们想在合约中支持广义的元交易,可以通过几个简单的步骤完成。从高层次上讲,有两个步骤: **第1步**:验证元交易的签名。按照[EIP-712](https://eips.ethereum.org/EIPS/eip-712)标准和`ecrecover`创建一个哈希值来完成: ```javascript bool isValidSignature = ecrecover(hash(transaction), v, r, s) == transaction.signerAddress ``` **第2步**:一旦得到验证,我们就可以提取实际的交易数据。通过对当前的合约地址使用`delegatecall`,执行一个函数(而不做新的合约调用)。请记住,delegatecall 使用当前合约的状态去调用合约的代码。因此,通过执行`address(this).delegatecall`,可以在当前的合约中执行所有的功能,并且可以传递交易数据。 ```javascript (bool didSucceed, bytes memory returnData) = address(this).delegatecall(transaction.data); ``` 大致就是这样。但也有一些关键的信息需要验证,也有签名的替代品。 让我们来看看更多细节。 ## 交易执行的细节 正如我们所看到的,执行的核心是 `delegatecall`。这是实际交易被执行的地方。但是为了确保正确的执行,我们必须确保一些事情是正确的。 ### 交易结构 首先让我们看看交易结构中的数据,包含了用户设定的所有相关要求,以及 `bytes data ` 作为将要执行的交易本身。data 也是从用户传递到运营商再到合约的内容: ```javascript struct Transaction { uint256 salt; uint256 expirationTimeSeconds; uint256 gasPrice; address signerAddress; bytes data; } ``` ### 结构化交易哈希 我们还需要在所有这些数据上计算一个哈希值。这将用于签名 schema和防止同一交易的重复执行。关于这方面的细节,请看最后的签名解释。 这是交易schema的哈希值: ```javascript EIP712_TRANSACTION_SCHEMA_HASH = keccak256( abi.encodePacked("Transaction(uint256 salt,uint256 expirationTimeSeconds,uint256 gasPrice,address signerAddress,bytes data)") ); ``` 这是EIP712 Schema 的哈希值,可以在合约的构造函数中计算一次。 我们可以使用`keccak256`和`abi.encodePacked`来计算结构化的交易哈希值: ```javascript一致 function _getTransactionTypedHash( Transaction memory transaction ) private view returns (bytes32) { return keccak256(abi.encodePacked( EIP712_TRANSACTION_SCHEMA_HASH, transaction.salt, transaction.expirationTimeSeconds, transaction.gasPrice, uint256(transaction.signerAddress), keccak256(transaction.data) )); } ``` 通过hash所有相关的值,我们可以确保只有原用户签名的交易才会成功执行。例如,即使运营商只是改变了`expirationTimeSeconds`中的1秒,它也不能成功执行。 这只是哈希值的第一部分,要了解包括安全签名要求在内的全部细节,请阅读下面关于签名的部分。 ### 设置正确的msg.sender 如果我们只是执行delegatecall,交易的msg.sender仍然是元交易的运营商,而不是原始签名者。 我们可以通过设置一个上下文变量来解决这个问题: ```javascript function _setCurrentContextAddressIfRequired(address contextAddress) private { currentContextAddress = contextAddress; } function _getCurrentContextAddress() private view returns (address) { return currentContextAddress == address(0) ? msg.sender : currentContextAddress; } ``` 你在合约中使用`msg.sender`的地方,现在都会调用`_getCurrentContextAddress()`。 ### 防止多重包裹的交易 ![雯丽佳](https://img.learnblockchain.cn/pics/20210709144854.jpg) 我们要防止的另一件事是执行元-元-交易。(除非你想无缘无故地耍酷) 它没有任何作用,只是浪费了额外的Gas。因此,我们可以在任何交易执行之前添加检查。 ```javascript require(currentContextAddress == address(0), "META_TX: Transaction has context set already"); ``` ### 确保满足交易条件 我们将进一步确保所有规定的条件得到满足 - 过期时间是有用的,用户需要知道一个交易在几个月后不会被执行。 - 一个`transactionsExecuted`映射,以确保元交易只被执行一次。注意:确保在成功执行后设置`transactionsExecuted[transactionHash] = true`。 - 一个由用户定义的Gas价格。这在你的系统中可能不需要。因为Gas是由运营商支付的,需要指定gas交易成本的唯一原因是该值在交易中会具有进一步对交易的影响。例如,在0x中,Gas价格会影响费用价格。 ```javascript require(block.timestamp < transaction.expirationTimeSeconds, "META_TX: Meta transaction is expired"); require(!transactionsExecuted[transactionHash], "META_TX: Transaction already executed"); require(tx.gasprice == requiredGasPrice, "META_TX: Gas price not matching required gas price"); ``` ## 验证签名 当然,我们只想执行有有效签名的交易。一个天真的解决方案可能只处理 transaction.data 并签名。 但是... - 我们如何确保所有额外的交易参数被正确设置(过期时间、salt、signer...)? - 我们如何防止一个已签名的交易被多次使用? 第一部分很简单,我们用前面的`_getTransactionTypedHash`函数在所有这些值上创建一个哈希值。第二部分通过[EIP-712](https://eips.ethereum.org/EIPS/eip-712)解决的问题,你可以看到如何从交易数据和额外的EIP-712数据中创建一个哈希值,代码如下: ```javascript function _getFullTransactionTypedHash(Transaction memory transaction) private view returns (bytes32) { bytes32 transactionStructHash = _getTransactionTypedHash(transaction); bytes32 EIP191_HEADER = 0x1901000000000000000000000000000000000000000000000000000000000000; bytes32 schemaHash = keccak256(abi.encodePacked("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")); uint256 chainId = 1; // mainnet address verifyingContract = address(this); bytes32 domainHash = keccak256(abi.encodePacked( schemaHash, keccak256(bytes("My Protocol Name")), keccak256(bytes("1.0.0")), chainId, verifyingContract )); return keccak256(abi.encodePacked(EIP191_HEADER, domainHash, hashStruct)); } ``` 将额外的信息放入我们的哈希值中,因此,一个已签署的交易只能准确地用于该合约与给定的链Id。所有的细节,请查看EIP或我之前关于[ERC20-Permit](https://learnblockchain.cn/article/1790)的文章。 好了,现在我们有了完整的交易哈希值和用户的签名。我们可以通过一个辅助工具提取byte32值来获得三个值r、s、v,这三个值是签名中的椭圆曲线签名值。uint8的v值只需要一个简单的转换。 使用`ecrecover`与给定的签名和交易哈希,可计算出一个签名者地址。如果这个地址与`transaction.signerAddress`相匹配,则签名确实有效。 ```javascript function _isValidTransactionWithHashSignature( Transaction memory transaction, bytes32 txHash, bytes memory signature ) private pure returns (bool) { require( signature.length == 66, "META_TX: Invalid signature length" ); uint8 v = uint8(signature[0]); bytes32 r = _readBytes32(signature, 1); bytes32 s = _readBytes32(signature, 33); address recovered = ecrecover(txHash, v, r, s); return transaction.signerAddress == recovered; } function _readBytes32( bytes memory b, uint256 index ) private pure returns (bytes32 result) { require( b.length >= index + 32, "META_TX: Invalid index for given bytes" ); // Arrays are prefixed by // a 256 bit length parameter index += 32; // Read the bytes32 from array memory assembly { result := mload(add(b, index)) } return result; } ``` 这就是常规的签名方案。如果你需要用户签署他自己的交易,它就能完美地工作。 但如果你想让智能合约创建有效的签名呢? ## 高级签名方案 一个更高级的使用场景是让智能合约签署元交易,但想象一下,用户把他的资金放在一个多签名的智能合约里面。这对于某些钱包来说已经很常见了。这个用户不能用EIP-712方案签署交易来创建一个有效的v、r、s签名。 这就是[EIP-1271](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1271.md)的作用,它允许智能合约来验证签名。标准本身没有说明合约如何做到这一点。唯一的定义是函数签名,其定义是: ```javascript function isValidSignature( bytes32 hash, bytes memory signature ) public view returns (bytes4); ``` 其中有效签名的返回值为`0x1626ba7e`。如何实现签名逻辑则取决于智能合约开发者。 那么,我们怎样才能验证这样的签名呢? 你可以在下边看到一个实现的例子。使用`staticcall`,我们可以确保在调用过程中没有进一步的状态修改发生。如果结果成功并且有一个有效的returnData长度([这是非常关键的,见之前的0x bug](https://samczsun.com/the-0x-vulnerability-explained/)),我们可以检查返回值是否符合`0x1626ba7e`。 ```javascript function _staticCallEIP1271Wallet( address verifyingContractAddress, bytes memory data, bytes memory signature ) private view returns (bool) { bytes memory callData = abi.encodeWithSelector( IEIP1271Wallet.isValidSignature.selector, data, signature ); (bool didSucceed, bytes memory returnData) = verifyingContractAddress.staticcall(callData); require( didSucceed && returnData.length == 32, "META_TX: EIP1271 call failed" ); bytes4 returnedValue = _readBytes4(returnData, 0); return returnedValue == 0x1626ba7e; } ``` 你可能想允许更多的签名方法,比如预签名或拥有可以代表用户签名的运营商。请看0x[这里](https://0x.org/docs/guides/v3-specification#signature-types)中的现有类型,以获得一些灵感。 ## 自己实现 到目前为止,我们已经看到了所有实现的关键部分,这应该让你对如何实现它有一个好的启发。我还建议你看一下: 1. [0x元交易的实现](https://github.com/0xProject/0x-monorepo/blob/development/contracts/exchange/contracts/src/MixinTransactions.sol) 2. [Openzeppelin EIP-712支持](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/drafts/EIP712.sol) 3. 实现签名部分的npm[ eip-712 库](https://github.com/Mrtenz/eip-712) Openzeppelin EIP-712库仍然是一个草案,但对链ID可能改变的分叉情况有额外支持。也可以看看0x代码,本博文中的很多实现都来自于此。 --- 本翻译由 [Cell Network](https://www.cellnetwork.io/?utm_souce=learnblockchain) 赞助支持。
- 原文:https://soliditydeveloper.com/meta-transactions
- 译文出自:登链翻译计划
- 译者:翻译小组
- 校对:Tiny 熊
- 本文永久链接:learnblockchain.cn/article…
在合约内启用元交易是一个强大的补充。要求用户持有ETH来支付Gas一直以来都是而且仍然是新用户进入的最大挑战之一。如果只是简单的点击,谁知道现在会有多少人在使用以太坊?
但有时,解决方案可以在你的合约中加入元交易能力。实现起来可能比你想象的要容易。
什么是元交易?
元交易是一个普通的以太坊交易,它包含另一个交易,即实际交易。实际交易由用户签署,然后发送给运营商(或类似的操作者),用户不需要Gas和区块链交互。而是由运营商支付费用签署交易,提交给区块链。
合约确保在实际交易上有一个有效的签名,然后执行它。
概述
如果我们想在合约中支持广义的元交易,可以通过几个简单的步骤完成。从高层次上讲,有两个步骤:
第1步:验证元交易的签名。按照EIP-712标准和ecrecover
创建一个哈希值来完成:
bool isValidSignature = ecrecover(hash(transaction), v, r, s) == transaction.signerAddress
第2步:一旦得到验证,我们就可以提取实际的交易数据。通过对当前的合约地址使用delegatecall
,执行一个函数(而不做新的合约调用)。请记住,delegatecall 使用当前合约的状态去调用合约的代码。因此,通过执行address(this).delegatecall
,可以在当前的合约中执行所有的功能,并且可以传递交易数据。
(bool didSucceed, bytes memory returnData) = address(this).delegatecall(transaction.data);
大致就是这样。但也有一些关键的信息需要验证,也有签名的替代品。
让我们来看看更多细节。
交易执行的细节
正如我们所看到的,执行的核心是 delegatecall
。这是实际交易被执行的地方。但是为了确保正确的执行,我们必须确保一些事情是正确的。
交易结构
首先让我们看看交易结构中的数据,包含了用户设定的所有相关要求,以及 bytes data
作为将要执行的交易本身。data 也是从用户传递到运营商再到合约的内容:
struct Transaction {
uint256 salt;
uint256 expirationTimeSeconds;
uint256 gasPrice;
address signerAddress;
bytes data;
}
结构化交易哈希
我们还需要在所有这些数据上计算一个哈希值。这将用于签名 schema和防止同一交易的重复执行。关于这方面的细节,请看最后的签名解释。
这是交易schema的哈希值:
EIP712_TRANSACTION_SCHEMA_HASH = keccak256(
abi.encodePacked("Transaction(uint256 salt,uint256 expirationTimeSeconds,uint256 gasPrice,address signerAddress,bytes data)")
);
这是EIP712 Schema 的哈希值,可以在合约的构造函数中计算一次。
我们可以使用keccak256
和abi.encodePacked
来计算结构化的交易哈希值:
function _getTransactionTypedHash(
Transaction memory transaction
) private view returns (bytes32) {
return keccak256(abi.encodePacked(
EIP712_TRANSACTION_SCHEMA_HASH,
transaction.salt,
transaction.expirationTimeSeconds,
transaction.gasPrice,
uint256(transaction.signerAddress),
keccak256(transaction.data)
));
}
通过hash所有相关的值,我们可以确保只有原用户签名的交易才会成功执行。例如,即使运营商只是改变了expirationTimeSeconds
中的1秒,它也不能成功执行。
这只是哈希值的第一部分,要了解包括安全签名要求在内的全部细节,请阅读下面关于签名的部分。
设置正确的msg.sender
如果我们只是执行delegatecall,交易的msg.sender仍然是元交易的运营商,而不是原始签名者。
我们可以通过设置一个上下文变量来解决这个问题:
function _setCurrentContextAddressIfRequired(address contextAddress) private {
currentContextAddress = contextAddress;
}
function _getCurrentContextAddress() private view returns (address) {
return currentContextAddress == address(0) ? msg.sender : currentContextAddress;
}
你在合约中使用msg.sender
的地方,现在都会调用_getCurrentContextAddress()
。
防止多重包裹的交易
我们要防止的另一件事是执行元-元-交易。(除非你想无缘无故地耍酷)
它没有任何作用,只是浪费了额外的Gas。因此,我们可以在任何交易执行之前添加检查。
require(currentContextAddress == address(0), "META_TX: Transaction has context set already");
确保满足交易条件
我们将进一步确保所有规定的条件得到满足
- 过期时间是有用的,用户需要知道一个交易在几个月后不会被执行。
- 一个
transactionsExecuted
映射,以确保元交易只被执行一次。注意:确保在成功执行后设置transactionsExecuted[transactionHash] = true
。 - 一个由用户定义的Gas价格。这在你的系统中可能不需要。因为Gas是由运营商支付的,需要指定gas交易成本的唯一原因是该值在交易中会具有进一步对交易的影响。例如,在0x中,Gas价格会影响费用价格。
require(block.timestamp < transaction.expirationTimeSeconds, "META_TX: Meta transaction is expired");
require(!transactionsExecuted[transactionHash], "META_TX: Transaction already executed");
require(tx.gasprice == requiredGasPrice, "META_TX: Gas price not matching required gas price");
验证签名
当然,我们只想执行有有效签名的交易。一个天真的解决方案可能只处理 transaction.data 并签名。
但是...
- 我们如何确保所有额外的交易参数被正确设置(过期时间、salt、signer...)?
- 我们如何防止一个已签名的交易被多次使用?
第一部分很简单,我们用前面的_getTransactionTypedHash
函数在所有这些值上创建一个哈希值。第二部分通过EIP-712解决的问题,你可以看到如何从交易数据和额外的EIP-712数据中创建一个哈希值,代码如下:
function _getFullTransactionTypedHash(Transaction memory transaction) private view returns (bytes32) {
bytes32 transactionStructHash = _getTransactionTypedHash(transaction);
bytes32 EIP191_HEADER = 0x1901000000000000000000000000000000000000000000000000000000000000;
bytes32 schemaHash = keccak256(abi.encodePacked("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"));
uint256 chainId = 1; // mainnet
address verifyingContract = address(this);
bytes32 domainHash = keccak256(abi.encodePacked(
schemaHash,
keccak256(bytes("My Protocol Name")),
keccak256(bytes("1.0.0")),
chainId,
verifyingContract
));
return keccak256(abi.encodePacked(EIP191_HEADER, domainHash, hashStruct));
}
将额外的信息放入我们的哈希值中,因此,一个已签署的交易只能准确地用于该合约与给定的链Id。所有的细节,请查看EIP或我之前关于ERC20-Permit的文章。
好了,现在我们有了完整的交易哈希值和用户的签名。我们可以通过一个辅助工具提取byte32值来获得三个值r、s、v,这三个值是签名中的椭圆曲线签名值。uint8的v值只需要一个简单的转换。
使用ecrecover
与给定的签名和交易哈希,可计算出一个签名者地址。如果这个地址与transaction.signerAddress
相匹配,则签名确实有效。
function _isValidTransactionWithHashSignature(
Transaction memory transaction,
bytes32 txHash,
bytes memory signature
) private pure returns (bool) {
require(
signature.length == 66,
"META_TX: Invalid signature length"
);
uint8 v = uint8(signature[0]);
bytes32 r = _readBytes32(signature, 1);
bytes32 s = _readBytes32(signature, 33);
address recovered = ecrecover(txHash, v, r, s);
return transaction.signerAddress == recovered;
}
function _readBytes32(
bytes memory b, uint256 index
) private pure returns (bytes32 result) {
require(
b.length >= index + 32,
"META_TX: Invalid index for given bytes"
);
// Arrays are prefixed by
// a 256 bit length parameter
index += 32;
// Read the bytes32 from array memory
assembly {
result := mload(add(b, index))
}
return result;
}
这就是常规的签名方案。如果你需要用户签署他自己的交易,它就能完美地工作。
但如果你想让智能合约创建有效的签名呢?
高级签名方案
一个更高级的使用场景是让智能合约签署元交易,但想象一下,用户把他的资金放在一个多签名的智能合约里面。这对于某些钱包来说已经很常见了。这个用户不能用EIP-712方案签署交易来创建一个有效的v、r、s签名。
这就是EIP-1271的作用,它允许智能合约来验证签名。标准本身没有说明合约如何做到这一点。唯一的定义是函数签名,其定义是:
function isValidSignature(
bytes32 hash,
bytes memory signature
) public view returns (bytes4);
其中有效签名的返回值为0x1626ba7e
。如何实现签名逻辑则取决于智能合约开发者。
那么,我们怎样才能验证这样的签名呢?
你可以在下边看到一个实现的例子。使用staticcall
,我们可以确保在调用过程中没有进一步的状态修改发生。如果结果成功并且有一个有效的returnData长度(这是非常关键的,见之前的0x bug),我们可以检查返回值是否符合0x1626ba7e
。
function _staticCallEIP1271Wallet(
address verifyingContractAddress,
bytes memory data,
bytes memory signature
) private view returns (bool) {
bytes memory callData = abi.encodeWithSelector(
IEIP1271Wallet.isValidSignature.selector,
data,
signature
);
(bool didSucceed, bytes memory returnData)
= verifyingContractAddress.staticcall(callData);
require(
didSucceed && returnData.length == 32,
"META_TX: EIP1271 call failed"
);
bytes4 returnedValue = _readBytes4(returnData, 0);
return returnedValue == 0x1626ba7e;
}
你可能想允许更多的签名方法,比如预签名或拥有可以代表用户签名的运营商。请看0x这里中的现有类型,以获得一些灵感。
自己实现
到目前为止,我们已经看到了所有实现的关键部分,这应该让你对如何实现它有一个好的启发。我还建议你看一下:
- 0x元交易的实现
- Openzeppelin EIP-712支持
- 实现签名部分的npm eip-712 库
Openzeppelin EIP-712库仍然是一个草案,但对链ID可能改变的分叉情况有额外支持。也可以看看0x代码,本博文中的很多实现都来自于此。
本翻译由 Cell Network 赞助支持。
区块链技术网。
- 发表于 2021-07-12 08:59
- 阅读 ( 1373 )
- 学分 ( 42 )
- 分类:智能合约
评论