簡介
2024 年,Solana 強勢崛起,成為鎖倉量(TVL)排名第四的公鏈,吸引了投資者與開發者的目光。
BlockSec 策劃了「Solana 簡明教程」(Solana Simplified)系列,內容涵蓋 Solana 基本概念、編寫智能合約的教學,以及分析 Solana 交易的指南。我們的目標是協助讀者理解 Solana 生態系統,並掌握開發專案與進行交易所需的必要技能。
在本系列的第一篇文章中,我們深入探討了 Solana 網路的關鍵概念,包括其運作機制、帳戶模型與交易方式,為編寫正確且高效能的 Solana 合約奠定了堅實的基礎。
本文將帶領您撰寫一個用於發布並顯示文章的 Solana 程式(即 Solana 智能合約)。這將有助於鞏固第一篇文章中的概念,並介紹一些我們尚未討論過的 Solana 功能。相關程式碼與測試程式已發布於 GitHub。
環境設定
注意:以下指令僅適用於 Ubuntu 系統。部分指令可能無法在 Windows 和 macOS 系統下執行。
👉 您可以使用替代指令,或參考「設定本地開發環境並安裝 Solana CLI」來解決此問題。
我們將在本地環境中編譯並部署 Solana 程式。在深入進行 Solana 程式開發之前,我們需要安裝一些指令與依賴軟體。
Rust
Solana 程式主要使用 Rust 程式語言編寫,因此我們需要執行下列指令來安裝 Rust:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
node.js & TypeScript
測試腳本由 TypeScript 編寫,因此我們需要安裝 Node.js 與 TypeScript 工具鏈:
curl -fsSL https://deb.nodesource.com/setup_15.x | sudo -E bash -
sudo apt-get install -y nodejs npm
npm install -g typescript
npm install -g ts-node
Solana CLI
接下來,我們需要安裝 Solana CLI 工具,它提供了建立錢包、部署合約等任務的指令:
sh -c "$(curl -sSfL https://release.solana.com/stable/install)"
執行下列指令,將變更套用到目前的終端機:
source ~/.profile
您可以執行 solana --version 來確認 Solana CLI 是否已成功安裝:
$ solana --version
solana-cli 1.17.25 (src:d0ed878d; feat:3580551090, client:SolanaLabs)
本地錢包
接下來,執行下列指令來產生一個本地檔案系統錢包:
solana-keygen new
預設情況下,此指令會在 ~/.config/solana/id.json 建立一個私鑰檔案,我們稍後會在測試腳本中使用。您可以使用 solana address 指令來查看產生的地址:
$ solana address
9FcGDHvzs8Czo4B1pQq8GPdXfdGHqNqayvh6bu4hVGfy
Devnet
由於程式將部署在 Devnet(開發測試網)上,我們需要執行下列指令將 Solana CLI 切換至 Devnet:
solana config set --url https://api.devnet.solana.com
SOL 代幣
由於部署合約與發送測試交易並非免費,我們需要申請一些 SOL 代幣。您可以執行 solana airdrop 2,或直接從公開的網頁水龍頭請求代幣。
編寫程式
我們將介紹的程式允許使用者發布文章,並列出所有已發布的文章。它處理三種類型的指令:
- init 指令:透過建立一個資料帳戶來初始化程式,用以儲存目前的文章最大索引值。
- post 指令:將發布的文章儲存在新的資料帳戶中。
- list 指令:在日誌(log)中列印所有已發布的文章。
程式架構如下:
$ tree program/
program/
├── Cargo.lock
├── Cargo.toml
└── src
├── instructions
│ ├── init.rs
│ ├── list.rs
│ ├── mod.rs
│ └── post.rs
├── lib.rs
└── processor.rs
Cargo.toml 指定了該專案的外部函式庫:
[dependencies]
borsh = "0.10.0"
borsh-derive = "0.10.0"
solana-program = "=1.17.25"
注意:
solana-program的版本應與所安裝的 Solana CLI 版本一致(您可以透過solana --version檢查)。否則,可能會在編譯過程中遇到錯誤。
合約的入口函式定義在 processor.rs 中。該檔案開頭導入了必要的依賴(我們將會略過其他 Rust 檔案中類似的部分):

接下來是合約入口函式的定義:

在程式的第 10 行,Solana 函式庫的 entrypoint! 巨集指定了入口函式為 process_instruction。此函式接收三個參數:
- program_id:目前程式已部署的地址。
- accounts:指令中涉及的所有帳戶。
- instruction_data:用於處理指令的位元組陣列。
process_instruction 會提取 instruction_data 的第一個位元組以識別指令類型,並呼叫相應的函式進行處理。接下來,我們看看這三個函式是如何實作的。
init
這三個函式分別定義在 program/src/instructions 目錄下同名的檔案中。我們先從 init.rs 開始。

由於 init 指令不需要額外的位元組陣列,它只接受兩個參數:program_id 與 accounts。
在第 12 至 15 行之間,程式按順序提取必要的帳戶資訊,然後呼叫 find_program_address 函式來計算用於儲存目前文章最大索引值之資料帳戶的地址 index_pda_key。接著,程式會斷言 index_pda_key 等於 index_pda 的地址。
這裡 index_pda 的地址是一種特殊的地址,稱為 PDA(程式衍生地址,Program Derived Addresses)。與根據公鑰產生的「錢包」帳戶地址不同,PDA 是由一個可選的位元組陣列、一個稱為「bump」的位元組以及一個程式地址所衍生。位元組陣列與程式地址由呼叫者明確提供,而 bump 則由 find_program_address 函式自動生成並返回。Bump 保證了以這種方式建立的地址,不會與透過公開金鑰產生的地址發生碰撞。

確認 index_pda 的地址無誤後,我們使用 SystemProgram 提供的 create_account 指令來建立 index_pda。在第 22 行,程式建立了一個 IndexAccountData 型別的變數,用於記錄目前文章的最大索引(初始化為 0)。如下圖所示,此型別實作了 BorshSerialize 與 BorshDeserialize 特徵,允許其以 Borsh 格式進行序列化與反序列化:

第 23 至 24 行計算了儲存帳戶資料所需的空間,以及建立帳戶所需的最低租金。
第 27 至 37 行建立了一個來自 SystemProgram 的 create_account 指令,並使用 invoke_signed 方法呼叫它。在一個程式中呼叫另一個程式的指令,稱為 CPI(跨程式呼叫,Cross-Program Invocation)。在 Solana 中,您可以使用兩個函式來執行 CPI:invoke 與 invoke_signed。
invoke 函式直接處理給定的指令,其函式簽章如下:
pub fn invoke(
instruction: &Instruction,
account_infos: &[AccountInfo<'_>]
) -> Result<(), ProgramError>
invoke_signed 與 invoke 的唯一區別在於 invoke_signed 提供了一種為 PDA 帳戶簽名的方式。PDA 帳戶沒有公私鑰,因此無法直接提供簽名。invoke_signed 解決了這個問題。此函式接受一個稱為 signers_seeds 的額外參數,其函式簽章如下。
與 find_program_address 函式的運作方式類似,invoke_signed 根據提供的 signers_seeds 和呼叫者的 program_id 計算出一組 PDA 地址。如果 account_infos 包含這些 PDA 地址,它們將被標記為簽名者,就像它們已經提供了簽名一樣。
pub fn invoke_signed(
instruction: &Instruction,
account_infos: &[AccountInfo<'_>],
signers_seeds: &[&[&[u8]]]
) -> Result<(), ProgramError>
這裡因為 create_account 指令要求被建立的帳戶必須是簽名者,所以我們必須使用 invoke_signed 來為 index_pda 提供簽名。

在程式最後,合約呼叫 index_data 上的 serialize 方法將資料序列化,並寫入 index_pda 的 data 欄位中。
post_article

接下來我們看 post 指令的實作。與 init 指令不同,post 指令接收一篇已發布的文章,因此有一個額外稱為 instruction_data_inner 的參數,用於儲存文章的序列化資料。
如同往常,程式從提取指令所需的帳戶資訊開始。由於我們為每篇文章產生一個獨立的 PDA 帳戶,因此程式在第 23 行接收了一個額外的帳戶 new_article_pda。
與 init 指令類似,第 27 至 30 行使用 find_program_address 來驗證傳入的 index_pda 地址是否正確。接著我們反序列化其 data 欄位中的資料,以讀取目前已分配的最大索引。
第 33 至 40 行同樣驗證了 new_article_pda 的地址。程式使用字串「ARTICLE_PDA」與目前的索引來產生一個新地址。索引會隨著每篇文章的上傳而遞增,這保證了產生的地址是唯一的。

接下來,程式將 instruction_data_inner 反序列化為文章資料,其型別為 PostArticleData:

此結構體僅有兩個欄位:title 與 content。在第 45 至 49 行,程式執行檢查以確保文章不會太大而無法包含在單個帳戶中。

後續步驟也與 init 指令類似:程式首先計算帳戶所需的空間與租金,然後呼叫 SystemProgram 的 create_account 指令來為儲存文章建立 PDA 帳戶。最後,將發布的文章序列化到 new_article_pda 的 data 欄位中,並將目前的最大索引遞增 1,再序列化到 index_pda 的 data 欄位中。
list_articles

最後,讓我們看看 list 指令的實作。由於此指令需要列出所有文章,因此在第 15 行使用了一個向量(vector)來表示儲存文章的所有 PDA 帳戶,並且也會驗證 index_pda 帳戶的地址。

之後,程式驗證向量的大小是否與目前的最大索引相同,並檢查每個帳戶地址的正確性。

在程式最後,它會迭代每個帳戶,並使用 solana-program 中的 msg! 巨集,將反序列化後的文章內容逐一輸出。
交易測試
為了測試我們的合約,我們使用了儲存庫中 client/main.ts 的 TypeScript 腳本。

在腳本頂部,我們匯入了所有必要的函式庫並定義了三個全域常數。KEYPAIR_PATH 指定了使用 solana-keygen new 產生之私鑰檔案的路徑,PROGRAM_ID 是我們在上一節中已編寫並部署的程式地址,POST_ARTICLE_SCHEMA 則是文章序列化所使用的物件。


在腳本的主體中,首先呼叫 loadKeyFromFile 函式來解析私鑰檔案,並取得一個 Keypair 物件作為交易費用的支付者。接著,使用 @solana/web3.js 提供的 findProgramAddressSync 方法來計算 indexPda 的地址。此方法使用與 Rust 合約相同的演算法,確保在相同參數下會計算出相同的地址。connection 物件指定我們將使用 devnet 進行測試。

接下來,我們傳送第一筆初始化交易。我們建立了一筆包含單一指令的交易。此指令的 data 欄位僅包含一個位元組「0」,表示這是一次對 init 指令的呼叫。接著使用 sendAndConfirmTransaction 傳送並確認該交易。

第二筆交易會呼叫 post 指令來發布文章。從第 56 行至 63 行,腳本序列化了兩篇文章並計算出對應的 PDA 地址。隨後兩條 post 指令被新增至同一筆交易中,以發布這些文章。

最後一筆交易呼叫了 list 指令,並在交易確認後列印出日誌。
若要使用腳本測試程式,請將 KEYPAIR_PATH 取代為您的私鑰路徑(如果不是預設路徑)。接下來,透過執行下列指令來編譯並部署 Rust 智能合約:
cd program
cargo build-bpf
solana program deploy ./target/deploy/hello_solana.so
然後,複製部署後的地址並貼上到 PROGRAM_ID 欄位中。在專案根目錄下,執行 npm install 以安裝依賴,接著執行 npm start 即可執行該腳本。
之後,我們可以使用 Solscan 查看測試交易的執行詳情。

讓我們直接查看第二筆發布文章的交易。它包含兩條指令,每條指令的 Instruction Data Raw 第一個位元組皆為 0x01。此外,每條指令都包含一個內部指令,即對 System Program 的 CreateAccount 指令的呼叫。

同樣地,我們可以查看第三筆列出所有文章的交易。在 Program Logs 區塊中,我們可以發現兩篇文章的索引、標題與內容皆已列印出來。
結論
在本文中,我們首先介紹了如何在本地設定 Solana 程式設計與執行環境。接著,詳細拆解了 Solana 合約的實作邏輯。最後,我們透過 TypeScript 腳本測試了合約功能。透過這份逐步指南,相信您已經學會如何編寫簡單的 Solana 智能合約了 🥳。
在下一篇文章中,我們將提供如何使用 Phalcon Explorer 查看與分析 Solana 交易的詳細指南(Phalcon Explorer 現已支援 Solana)。敬請期待!



