引言
上文中提到,普通的 ETH 交易并不能夠做到讓用戶無需 gas 費,需要交易中嵌套一個交易,即元交易,來實作免 gas 費,
本文將分析開源庫 OpenZeppelin/openzeppelin-contracts 中的元交易合約的實作,讓你能夠快速入門元交易實作細節,從而能夠自己對后續更多的相關技術深入探索,
前置知識概述
元交易會涉及到 ECDSA 與 EIP712 等知識,如果你是熟手,可以跳過此節內容,直接瀏覽具體實作分析部分,
Hash
也稱哈希、散列、數字摘要,通過哈希函式,可以將長短不一的資訊轉化為一段長度任意但可預測的(確定性的)結果,這是一類神奇的函式,可以將一大堆資訊轉變成一串短的,可作為摘要的資料 “指紋”,對于一個給定的輸入而言,生成的 “指紋” 始終一致,如果你的原始資料中有任何細微的改動,生成的哈希值將大不相同,以太坊中采用的是 Keccak-256 演算法,
ECDSA
在密碼學中,ECDSA(Elliptic Curve Digital Signature Algorithm,橢圓曲線數字簽名演算法)是使用橢圓曲線密碼學的數字簽名演算法(DSA)的一個變種,
主要用于對資料(比如一個檔案)創建數字簽名,以便于你在不破壞它的安全性的前提下對它的真實性進行驗證,可以將它想象成一個實際的簽名,你可以識別部分人的簽名,但是你無法在別人不知道的情況下偽造它,
你不應該將ECDSA與用來對資料進行加密的AES(高級加密標準)相混淆,ECDSA不會對資料進行加密、或阻止別人看到或訪問你的資料,它可以防止的是確保資料沒有被篡改,
如圖所示,在以太坊中,ECDSA 用于對原始資料的 hash 值進行簽名及恢復,

將原始資料通過 hash 函式得到它的 hash 值后,用戶 A 用自己的私鑰對該 hash 值進行簽名,得到 Signature(簽名),有了該簽名與 hash 值,任何人都能夠從中恢復出簽名人的錢包地址,在這里用戶 B 則恢復得到了用戶 A 的錢包地址,
EIP712
Ethereum Improvement Proposals (EIPs),你可以在這里查看所有的 EIPs,EIP712 (Ethereum typed structured data hashing and signing)以太坊型別的結構化資料哈希與簽名,
如果我們只關心位元組字串的話,簽名資料是一個已經解決了的問題,但不幸的是,在現實世界中,我們關心的是復雜而有意義的資訊,對結構化資料進行哈希是非常重要的,錯誤會導致系統安全屬性的丟失,
此 EIP 旨在提高鏈上使用的鏈下訊息簽名的可用性,我們看到越來越多的人采用鏈下訊息簽名,因為它節省了 gas 費,減少了區塊鏈上的交易數量,當前簽名訊息是一個不透明的十六進制字串,顯示給用戶,關于組成訊息的專案的背景關系很少,

EIP712 概述了一個編碼資料及其結構的方案,該方案允許在簽名時將資料顯示給用戶進行驗證,下面是一個用戶在簽署 EIP712 訊息時顯示的示例,

元交易合約的實作
此分析針對 openzeppelin-contracts v4.3.2 版本,
contract MinimalForwarder is EIP712 {
using ECDSA for bytes32;
struct ForwardRequest {
address from;
address to;
uint256 value;
uint256 gas;
uint256 nonce;
bytes data;
}
constructor() EIP712("MinimalForwarder", "0.0.1") {}
}
ECDSA 是 openzeppelin 實作的一個 solidity 庫,它實作了從 hash 值中恢復錢包地址的方法,將它應用在 bytes32 上,就可以直接在 bytes32 上呼叫 recover 方法,recover 函式簽名:function recover(bytes32 hash, bytes memory signature) internal pure returns (address) ,
ForwardRequest 結構體定義了一個交易中用于簽名的基本組成成分,與以太坊交易不同的是沒有 gasPrice,因為智能合約的執行只關心 gas 的消耗,ForwardRequest 中 的 nonce 概念與以太坊類似,都是為了避免雙花攻擊,但這里的 nonce 僅由智能合約維護,跟普通的以太坊交易中的 nonce 無關,
建構式中直接使用 EIP712 的建構式進行初始化,EIP712 的建構式簽名為:constructor(string memory name, string memory version) ,其中 name 是合約名稱,version 是合約版本,這將作為 EIP712 簽名驗證的一部分,它在部署時,將自動獲取合約的地址、chainId 等資訊,意味著,即便有相同的 ForwardRequest 結構體資料,但合約地址或區塊鏈網路不同,也會導致簽名無效,
mapping(address => uint256) private _nonces;
function getNonce(address from) public view returns (uint256) {
return _nonces[from];
}
為了避免雙花攻擊,在智能合約中維護 nonce 是必要的,
bytes32 private constant _TYPEHASH =
keccak256("ForwardRequest(address from,address to,uint256 value,uint256 gas,uint256 nonce,bytes data)");
function verify(ForwardRequest calldata req, bytes calldata signature) public view returns (bool) {
address signer = _hashTypedDataV4(
keccak256(abi.encode(_TYPEHASH, req.from, req.to, req.value, req.gas, req.nonce, keccak256(req.data)))
).recover(signature);
return _nonces[req.from] == req.nonce && signer == req.from;
}
看到 verify 函式,我們知道,要將錢包地址恢復,至少需要經過 ECDSA 的簽名以及用于簽名的原始資料,而此處,ECDSA 簽名的原始資料就是經過 abi 編碼的 keccak256(abi.encode(_TYPEHASH, req.from, req.to, req.value, req.gas, req.nonce, keccak256(req.data))) ForwardRequest 結構體資料的哈希值,再通過呼叫 ECDSA 庫中的 recover 函式,傳入簽名,就能夠恢復得到簽名者的錢包地址,
通過 _nonces[req.from] == req.nonce 來確保交易的呼叫是順序的,且不會遭受雙花攻擊,signer == req.from 避免簽名者與實際元交易發送者不匹配,
接下來看,如何執行元交易,
function execute(ForwardRequest calldata req, bytes calldata signature)
public
payable
returns (bool, bytes memory)
{
require(verify(req, signature), "MinimalForwarder: signature does not match request");
_nonces[req.from] = req.nonce + 1;
(bool success, bytes memory returndata) = req.to.call{gas: req.gas, value: req.value}(
abi.encodePacked(req.data, req.from)
);
// Validate that the relayer has sent enough gas for the call.
// See https://ronan.eth.link/blog/ethereum-gas-dangers/
assert(gasleft() > req.gas / 63);
return (success, returndata);
}
在使用 Address.call 方法的時候,根據元交易引數,指定了 call 的 gas 與 value 值,需要注意的是,這里并不直接將元交易的 data 欄位當作 call 操作的 data,而是將 data 與 from 進行 abi 編碼后一起作為 call 操作的引數,這在目標合約(也就是 req.to)中會被決議,從而得到交易的發送者,在下面會詳細講解,
assert(gasleft() > req.gas / 63) 簡單理解為避免中繼器(代為執行元交易的人)惡意地或無意地使用足夠低的 gas 使得交易執行成功,而元交易執行失敗,詳情可以在 ethereum gas dangers 中學習,
ERC2771
要支持元交易,僅實作元交易智能合約是不夠的,因為目標合約無法知道實際的元交易 from 是誰,如果沒有額外的措施,它將只能夠從 msg.sender 中獲取,由于在元交易合約實作中,是通過 Address.call 呼叫的,因此將得到的發送者是元交易合約的地址,ERC2771 則解決了該問題,
abstract contract ERC2771Context is Context
ERC2771Context 繼承了 Context,而 Context 中簡單封裝了從 msg.sender 與 msg.data ,以便規范這兩個功能的使用,且能夠讓其在子合約中修改其行為,要求使用 Context 合約獲取 msg 相關的資料,而不是直接使用 msg.sender 等,
abstract contract Context {
function _msgSender() internal view virtual returns (address) {
return msg.sender;
}
function _msgData() internal view virtual returns (bytes calldata) {
return msg.data;
}
}
ERC2771Context 就修改了 Context 合約的方法,
function _msgSender() internal view virtual override 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 super._msgSender();
}
}
先通過 isTrustedForwarder(msg.sender) 驗證元交易的呼叫方是期望的元交易合約地址,assembly 代碼將上文的元交易合約中 req.to.call{...}(abi.encodePacked(req.data, req.from)) 編碼進的 data 部分內容的 req.from 獲取到,然后再回傳該值,
元交易使用概覽
讓我們來嘗試簡單使用元交易合約,要支持元交易,你所撰寫的合約必須繼承 ERC2771Context,在這里簡單實作一個 NFT 合約,在部署它之前,你必須先部署元交易合約,將元交易合約地址作為引數傳遞給 NFT 合約建構式,
// SPDX-License-Identifier: GPL3.0
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
import "@openzeppelin/contracts/metatx/ERC2771Context.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
contract NFT is ERC2771Context, ERC721 {
using SafeMath for uint256;
uint256 private _currentTokenId = 0;
constructor(
string memory name,
string memory symbol,
address trustedForwarder
) ERC721(name, symbol) ERC2771Context(trustedForwarder) {}
function safeMint() public virtual {
safeMint("");
}
function safeMint(bytes memory _data) internal virtual {
uint256 tokenId = _getNextTokenId();
_incrementTokenId();
_safeMint(_msgSender(), tokenId, _data);
}
function getCurrTokenId() public virtual view returns (uint256) {
return _currentTokenId;
}
/**
* @dev calculates the next token ID based on value of _currentTokenId
* @return uint256 for the next token ID
*/
function _getNextTokenId() internal virtual view returns (uint256) {
return _currentTokenId.add(1);
}
/**
* @dev increments the value of _currentTokenId
*/
function _incrementTokenId() internal virtual {
_currentTokenId++;
}
function _msgSender() internal view virtual override(Context, ERC2771Context) returns (address) {
return ERC2771Context._msgSender();
}
function _msgData() internal view virtual override(Context, ERC2771Context) returns (bytes calldata) {
return ERC2771Context._msgData();
}
}
在這個示例中,如果 Alice 沒有足夠的 ETH 支付 gas 費,來鑄造一個 NFT,她可以簽署一個元交易,元交易的 data 是由 abi.encodeWithSignature(functionSelector, parmas...) 得到的,將該元交易遞交給具有足夠 ETH 的 Bob,Bob 呼叫元交易合約 MinimalForwarder.execute(req, signature),從而讓 Alice 的元交易成功執行,
OpenZeppelin 的完整代碼實作
參考
- 以太坊元交易
- 維基百科
- EIPs
- OpenZeppelin 的完整代碼實作
轉載請註明出處,本文鏈接:https://www.uj5u.com/qukuanlian/356173.html
標籤:區塊鏈
上一篇:智能合約開發實戰——元交易(Metatransaction)系列一,什么是元交易?
下一篇:Angular未正確顯示錯誤
