原文作者:@Web3 Mario(https://x.com/web3_mario)
承接上一篇關於 TON 技術介紹的文章,這段時間深入研究了一下 TON 官方開發文檔,感覺學習起來還是有些門檻,當前的文檔內容似乎更像是一個內部開發文檔,對新入門的開發者來說不太友好,因此試着以自己的學習軌跡,梳理一系列關於 TON Chain 項目開發的文章,希望可以對大家快速入門 TON DApp 開發有一些幫助。行文有誤也歡迎大家指正,一起學習。
在 EVM 中開發 NFT 和在 TON Chain 上開發 NFT 有哪些不同
發行一個 FT 或 NFT 對於 DApp 開發者來說通常是最基本的需求。因此我也以此作爲學習入口。首先讓我們來了解以下在 EVM 技術棧中開發一個 NFT 和在 TON Chain 中的區別。基於 EVM 的 NFT 通常會選擇繼承 ERC-721 的標準。所謂 NFT,指的是不可分割的加密資產類型,且每個資產具有唯一性,即存在某些專屬的特性。而 ERC-721 就是對這個類型的資產的一種通用的開發範式。讓我們看一個常見的 ERC 721 合約需要實現哪些函數以及記錄哪些信息。下圖是一個 ERC 721 接口。可以看到與 FT 不同,在轉賬接口中需要輸入的是待轉賬的 tokenId 而非數量。這個 tokenId 也是 NFT 資產唯一性最基本的體現,當然爲了承載更多的屬性,通常會爲每個 tokenId 記錄一個 metadata,這個 metadata 是一個外部鏈接,保存了該 NFT 的其他可擴展數據,例如一張 PFP 圖片的鏈接,某些屬性名稱等。
對於熟悉 Solidity 或者熟悉面向對象的開發者來說,實現這樣一個智能合約是件容易的事,只要定義好合約中需要的數據類型,例如一些關鍵的映射關係 mapping,並根據所需功能開發相應的對這些數據的修改邏輯,即可實現一個 NFT。
然而在 TON Chain 中這一切變的不太相同,造成不同的核心原因有兩個:
在 TON 中數據的存儲是基於 Cell 實現的,而同一個賬戶的 Cell 通過有向無環圖來實現。這樣就導致需要之久化存儲的數據不能無邊界的增長下去,因爲一個有向無環圖來說,數據深度決定的查詢成本,當深度無限延伸之後,有可能造成查詢成本過高,從而導致合約陷入死鎖問題。
爲了追求高併發性能,TON 捨棄了串行執行的架構,轉而採用了一個專爲並行而生的開發範式,Actor 模型,來重構執行環境。這就造成了一個影響,智能合約之間只能通過發送所謂內部消息的方式異步調用,注意無論是狀態修改類型或只讀類型的調用都需要遵循這個原則,除此之外,也需要仔細考慮異步調用若失敗,如何處理數據回滾的問題。
當然關於技術上其他不同點在上一篇文章中有過詳細的論述,本篇文章希望可以聚焦在智能合約開發上,所以不展開討論。上述兩條設計原則讓 TON 中智能合約開發與 EVM 產生了很大區別。在開始的論述中,我們知道一個 NFT 合約中需要定義一些映射關係,也就是 mapping,來保存 NFT 相關的數據。其中最重要的就是 owners,這個 mapping 存儲了某個 tokenID 對應的 NFT 的所有者地址的映射關係,決定了 NFT 的所有權,轉賬就是對該所有權的修改。由於理論上這是一個可以無邊界的數據結構,需要儘量避免。因此官方推薦以是否存在無邊界數據結構作爲分片的標準。即當有類似的數據存儲需求時,通過主從合約的範式來替代,通過創建子合約的方式來管理每個 key 對應的數據。並通過主合約管理全局參數,或幫助處理子合約之間的內部信息交互。
這也就意味着在 TON 中的 NFT 也需要採用類似的架構來設計,每個 NFT 都是一個獨立的子合約,保存了諸如所有者地址,metadata 等專屬數據,並通過一個主合約來管理全局數據,例如 NFT name,symbol,總供應量等。
在明確了架構後,接下來就需要解決核心功能的需求了,由於採用了這個主從合約的方式,因此就需要明確哪些功能由主合約承載,哪些功能由子合約承載,並且兩者之間通過什麼內部信息溝通,同時當出現執行錯誤時,如何回滾之前的數據。通常情況下,在開發複雜的大型項目之前,通過一個類圖並明確彼此之間的信息流,並仔細思考內部調用失敗後的回滾邏輯是必要的,當然上述 NFT 開發雖然簡單,但也可以做類似驗證。
從源碼學習開發 TON 智能合約
TON 選擇了設計一種類 C 語言的、靜態類型語言,名爲 Func 來作爲智能合約開發語言,那麼接下來就讓我們從源碼來學習如何開發 TON 智能合約,我選擇了 TON 官方文檔中的 NFT 示例來進行介紹,感興趣的小夥伴可以自行去查閱。在這個 case 中實現了一個簡單的 TON NFT 例。讓我們看下合約結構,共分爲兩個功能合約以及三個必要的庫。
這兩個主要的功能合約即按照上述的原則進行設計,首先讓我們來看下主合約 nft-collection 的代碼:
這引入了第一個知識點,如何在 TON 智能合約中持久化存儲數據,我們知道在 Solidity 中數據的持久化存儲是由 EVM 根據參數的類型自動處理的,通常情況下,智能合約的狀態變量將在執行結束後根據最新值自動被持久化存儲,開發者並不需要考慮這個過程。但在 Func 中情況並不如此,開發者需要自己來實現相應的處理邏輯,這個情況有點類似於 C 和 C++ 需要考慮 GC 的過程,但其他新的開發語言通常將這部分邏輯自動化處理。我們來看下代碼,首先引入一些需要的庫,然後看到第一個函數 load_data 用於讀取被持久化存儲的數據,其邏輯爲首先通過 get_data 返回持久化合約存儲 cell,注意這是由標準庫 stdlib.fc 實現的,通常情況下可以將其中的一些函數視爲系統函數來使用。
該函數的返回值類型爲 cell,這是 TVM 中的 cell 類型。在之前的介紹中,我們已經知道 TON 區塊鏈中的所有持久數據都存儲在 cell 樹中。每個 cell 最多有 1023 位任意數據和最多四個對其他 cell 的引用。cell 在基於堆棧的 TVM 中用作內存。cell 中保存的是緊編碼後的數據,要想獲取其中具體的明文數據,需要將 cell 轉換爲被稱爲 slice 的類型。cell 可以通過 begin_parse 函數轉換成爲 slice 類型,然後可以通過從 slice 加載數據位和對其他 cell 的引用來獲得 cell 中的數據。注意 15 行代碼中的這種調用方法是一個 func 中的語法糖,可以直接調用第一個函數的返回值的第二個函數。並在最後按照數據持久化順序依次加載相應的數據。注意這個過程和 solidity 不同,並不是根據 hashmap 調用,所以這個調用順序不能亂。
在 save_data 函數中,邏輯與之類似,只不過這是一個反向的過程,這就引入了下一個知識點,一個新的類型 builder,這是 cell 構建器的類型。數據位和對其他 cell 的引用可以存儲在構建器中,然後構建器可以最終化爲新 cell。首先通過標準函數 begin_cell 創建一個 builder,並依次通過 store 相關函數存儲相關函數,注意上文中調用順序與此處存儲順序需要保持一致。最後通過 end_cell 完成新 cell 構建,這時該 cell 被管理在內存中,最後通過最外層的 set_data,就可以完成對該 cell 的持久化存儲。
接下來讓我們來看下業務相關函數,首先需要先介紹下一個知識點,如何通過合約創建一個新的合約,這在剛剛介紹的主從架構中將被經常用到。我們知道在 TON 中,智能合約之間的調用是通過發送內部消息的方式來實現的。這是通過一個名爲 send_raw_message 來實現的,注意第一個參數是 message 編碼後的 cell,第二個參數是標識位,用於表明該交易的執行方式的區別,在 TON 中設置了不同的內部消息發送的執行方式,目前有 3 種消息 Modes 和 3 種消息 Flags。可以將單一 Mode 與多個(也許沒有)標誌組合以獲得所需的 mode。組合只是意味着將它們值的和填入即可。下面給出了 Modes 和 Flags 的描述表格:
那麼讓我們來看第一個主要函數,deploy_nft_item,顧名思義,這是一個用於創建或者說鑄造新 NFT 實例的函數,經過一番操作編碼一個 msg 後,通過 send_raw_message 發送該內部合約,並選擇了 flag 1 的發送標識位,僅將編碼中指定的 fee 作爲本次執行的 gas fee。經過上文的介紹我們很容易意識到,這個編碼規則應該是對應創建一個新的智能合約的方式。那讓我們來看看具體是怎麼實現的。
讓我們直接看 51 行,上面兩個函數是用於生成 message 所需信息的輔助函數,因此我們後面再來看,這是一個用於創建智能合約的內部消息的編碼過程,中間的一些數字其實也是一些標識位,用於說明該內部消息的需求,這裏要引入下一個知識點,TON 選擇了一種名爲 TL-B 的二進制語言來描述消息的執行方式,並且根據設置不同的標記位來實現某些特定功能的內部消息,最容易想到的兩個使用場景,新合約創建和已部署合約函數調用。而 51 行的這種方式即對應了前者,創建一個新的 nft item 合約,而這主要是通過 55 , 56 , 57 三行指定的。首先 55 行這一大串數字是一系列標識位,注意 store_uint 的第一個入參是數值,第二個是位長,其中決定了該內部消息是合約創建的是後三個標記位,以及相應二進制值位爲 111 (十進制即爲 4+ 2+ 1),其中前兩個表示該消息將附帶 StateInit 數據,這個數據即爲新合約的源碼,以及初始化所需的數據。而後一個標記位表示內部消息附載,即希望執行相關邏輯以及需要的參數。因此你會看到在第 66 行代碼並沒有設置該三位數據,則表明的是一次對已部署合約的函數調用。具體的編碼規則在這裏查看。
那麼 StateInit 的編碼規則即對應了 49 行代碼,通過 calculate_nft_item_state_init 計算,注意 stateinit 數據的編碼也遵循了一種既定的 TL-B 編碼規則,除了一些標記位之外,主要涉及到兩部分新合約 code 和以及初始化 data。data 的編碼順序需要與新合約指定的持久化 cell 的存儲順序保持一致。在 36 行可以看到,初始化數據有 item_index,即類似與 ERC 721 中的 tokenId,以及由標準函數 my_address 返回的當前合約地址,即爲 collection_address,這個數據的順序與 nft-item 中的聲明保持一致。
接下來一個知識點就是在 TON 中,所有未生成的智能合約而可以預先計算其生成後的地址,這點與 Solidity 中的 create 2 函數類似,在 TON 中新地址的生成由兩部分組成,workchain 標識位與 stateinit 的哈希值拼接而成,前者在之前的介紹中我們已經知道是爲了相應 TON 無限分片架構而需要被指定的,當前爲統一值。由標準函數 workchain 獲得。後者由標準函數 cell_hash 獲得。因此回到該例子,calculate_nft_item_address 即爲預先計算新合約地址的函數。並將生成值在第 53 行編碼到 message 中,作爲該內部消息的接收地址。而 nft_content 則對應了對被創建合約的初始化調用,具體的實現在下一篇文章中介紹。
至於 send_royalty_params,則需要是對某隻讀請求的內部消息的相應,在之前的介紹中,我們特意強調了在 TON 中內部消息不光包含可能會修改數據的操作,只讀操作也需要通過這種方式實現,因此該合約即爲此類操作,首先值得注意的是 67 行表示響應該請求後對請求者回調函數的標記,記下來即爲返回的數據,分別是請求的 item index,以及相應的 royalty 數據。
接下來讓我們引入下一個知識點,TON 中智能合約只有兩個統一的入口,名爲 recv_internal 和 recv_external,其中前者爲所有內部消息的統一調用入口,後者爲所有外部消息的統一調用入口,開發者需要在函數內部根據需求,採用類似 switch 的方式根據 message 指定的不同標記位來響應不同的請求,這裏的標記位即爲上述 67 行的回調函數標記。回到該例子,首先對 message 進行空位檢查,通過後分別解析 message 中的信息,首先在 83 行解析獲得 sender_address,該參數將用於後續的權限檢查,注意這裏的~操作符,屬於另一個語法糖。這裏先不展開將。接下來解析 op 操作標記位,而後根據不同的標記位,分別處理相應請求。其中即根據某些邏輯分別調用了上述的函數。例如響應對 royalty 參數的請求,或鑄造新的 nft,並自增全局 index。
接下來一個知識點對應了 108 行,想必大家通過命名也可以知道該函數的處理邏輯,與 Solidity 中的 require 函數類似,Func 中通過標準函數 throw_unless 來拋出異常,第一個入參爲錯誤碼,第二個是檢查位布爾值,若位 false 則拋出異常,並附帶該錯誤碼。而在這行中通過 equal_slices 來判斷上面解析到的 sender_address 是否等於該合約持久化存儲的 owner_address,做權限判斷。
最後爲了使代碼結構更清晰,開始閒了一系列幫助獲取持久化信息的輔助函數,在這裏就不展開介紹了,開發者可以參考這種結構來開發自己的智能合約。
TON 生態的 DApp 開發實在是件有趣的事情,與 EVM 的開發範式有很大差異,因此我會通過一系列文章來介紹如何在 TON Chain 中開發 DApp。與大家共同學習,把握這波機會。也歡迎大家在 twitter 上與我互動,碰撞一些新的有趣的 dapp idea,一起開發。