• 译文出自:登链翻译计划 [1]

  • 译者:翻译小组 [2]

  • 校对:Tiny 熊 [3]

在合约内启用元交易是一个强大的补充。要求用户持有 ETH 来支付 Gas 一直以来都是而且仍然是新用户进入的最大挑战之一。如果只是简单的点击,谁知道现在会有多少人在使用以太坊?

但有时,解决方案可以在你的合约中加入元交易能力。实现起来可能比你想象的要容易。

如何实现广义的元交易(Meta Transaction) Meta XKCD

什么是元交易?

元交易是一个普通的以太坊交易,它包含另一个交易,即实际交易。实际交易由用户签署,然后发送给运营商(或类似的操作者),用户不需要 Gas 和区块链交互。而是由运营商支付费用签署交易,提交给区块链。

合约确保在实际交易上有一个有效的签名,然后执行它。

概述

如果我们想在合约中支持广义的元交易,可以通过几个简单的步骤完成。从高层次上讲,有两个步骤:

第 1 步 :验证元交易的签名。按照 EIP-712[4] 标准和 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()

防止多重包裹的交易

如何实现广义的元交易(Meta Transaction) 雯丽佳

我们要防止的另一件事是执行元-元-交易。(除非你想无缘无故地耍酷)

它没有任何作用,只是浪费了额外的 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[5] 解决的问题,你可以看到如何从交易数据和额外的 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[6] 的文章。

好了,现在我们有了完整的交易哈希值和用户的签名。我们可以通过一个辅助工具提取 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[7] 的作用,它允许智能合约来验证签名。标准本身没有说明合约如何做到这一点。唯一的定义是函数签名,其定义是:

    function isValidSignature(          bytes32 hash,          bytes memory signature      ) public view returns (bytes4);  

其中有效签名的返回值为 0x1626ba7e 。如何实现签名逻辑则取决于智能合约开发者。

那么,我们怎样才能验证这样的签名呢?

你可以在下边看到一个实现的例子。使用 staticcall ,我们可以确保在调用过程中没有进一步的状态修改发生。如果结果成功并且有一个有效的 returnData 长度(这是非常关键的,见之前的 0x bug[8]),我们可以检查返回值是否符合 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 这里 [9] 中的现有类型,以获得一些灵感。

自己实现

到目前为止,我们已经看到了所有实现的关键部分,这应该让你对如何实现它有一个好的启发。我还建议你看一下:

  1. 0x 元交易的实现 [10]

  2. Openzeppelin EIP-712 支持 [11]

  3. 实现签名部分的 npm eip-712 库 [12]

Openzeppelin EIP-712 库仍然是一个草案,但对链 ID 可能改变的分叉情况有额外支持。也可以看看 0x 代码,本博文中的很多实现都来自于此。


本翻译由 Cell Network[13] 赞助支持。

来源: https://soliditydeveloper.com/meta-transactions

参考资料

[1]

登链翻译计划 : https://github.com/lbc-team/Pioneer

[2]

翻译小组 : https://learnblockchain.cn/people/412

[3]

Tiny 熊 : https://learnblockchain.cn/people/15

[4]

EIP-712: https://eips.ethereum.org/EIPS/eip-712

[5]

EIP-712: https://eips.ethereum.org/EIPS/eip-712

[6]

ERC20-Permit: https://learnblockchain.cn/article/1790

[7]

EIP-1271: https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1271.md

[8]

这是非常关键的,见之前的 0x bug: https://samczsun.com/the-0x-vulnerability-explained/

[9]

这里 : https://0x.org/docs/guides/v3-specification#signature-types

[10]

0x 元交易的实现 : https://github.com/0xProject/0x-monorepo/blob/development/contracts/exchange/contracts/hide/MixinTransactions.sol

[11]

Openzeppelin EIP-712 支持 : https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/drafts/EIP712.sol

[12]

eip-712 库 : https://github.com/Mrtenz/eip-712

[13]

Cell Network: https://www.cellnetwork.io/?utm_souce=learnblockchain

如何实现广义的元交易(Meta Transaction)