作為一個持續看衰傳統互聯網行業 Web 前端開發的大齡前端工程師,我一直在尋找一個能最大化利用自己已有知識與技能的出路 —— 選擇了向 Web3 領域的全棧開發轉型。
在做出此決定之時,我對 Web3 還不甚了解,無論是站在從業者或是普通用戶的立場,我急需一個能讓我快速入門的途徑!
機緣巧合之下,我知道了 OpenBuild 的《Web3 前端訓練營》,從內容介紹來看應該能滿足我的需求,就毫不猶豫地報名了 —— 都免費的了,還有「瑪尼」賺,猶豫個鬼啊!
本文內容是訓練營課程的實戰筆記,圍繞著「有前端開發基礎的智能合約純小白如何開發出自己的第一個 NFT market dApp」去寫,也就是說,會涵蓋 task 3、task 4 和 task 5。
由於我是剛向 Web3 轉型的初學者,很多東西不太懂,以下內容僅代表個人理解,有錯漏謬誤歡迎指出。
我認為「智能合約」這個名字源於它所起到的業務作用,而對於開發者來說,它僅僅是軟件程序而已,需要用某種編程語言去寫代碼。
所以,要想編寫以太坊的智能合約,就得學習並了解 Solidity 語法、ERC 及鏈上交互流程,這幾個理解了代碼就能寫對了,剩下的是部署。
學習 Solidity#
編程經驗豐富的人只要搂一眼就知道 Solidity 是面向對象的靜態類型語言,雖說有一些陌生的關鍵字,但不妨礙我把它整體看作是披著「合約」外衣的「類」。
因而,對 TS、Java 等有類型的基於類的編程語言熟悉的話,能夠通過建立映射關係很快地初步了解 Solidity。
contract
關鍵字可認為是 class
關鍵字的領域特定變形,更加語義化地表達「合約」這個概念,因此寫一個合約相當於寫類。
狀態變量用於存儲合約內的數據,相當於類的成員變量,即類屬性。
函數既可定義在合約內部,也可在外部 —— 前者相當於類的成員函數,即類方法;後者則是普通函數,通常是一些工具函數。
不像 TS 和 Java,在 Solidity 中訪問可見性標識不是在最前面,而且對變量與函數來說位置是不一致的,這有點反直覺。
private
與 public
的語義跟其他語言是一樣的,但沒有 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.sender
在 constructor()
中時實際上是部署合約的賬戶地址,如果是用自己的賬戶地址部署,那初始代幣就全進自己賬戶中了。
由於自己的 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.ts
、 RaiE.ts
和 RaiGallery.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 錢包中做兩件事:
- 將 Hardhat 本地節點添加進網絡,可參考油管視頻《Metamask 添加本地測試網絡》;
- 按官網所示把自己正在測的 ERC-20 代幣合約地址加上去以方便查看賬戶餘額。
我在前端部分所依賴的第三方庫和框架主要有 Vite、React、Ant Design Web3 和 Wagmi;由於前端是我所熟悉的,沒啥心得體會,就不多贅述了。
但是,在開發前端部分時,有一個點讓我糾結了一段時間 ——
雖說程序上是要先 mint 出一個新的 NFT 才能上架到市場進行交易,但在界面上的體現應該是一步到位的,即填完 NFT 相關信息點「確定」後就直接上架了。
而作業的要求是先 mint 後上架的兩步操作,這讓我覺得有點不合理,或者說用戶體驗不好。
最終還是因自己對 Wagmi 使用不熟而實在沒想出實現方案,且急於交作業,就沒再繼續糾結下去……😂😂😂
聯調時若遇到問題卡住,可按下面步驟依次排查:
- 上架出售 NFT 時需先調用 NFT 代幣合約的
setApprovalForAll
對市場合約進行授權,以托管市場代為轉移 NFT; - 發送上架出售請求之前需用 viem 或 ethers 的
parseUnits
轉換為符合自己 ERC-20 代幣合約中定義的decimals()
的數(默認是18
); - 購買 NFT 前在錢包中檢查下當前賬戶自己自定義的 ERC-20 代幣餘額夠不夠,避免將以太幣(ETH)看作是自己 ERC-20 代幣的餘額;
- 購買 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 可通過註冊 Infura 或 Alchemy 賬號獲得。
然後,按照上文中部署到本地節點的流程再走一遍,在前端把測試網環境的功能驗證通過後就可以提交作業啦啦啦啦啦!
結語#
我把 NFT market 這個 dApp 相關的代碼全部在 ourai/my-first-nft-market
中開源了,打算日後把上文談及所糾結的點儘量都解決掉,並打造成這類 demo 的標杆。
由於裡面已經配置了 Sepolia 合約地址,可直接本地運行操作,歡迎參考,探討和指點。