如果用過 opensea 交易 NFT,對簽名就不會陌生。從 Metamask 錢包進行簽署時彈出的窗口,可以證明你擁有私鑰的同時不需要對外公佈私鑰。以太坊使用的數位簽章演算法叫做雙橢圓曲線數位簽章演算法(ECDSA),是基於雙橢圓曲線「私鑰-公鑰」對的數位簽章演算法。它主要起到了三個作用:
ECDSA標準中包含兩個部分:
私鑰: 0x227dbb8586117d55284e26620bc76534dfbd2394be34cf4a09cb775d593b6f2b
公鑰: 0xe16C1623c1AA7D919cd2241d8b36d9E79C1Be2A2
訊息: 0x1bf2c0ce4546651a1a2feb457b39d891a6b83931cc2454434f39961345ac378c
以太坊簽名訊息: 0xb42ca4636f721c7a331923e764587e98ec577cea1a185f60dfcc14dbb9bd900b
簽名: 0x390d704d7ab732ce034203599ee93dd5d3cb0d4d1d7c600ac11726659489773d559b12d220f99f41d17651b0c1c6a669d346a397f8541760d6b32a5725378b241c
function getMessageHash(address _account, uint256 _tokenId) public pure returns(bytes32){
return keccak256(abi.encodePacked(_account, _tokenId));
}
"\x19Ethereum Signed Message:\n32"
字串,並再做一次 keccak256 哈希,作為以太坊簽名訊息。經過toEthSignedMessageHash()
函數處理後的訊息,不能被用來執行交易:
/**
* @dev 回傳以太坊簽名訊息
* `hash`:訊息
* 遵從以太坊簽名標準:https://eth.wiki/json-rpc/API#eth_sign[`eth_sign`]
* 以及`EIP191`:https://eips.ethereum.org/EIPS/eip-191
* 添加"\x19Ethereum Signed Message:\n32"字串,防止簽名的是可執行交易。
*/
function toEthSignedMessageHash(bytes32 hash) public pure returns (bytes32) {
// 哈希的長度為32
return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
}
ethereum.enable()
account = "0xe16C1623c1AA7D919cd2241d8b36d9E79C1Be2A2" // 公鑰
hash = "0x1bf2c0ce4546651a1a2feb457b39d891a6b83931cc2454434f39961345ac378c" // 訊息
ethereum.request({method: "personal_sign", params: [account, hash]})
在 console 頁面傳回的結果(Promise 的 PromiseResult)可以看到建立好的簽章。不同帳戶有不同的私鑰,創建的簽名值也不同。from web3 import Web3, HTTPProvider
from eth_account.messages import encode_defunct
private_key = "0x227dbb8586117d55284e26620bc76534dfbd2394be34cf4a09cb775d593b6f2b"
address = "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4"
rpc = 'https://rpc.ankr.com/eth'
w3 = Web3(HTTPProvider(rpc))
#打包訊息
msg = Web3.solidity_keccak(['address','uint256'], [address,0])
print(f"消息:{msg.hex()}")
#建構可簽名訊息
message = encode_defunct(hexstr=msg.hex())
#簽名
signed_message = w3.eth.account.sign_message(message, private_key=private_key)
print(f"簽名:{signed_message['signature'].hex()}")
運行計算的簽名結果應該和前面的案例一致。為了驗證簽名,驗證者需要擁有訊息、簽名和簽名使用的公鑰。我們能驗證簽名的原因是只有私鑰的持有者才能夠針對交易產生這樣的簽名,而別人不能。
// @dev 從_msgHash和簽名_signature中恢復signer地址
function recoverSigner(bytes32 _msgHash, bytes memory _signature) internal pure returns (address){
// 檢查簽名長度,65是標準r,s,v簽名的長度
require(_signature.length == 65, "invalid signature length");
bytes32 r;
bytes32 s;
uint8 v;
// 目前只能用assembly (行內組語)來從簽名中獲得r,s,v的值
assembly {
/*
前32 bytes儲存簽章的長度 (動態陣列儲存規則)
add(sig, 32) = sig的指標 + 32
等效為略過signature的前32 bytes
mload(p) 載入從記憶體位址 p 起始的接下來 32 bytes資料
*/
// 讀取長度資料後的 32 bytes
r := mload(add(_signature, 0x20))
// 讀取之後的 32 bytes
s := mload(add(_signature, 0x40))
// 讀取最後一個 byte
v := byte(0, mload(add(_signature, 0x60)))
}
// 使用ecrecover(全域函數):利用 msgHash 和 r,s,v 來恢復 signer 位址
return ecrecover(_msgHash, v, r, s);
}
/**
* @dev @dev 透過ECDSA,驗證簽章位址是否正確,如果正確則回傳true
* _msgHash為訊息的hash
* _signature為簽名
* _signer為簽名地址
*/
function verify(bytes32 _msgHash, bytes memory _signature, address _signer) internal pure returns (bool) {
return recoverSigner(_msgHash, _signature) == _signer;
}
NFT 專案方可以利用 ECDSA 的這個特性發放白名單。由於簽名是鏈下的,不需要 gas,因此這種白名單發放模式比 Merkle Tree 模式還要經濟實惠。方法非常簡單,專案方利用專案方帳戶把白名單發放地址簽名(可以加上地址可以鑄造的 tokenId)。然後 mint 的時候利用 ECDSA 檢驗簽章是否有效,如果有效,則給他 mint。但由於使用者要請求中心化介面去取得簽名,不可避免的犧牲了一部分去中心化。另外還有一個好處是白名單可以動態變化,而不是提前寫死在合約裡面,因為專案方的中心化後端介面可以接受任何新地址的請求並給予白名單簽名。
SignatureNFT 合約實現了利用簽名發放 NFT 白名單:
contract SignatureNFT is ERC721 {
address immutable public signer; // 簽名地址
mapping(address => bool) public mintedAddress; // 記錄已經mint的位址
// 建構子,初始化 NFT 合集的名稱、代號、簽名地址
constructor(string memory _name, string memory _symbol, address _signer)
ERC721(_name, _symbol)
{
signer = _signer;
}
// 利用ECDSA驗證簽章並mint
function mint(address _account, uint256 _tokenId, bytes memory _signature)
external
{
bytes32 _msgHash = getMessageHash(_account, _tokenId); // 將_account和_tokenId打包訊息
bytes32 _ethSignedMessageHash = ECDSA.toEthSignedMessageHash(_msgHash); // 計算以太坊簽名訊息
require(verify(_ethSignedMessageHash, _signature), "Invalid signature"); // ECDSA檢驗通過
require(!mintedAddress[_account], "Already minted!"); // 地址沒有mint過
_mint(_account, _tokenId); // mint
mintedAddress[_account] = true; // 記錄mint過的地址
}
/*
* 將mint位址(address類型)和tokenId(uint256類型)拼成訊息msgHash
* _account: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
* _tokenId: 0
* 對應的訊息: 0x1bf2c0ce4546651a1a2feb457b39d891a6b83931cc2454434f39961345ac378c
*/
function getMessageHash(address _account, uint256 _tokenId) public pure returns(bytes32){
return keccak256(abi.encodePacked(_account, _tokenId));
}
// ECDSA驗證,呼叫ECDSA函式庫的verify()函數
function verify(bytes32 _msgHash, bytes memory _signature)
public view returns (bool)
{
return ECDSA.verify(_msgHash, _signature, signer);
}
}
Opensea 是以太坊上最大的 NFT 交易平台,總交易總量達到了 $300 億。 Opensea 在交易中抽成 2.5%,因此它透過使用者交易獲利了至少 $7.5億。另外,它的運作並不去中心化,也不準備發幣補償用戶。 NFT 玩家苦 Opensea 久矣,今天我們就利用智能合約搭建一個零手續費的去中心化 NFT 交易所:NFTSwap。
event List(address indexed seller, address indexed nftAddr, uint256 indexed tokenId, uint256 price); // 掛單
event Purchase(address indexed buyer, address indexed nftAddr, uint256 indexed tokenId, uint256 price); //
event Revoke(address indexed seller, address indexed nftAddr, uint256 indexed tokenId); // 撤單
event Update(address indexed seller, address indexed nftAddr, uint256 indexed tokenId, uint256 newPrice); // 修改價格
NFT 訂單抽象化為 Order 結構,包含掛單價格 price 和持有人 owner 資訊。nftList 映射記錄了訂單是對應的 NFT 系列(合約地址)和 tokenId 資訊。
// 定義訂單結構
struct Order{
address owner;
uint256 price;
}
// NFT 訂單映射
mapping(address => mapping(uint256 => Order)) public nftList;
在 NFTSwap 中,使用者用 ETH 購買 NFT。因此,合約需要實作 fallback() 函數來接收 ETH。
fallback() external payable{}
ERC721 的安全轉帳函數會檢查接收合約是否實作了 onERC721Received() 函數,並傳回正確的選擇器 selector。使用者下單之後,需要將 NFT 發送給 NFTSwap 合約。因此 NFTSwap 繼承 IERC721Receiver 介面,並實現 onERC721Received() 函數:
contract NFTSwap is IERC721Receiver{
// 實現{IERC721Receiver}的onERC721Received,能夠接收ERC721代幣
function onERC721Received(
address operator,
address from,
uint tokenId,
bytes calldata data
) external override returns (bytes4){
return IERC721Receiver.onERC721Received.selector;
}
合約實現了4個交易相關的函數:
// 掛單: 賣家上架NFT,合約地址為_nftAddr,tokenId為_tokenId,價格_price為以太坊(單位是wei)
function list(address _nftAddr, uint256 _tokenId, uint256 _price) public{
IERC721 _nft = IERC721(_nftAddr); // 宣告 IERC721 介面合約變數
require(_nft.getApproved(_tokenId) == address(this), "Need Approval"); // 合約得到授權
require(_price > 0); // 價格大於0
Order storage _order = nftList[_nftAddr][_tokenId]; //設定NF持有者和價格
_order.owner = msg.sender;
_order.price = _price;
// 將NFT轉帳到合約
_nft.safeTransferFrom(msg.sender, address(this), _tokenId);
// 釋放List事件
emit List(msg.sender, _nftAddr, _tokenId, _price);
}
// 撤單: 賣家取消掛單
function revoke(address _nftAddr, uint256 _tokenId) public {
Order storage _order = nftList[_nftAddr][_tokenId]; // 取得 Order
require(_order.owner == msg.sender, "Not Owner"); // 必須由持有人發起
// 宣告IERC721介面合約變數
IERC721 _nft = IERC721(_nftAddr);
require(_nft.ownerOf(_tokenId) == address(this), "Invalid Order"); // NFT在合約中
// 將NFT轉給賣家
_nft.safeTransferFrom(address(this), msg.sender, _tokenId);
delete nftList[_nftAddr][_tokenId]; // 刪除order
// 釋放Revoke事件
emit Revoke(msg.sender, _nftAddr, _tokenId);
}
// 調整價格:賣家調整掛單價格
function update(address _nftAddr, uint256 _tokenId, uint256 _newPrice) public {
require(_newPrice > 0, "Invalid Price"); // NFT價格大於0
Order storage _order = nftList[_nftAddr][_tokenId]; // 取得 Order
require(_order.owner == msg.sender, "Not Owner"); // 必須由持有人發起
// 宣告IERC721介面合約變數
IERC721 _nft = IERC721(_nftAddr);
require(_nft.ownerOf(_tokenId) == address(this), "Invalid Order"); // NFT在合約中
// 調整NFT價格
_order.price = _newPrice;
// 釋放Update事件
emit Update(msg.sender, _nftAddr, _tokenId, _newPrice);
}
// 購買: 買家購買NFT,合約為_nftAddr,tokenId為_tokenId,呼叫函數時要附帶ETH
function purchase(address _nftAddr, uint256 _tokenId) payable public {
Order storage _order = nftList[_nftAddr][_tokenId]; // 取得Order
require(_order.price > 0, "Invalid Price"); // NFT價格大於0
require(msg.value >= _order.price, "Increase price"); // 購買價格大於標價
// 宣告IERC721介面合約變數
IERC721 _nft = IERC721(_nftAddr);
require(_nft.ownerOf(_tokenId) == address(this), "Invalid Order"); // NFT在合約中
// 將NFT轉給買家
_nft.safeTransferFrom(address(this), msg.sender, _tokenId);
// 將ETH轉給賣家,多餘ETH給買家退款
payable(_order.owner).transfer(_order.price);
payable(msg.sender).transfer(msg.value-_order.price);
delete nftList[_nftAddr][_tokenId]; // 刪除order
// 釋放Purchase事件
emit Purchase(msg.sender, _nftAddr, _tokenId, _order.price);
}