iT邦幫忙

2024 iThome 鐵人賽

DAY 26
0
自我挑戰組

Solidity 初學之路系列 第 26

DAY 26 - Signature、NFT 交易所

  • 分享至 

  • xImage
  •  

Signature

如果用過 opensea 交易 NFT,對簽名就不會陌生。從 Metamask 錢包進行簽署時彈出的窗口,可以證明你擁有私鑰的同時不需要對外公佈私鑰。以太坊使用的數位簽章演算法叫做雙橢圓曲線數位簽章演算法(ECDSA),是基於雙橢圓曲線「私鑰-公鑰」對的數位簽章演算法。它主要起到了三個作用:

  1. 身分認證:證明簽章方是私鑰的持有人。
  2. 不可否認:發送方不能否認發送過這個訊息。
  3. 完整性:透過驗證針對傳輸訊息產生的數位簽名,可以驗證訊息是否在傳輸過程中被竄改。

ECDSA 合約

ECDSA標準中包含兩個部分:

  1. 簽署者利用私鑰(private)對訊息(public)創建簽名(public)。
  2. 其他人則使用訊息(public)和簽名(public)恢復簽署者的公鑰(public)並驗證簽名。
私鑰: 0x227dbb8586117d55284e26620bc76534dfbd2394be34cf4a09cb775d593b6f2b
公鑰: 0xe16C1623c1AA7D919cd2241d8b36d9E79C1Be2A2
訊息: 0x1bf2c0ce4546651a1a2feb457b39d891a6b83931cc2454434f39961345ac378c
以太坊簽名訊息: 0xb42ca4636f721c7a331923e764587e98ec577cea1a185f60dfcc14dbb9bd900b
簽名: 0x390d704d7ab732ce034203599ee93dd5d3cb0d4d1d7c600ac11726659489773d559b12d220f99f41d17651b0c1c6a669d346a397f8541760d6b32a5725378b241c

建立簽名

  1. 打包訊息: 在以太坊的 ECDSA 標準中,被簽署的訊息是一組資料的 keccak256 hash,為 bytes32 類型。我們可以把任何想要簽署的內容利用 abi.encodePacked() 函數打包,然後用 keccak256() 計算 hash,作為訊息。例子中的訊息是由一個 address 類型變數和一個 uint256 類型變數得到的:
    function getMessageHash(address _account, uint256 _tokenId) public pure returns(bytes32){
        return keccak256(abi.encodePacked(_account, _tokenId));
    }
    
  2. 計算以太坊簽章訊息:訊息可以是能被執行的交易,也可以是其他任何形式。為了避免使用者誤簽了惡意交易,EIP191 提倡在訊息前加上 "\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));
        }
    
  3. (1) 利用錢包簽章: 在日常操作中,大部分使用者都是透過這種方式進行簽署。在取得到需要簽名的訊息之後,我們需要使用 Metamask 錢包進行簽名。Metamask 的 personal_sign 方法會自動把訊息轉換為以太坊簽章訊息,然後發起簽章。所以我們只需要輸入訊息和簽名者錢包 account。要注意的是輸入的簽署者錢包 account 需要和 metamask 目前連接的 account 一致才能使用。
    因此需把例子中的私鑰導入到 Metamask 錢包,然後打開瀏覽器的 console 頁面。在連接錢包的狀態下(如連接 opensea,否則會出現錯誤),依序輸入以下指令進行簽署
    ethereum.enable()
    account = "0xe16C1623c1AA7D919cd2241d8b36d9E79C1Be2A2" // 公鑰
    hash = "0x1bf2c0ce4546651a1a2feb457b39d891a6b83931cc2454434f39961345ac378c" // 訊息
    ethereum.request({method: "personal_sign", params: [account, hash]})
    
    在 console 頁面傳回的結果(Promise 的 PromiseResult)可以看到建立好的簽章。不同帳戶有不同的私鑰,創建的簽名值也不同。
  4. (2) 利用 web3.py 簽名: 批次呼叫中更傾向於使用程式碼進行簽名,以下是基於web3.py的實作。
    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()}")
    
    運行計算的簽名結果應該和前面的案例一致。

驗證簽名

為了驗證簽名,驗證者需要擁有訊息、簽名和簽名使用的公鑰。我們能驗證簽名的原因是只有私鑰的持有者才能夠針對交易產生這樣的簽名,而別人不能。

  1. 透過簽名和訊息恢復公鑰:簽名是由數學演算法產生的。這裡我們使用的是 rsv 簽名,簽名包含 r, s, v 三個值的資訊。而後,我們可以透過 r, s, v 及以太坊簽章訊息來求公鑰。下面的 recoverSigner() 函數實現了上述步驟,它利用以太坊簽署訊息 _msgHash 和簽署 _signature 恢復公鑰(使用了簡單的行內組語):
        // @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);
    }
    
  2. 比較公鑰並驗證簽章:接下來只需要比對復原的公鑰與簽署者公鑰 _signer 是否相等,若相等,則簽章有效;否則,簽章無效:
        /**
     * @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);
    }
}

說明

狀態變數

  • signer:公鑰,專案方簽署地址。
  • mintedAddress:是一個 mapping,記錄了已經 mint 過的地址。

函數

  • 建構子:初始化 NFT 的名稱和代號,還有 ECDSA 的簽章地址 signer
  • mint()函數:接受地址 address、tokenId 和 _signature 三個參數,驗證簽名是否有效:如果有效,則把 tokenId 的 NFT 鑄造給 address 地址,並將它記錄到 mintedAddress。它呼叫了 getMessageHash()、ECDSA.toEthSignedMessageHash() 和 verify() 函數。
  • etMessageHash() 函數:將 mint 位址(address 類型)和tokenId(uint256 類型)拼成訊息。
  • verify() 函數呼叫了 ECDSA 函式庫的 verify() 函數,來進行 ECDSA 簽章驗證。

NFT交易所

Opensea 是以太坊上最大的 NFT 交易平台,總交易總量達到了 $300 億。 Opensea 在交易中抽成 2.5%,因此它透過使用者交易獲利了至少 $7.5億。另外,它的運作並不去中心化,也不準備發幣補償用戶。 NFT 玩家苦 Opensea 久矣,今天我們就利用智能合約搭建一個零手續費的去中心化 NFT 交易所:NFTSwap。

設計邏輯

  • 賣家:出售 NFT 的一方,可以掛單 list、取消單 revoke、修改價格 update。
  • 買家:購買 NFT 的一方,可以購買 purchase。
  • 訂單:賣家發布的 NFT 鏈上訂單,一個系列的同一 tokenId 最多存在一個訂單,其中包含掛單價格 price 和持有人 owner 資訊。當一個訂單交易完成或被撤單後,其中資訊清除。

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{}

onERC721Received

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個交易相關的函數:

  • 掛單list():賣家建立 NFT 並建立訂單,然後釋放 List 事件。參數為 NFT 合約地址 _nftAddr,NFT 對應的 _tokenId,掛單價格 _price(單位是 wei)。成功後,NFT 會從賣家轉到 NFTSwap 合約。
        // 掛單: 賣家上架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);
    }
    
  • 撤單 revoke():賣家撤回掛單,並釋放 Revoke 事件。參數為 NFT 合約位址 _nftAddr,NFT 對應的 _tokenId。成功後,NFT 會從 NFTSwap 合約轉回賣家。
        // 撤單: 賣家取消掛單
    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);
    }
    
  • 修改價格 update():賣家修改 NFT 訂單價格,並釋放 Update 事件。參數為 NFT 合約地址 _nftAddr,NFT 對應的 _tokenId,更新後的掛單價格 _newPrice(單位是wei)。
        // 調整價格:賣家調整掛單價格
    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);
    }
    
  • 購買 purchase:買家支付 ETH 購買掛單的 NFT,並釋放 Purchase 事件。參數為 NFT 合約地址 _nftAddr,NFT 對應的 _tokenId。成功後,ETH 將轉給賣家,NFT 將從NFTSwap 合約轉給買家。
        // 購買: 買家購買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);
    }
    

上一篇
DAY 25 - Merkle Tree
下一篇
DAY 27 - 鏈上隨機數、EIP1155
系列文
Solidity 初學之路30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言