banner
欧雷

欧雷流 on-Ch@iN

🫣
follow

智能合約純小白是如何完成自己的第一個 dApp——NFT 市場?

作為一個持續看衰傳統互聯網行業 Web 前端開發的大齡前端工程師,我一直在尋找一個能最大化利用自己已有知識與技能的出路 —— 選擇了向 Web3 領域的全棧開發轉型。

在做出此決定之時,我對 Web3 還不甚了解,無論是站在從業者或是普通用戶的立場,我急需一個能讓我快速入門的途徑!

機緣巧合之下,我知道了 OpenBuild 的《Web3 前端訓練營》,從內容介紹來看應該能滿足我的需求,就毫不猶豫地報名了 —— 都免費的了,還有「瑪尼」賺,猶豫個鬼啊!

本文內容是訓練營課程的實戰筆記,圍繞著「有前端開發基礎的智能合約純小白如何開發出自己的第一個 NFT market dApp」去寫,也就是說,會涵蓋 task 3task 4task 5

由於我是一剛向 Web3 轉型的初學者,很多東西不太懂,以下內容僅代表個人理解,有錯漏謬誤歡迎指出。

我認為「智能合約」這個名字源於它所起到的業務作用,而對於開發者來說,它僅僅是軟件程序而已,需要用某種編程語言去寫代碼。

所以,要想編寫以太坊的智能合約,就得學習並了解 Solidity 語法、ERC 及鏈上交互流程,這幾個理解了代碼就能寫對了,剩下的是部署。

學習 Solidity#

編程經驗豐富的人只要搂一眼就知道 Solidity 是面向對象的靜態類型語言,雖說有一些陌生的關鍵字,但不妨礙我把它整體看作是披著「合約」外衣的「類」

因而,對 TS、Java 等有類型的基於類的編程語言熟悉的話,能夠通過建立映射關係很快地初步了解 Solidity。

contract 關鍵字可認為是 class 關鍵字的領域特定變形,更加語義化地表達「合約」這個概念,因此寫一個合約相當於寫類。

狀態變量用於存儲合約內的數據,相當於類的成員變量,即類屬性。

函數既可定義在合約內部,也可在外部 —— 前者相當於類的成員函數,即類方法;後者則是普通函數,通常是一些工具函數。

不像 TS 和 Java,在 Solidity 中訪問可見性標識不是在最前面,而且對變量與函數來說位置是不一致的,這有點反直覺。

privatepublic 的語義跟其他語言是一樣的,但沒有 protected,取而代之的是 internal,另外還多了一個表示僅供外部調用的 external

函數修飾符相當於 TS 裝飾器或 Java 註解,可以進行面向切面編程,即 AOP;函數與函數修飾符都可被衍生的合約覆蓋。

以下幾種類型都可看作是 ES 中的對象,但使用場景有所不同:

  • 結構體(struct)用於定義實體;
  • 枚舉(enum)是有限選項的集合;
  • 映射(mapping)則是無限的選項。

Solidity 支持多重繼承與函數多態性,能夠更好地組合復用;由於合約的開發有 ERC 驅動的傾向,多重繼承的副作用應該不會像在其他語言中那麼嚴重。

鑑於 Solidity 是為區塊鏈而生,以及區塊鏈本身及應用場景的特性,通過事件與外部通信和遇到錯誤時回滾之前的操作可以說是「剛需」,所以在語法層面支持事件與錯誤相關處理。

require() 這個函數的用法對我來說也是有點特別的,require(initialValue > 999, "Initial supply must be greater than 999."); 就相當於以下 ES 代碼的簡明語義化版:

if (initialValue <= 999) {
  throw new Error('Initial supply must be greater than 999.');
}

了解 ERC#

在以太坊中,「ERC」的全稱為「Ethereum Request for Comments」,是 EIP(Ethereum Improvement Proposal)的一個類型,定義了智能合約應用程序相關標準和約定。

由於 Web3 所推崇的是去中心化與開放性,保障智能合約應用程序的互操作性就成了基本要求,因此作為這方面標準的 ERC 就顯得十分重要。

以太坊智能合約應用程序開發中最基本的 ERC 有以下兩個:

  • ERC-20—— 同質化代幣,作為類金融系統的基礎設施,如虛擬貨幣、貢獻積分;
  • ERC-721—— 非同質化代幣(NFT),作為身份系統的基礎設施,如勳章、證書、門票。

實際上,可把 ERC 看作是權威的 API 文檔。

編寫智能合約#

開發智能合約應用程序時,需要選擇一個框架來輔助,貌似用 Hardhat 和 Foundry 的比較多 —— 我選用前者,因為它對 JS 技術棧友好,即對從前端開發轉型的人友好。

在 IDE 的選擇上,很多人會去使用以太坊官方提供的 Remix,而我則繼續使用 VS Code,主要是想在剛入門時儘量減少學習成本。

對 Hardhat 不了解的話,可按照官方教程選擇性地一步步搭建運行環境,所生成的目錄結構中除了 hardhat.config.ts 這個配置文件外,基本只需關注 4 個文件夾及其文件:

  • contracts—— 智能合約源碼;
  • artifacts—— 通過 hardhat compile 生成的編譯後文件;
  • ignition—— 基於 Hardhat Ignition 部署智能合約用的;
  • test—— 智能合約功能測試代碼。

ignition 中也會生成編譯後的文件,但與 artifacts 不同,是跟被部署的目標鏈綁定的,也就是生成到要部署的鏈 ID 的文件夾下。

作為訓練營作業的那 3 個 task,都涉及到 ERC-20 代幣、ERC-721 代幣和 NFT 市場這 3 個合約,其中前兩個代幣合約可借助經過驗證的 OpenZeppelin Contracts,以其為基礎進行擴展。

我的 ERC-20 代幣 RaiCoin 的實現代碼如下:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract RaiCoin is ERC20("RaiCoin", "RAIC") {
  constructor(uint256 initialValue) {
    require(initialValue > 999, "Initial supply must be greater than 999.");
    _mint(msg.sender, initialValue * 10 ** 2);
  }

  function decimals() public view virtual override returns (uint8) {
    return 2;
  }
}

最好是在初始化時就 mint 一定量的代幣(通常數目很大),並把擁有者設為自己的賬戶地址,否則在過後進行交易時會提示沒有餘額,處理起來更麻煩。

上面代碼中的 msg.senderconstructor() 中時實際上是部署合約的賬戶地址,如果是用自己的賬戶地址部署,那初始代幣就全進自己賬戶中了。

由於自己的 ERC-20 代幣只是隨便玩玩的性質,並不會增值,可以考慮覆蓋 OpenZeppelin 中的 decimals() 而把數值設置小點。

下面是 ERC-721 代幣 RaiE 的實現代碼:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol";

contract RaiE is ERC721 {
  uint256 private _totalCount;

  constructor() ERC721("RaiE", "RAIE") {
    _totalCount++;
  }

  function mint() external returns (uint256) {
    uint256 tokenId = _totalCount;

    _safeMint(msg.sender, tokenId);

    _totalCount++;

    return tokenId;
  }
}

我只額外實現了一個 mint(),且不帶任何參數,只是單純地發幣,這是為什麼呢?NFT 不是該有相應的圖片嗎?具體原因下文會說。

這兩個代幣合約算是白給的,自己無需寫多少代碼,真正需要思考的地方主要集中在 NFT 市場合約當中,比如 ——

市場中的 NFT 列表是否要分頁?

分頁的話,每次翻頁時的延遲會比較明顯,前端的用戶體驗不好;但不分頁的話,NFT 數量多時也會有這種問題。

NFT 的圖片 URL 該存哪裡?是 NFT 合約還是市場合約中?

理論上該存進 NFT 合約,但若如此,獲取 NFT 列表時就會頻繁通過外部調用的方式訪問 NFT 合約,影響性能與用戶體驗。

應該在 NFT 合約中維護一個「誰擁有哪些代幣」的可被外部獲取的列表嗎?

若要有,數據與市場合約中相比是冗餘的,會顯得 NFT 合約很是臃腫;若沒有,就無法顯性地知道都有哪些代幣,分別屬於誰。

可以看出,僅依賴區塊鏈相關技術去做一個產品級的應用,就目前而言是有很大局限性的,用戶體驗會很差!

也就是說,產品的性能和體驗還是得靠以往的應用架構去支撐,區塊鏈僅作為身份驗證及部分數據的「備份」用。

因此,我暫時放棄了以做產品為導向的思維方式,不去糾結哪裡是否合理之類的事情,轉變為先滿足作業要求為主 —— 只要有相關功能就行。

這樣一來,決策就很容易做了 —— 怎樣能更快地完成作業就怎麼來!於是,上面的 3 個疑惑很快就消除了:

  • 市場中的 NFT 列表不進行分頁 —— 只會有不幾個 NFT;
  • NFT 的圖片 URL 存在市場合約中 ——NFT 合約只被自己的市場合約使用;
  • NFT 合約中不維護代幣歸屬的列表 —— 臨時操作時能記住是哪些賬戶 mint 了哪些代幣。

在實現 NFT 市場 RaiGallery 時我發現,只有數組是可被遍歷的,mapping 不行,並且初始化時指定長度的數組不能用 .push() 添加元素,只能用索引:

contract RaiGallery {
  struct NftItem { address seller; address nftContract; uint tokenId; string tokenUrl; uint256 price; uint256 listedAt; bool listing; }

  struct NftIndex { address nftContract; uint tokenId; }

  NftIndex[] private _allNfts;

  function getAll() external view returns (NftItem[] memory) {
    // 初始化時指定了數組長度
    NftItem[] memory allItem = new NftItem[](_allNfts.length);
    NftIndex memory nftIdx;

    for (uint256 i = 0; i < _allNfts.length; i++) {
      nftIdx = _allNfts[i];
      // 這裡用 `allItem.push(_listedNfts[nftIdx.nftContract][nftIdx.tokenId])` 的話會報錯
      allItem[i] = _listedNfts[nftIdx.nftContract][nftIdx.tokenId];
    }

    return allItem;
  }
}

調試智能合約#

寫完智能合約源碼,就得先寫測試代碼過一遍,把一些基礎的問題暴露出來並解決掉。

如上文所述,在 Hardhat 項目中測試代碼是放在 test 文件夾下的,基本是每個文件對應一個合約,當然也可將不同文件間的可復用邏輯提取出來放到額外的文件中,如 helper.ts

測試代碼是基於 Mocha 和 Chai 的 API 去寫,在真正開始測試合約功能之前,需要先部署合約到本地環境中,可以是內置的 hardhat,也可啟動一個本地節點 localhost,我暫且選擇前者。

這時,部署的方式能夠復用 Hardhat Ignition 模塊,但我還沒搞懂它是怎麼用的,就採用更容易理解的 loadFixture()

搞測試還挺費勁的,感覺差不多一天的時間都耗進去了,但在這個過程中我對 ERC-20 代幣、ERC-721 代幣、NFT 市場及用戶這四方之間該如何交互有了更深的了解,如:

  • 直接用合約實例去調方法的話,那調用者就是合約本身,得用 合約實例.connect(某個賬戶) 後再去調用才能模擬與用戶間的操作;
  • NFT 的擁有者得通過 .setApprovalForAll(市場合約地址, true) 把自己的全部 NFT 授權給 NFT 市場後才能在市場中上架出售。

覺得智能合約的單方測試差不多了,就該部署到本地節點與前端進行聯調了,這回要用到 Hardhat Ignition 模塊了。

在去看文檔學習時,感覺有點晦澀難懂,看着看着就想睡覺的那種;但現在再回過頭看,每個模塊實際上就是在描述部署該模塊對應的合約時該如何初始化。

Hardhat Ignition 支持子模塊,通過 .useModule() 使用,能夠在編譯並部署模塊時把子模塊一同處理了,也就是說 ——

假設我有 RaiCoin.tsRaiE.tsRaiGallery.ts 三個模塊,其中 RaiGallery.ts 在部署時需要 RaiCoin.ts 部署後返回的地址,那就可將 RaiCoin.ts 作為 RaiGallery.ts 的子模塊:

import { buildModule } from '@nomicfoundation/hardhat-ignition/modules';
import RaiCoin from './RaiCoin';

export default buildModule('RaiGallery', m => {
  const { coin } = m.useModule(RaiCoin);
  const gallery = m.contract('RaiGallery', [coin]);

  return { gallery };
});

這樣的話,RaiE.ts 是單獨部署,而在部署 RaiGallery.ts 時會級聯部署 RaiCoin.ts,所以只執行兩次部署命令即可。

接著,把 hardhat.config.ts 中的 defaultNetwork 配置項改為 'localhost',在 Hardhat 項目根目錄下執行 npx hardhat node 啟動本地節點,再開啟一個終端窗口部署智能合約:

  • 執行 npx hardhat ignition deploy ./ignition/modules/RaiE.ts 部署 ERC-721 代幣合約;
  • 執行 npx hardhat ignition deploy ./ignition/modules/RaiGallery.ts 部署 ERC-20 代幣合約和 NFT 市場合約。

全部部署成功後,會在 ignition/deployments/chain-31337 文件夾(「31337」是本地節點的鏈 ID)中生成編譯後的合約相關文件:

  • deployed_addresses.json 中羅列了合約地址;
  • artifacts 文件夾下的 JSON 文件中包含了合約的 ABI。

上述兩項關鍵信息需要複製粘貼到前端項目的全局共用變量中,以供聯調時使用。

在開始聯調之前,需在 MetaMask 錢包中做兩件事:

我在前端部分所依賴的第三方庫和框架主要有 Vite、React、Ant Design Web3 和 Wagmi;由於前端是我所熟悉的,沒啥心得體會,就不多贅述了。

但是,在開發前端部分時,有一個點讓我糾結了一段時間 ——

雖說程序上是要先 mint 出一個新的 NFT 才能上架到市場進行交易,但在界面上的體現應該是一步到位的,即填完 NFT 相關信息點「確定」後就直接上架了。

而作業的要求是先 mint 後上架的兩步操作,這讓我覺得有點不合理,或者說用戶體驗不好。

最終還是因自己對 Wagmi 使用不熟而實在沒想出實現方案,且急於交作業,就沒再繼續糾結下去……😂😂😂

聯調時若遇到問題卡住,可按下面步驟依次排查:

  1. 上架出售 NFT 時需先調用 NFT 代幣合約的 setApprovalForAll 對市場合約進行授權,以托管市場代為轉移 NFT;
  2. 發送上架出售請求之前需用 viem 或 ethers 的 parseUnits 轉換為符合自己 ERC-20 代幣合約中定義的 decimals() 的數(默認是 18);
  3. 購買 NFT 前在錢包中檢查下當前賬戶自己自定義的 ERC-20 代幣餘額夠不夠,避免將以太幣(ETH)看作是自己 ERC-20 代幣的餘額
  4. 購買 NFT 時需先調用自己 ERC-20 代幣合約的 approve 對市場合約進行授權,以托管市場代為轉帳。

聯調也結束了,終於,到了最後一個環節 —— 部署到 Sepolia 測試網!

這需要有 Sepolia 的以太幣,一般的獲取方式是到那些「水龍頭」一滴一滴地接,每天只能弄一丁點兒,多虧 @Mika-Lahtinen 提供了一種 PoW 的方式,詳見 @zer0fire 的筆記《🚀極簡拧水龍頭教程 - 無需交易記錄或賬戶餘額》。

此時,將目光移回到 Hardhat 項目中,打開 hardhat.config.ts 文件,將 defaultNetwork 臨時改為 'sepolia',並在 networks 中添加一個 sepolia

const config: HardhatUserConfig = {
  defaultNetwork: 'sepolia',  // 默認網絡臨時改成這個
  networks: {
    sepolia: {
      url: '你的 Sepolia endpoint URL',
      accounts: ['你的錢包賬戶私鑰'],
    },
  },
};

其中,Sepolia endpoint 可通過註冊 InfuraAlchemy 賬號獲得。

然後,按照上文中部署到本地節點的流程再走一遍,在前端把測試網環境的功能驗證通過後就可以提交作業啦啦啦啦啦!

結語#

我把 NFT market 這個 dApp 相關的代碼全部在 ourai/my-first-nft-market 中開源了,打算日後把上文談及所糾結的點儘量都解決掉,並打造成這類 demo 的標杆。

由於裡面已經配置了 Sepolia 合約地址,可直接本地運行操作,歡迎參考,探討和指點。

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。