เขียน Solana Program ด้วย Anchor Framework
Anchor เป็น Solana Framework สำหรับเขียน Program (Smart Contract) ที่ช่วยให้เขียน Solana Program ง่ายและสะดวกขึ้น ปกติถ้าเราไม่ใช้ Anchor เวลาที่เราจะใช้ Client SDK ติดต่อ Program ต้องกำหนด Schema ทำ Deserialize/Serialize ซึ่งค่อนข้างยุ่งยาก เหมือนกับบทความที่แล้ว ที่ผมเขียนไว้ มาลองหัดเขียน Smart Contract บน Solana กัน ด้วยแอพ Hello World
ข้อดีของ Anchor คือ
- Rust eDSL สำหรับเขียน Solana Program
- Generate ไฟล์ IDL คล้ายๆกับ ABI เพื่อเอาไว้ติดต่อกับ Program ผ่าน JSON RPC.
- คล้ายๆกับ Truffle/ web3.js หรือ Hardhat/ethers.js
- รองรับ Rust ที่เป็น Official Library และ TypeScript (ฝั่ง Client)
Anchor ยังอยู่ในขั้นตอนการพัฒนา ฉะนั้น API หรือโค๊ดต่างๆ อาจจะมีการเปลี่ยนแปลงได้ตลอดเวลา ฉะนั้นดูเวอร์ชั่นที่ใช้งานด้วยนะครับ และก็ลองดู Changelog ว่ามี breaking changes อะไรมั้ย
Prerequisites
- ควรมีพื้นฐาน Rust เบื้องต้นครับ - แนะนำลองอ่านนี้ประมาณ 30-60 นาที Tour of Rust
- เข้าใจ Solana Program เบื้องต้น (รู้ว่า transactions, instructions, program, account คืออะไร) - อ่านเพิ่มเติม
- ใช้งาน JavaScript / TypeScript เบื้องต้นได้ (ต้องใช้เขียนฝั่ง Client เพื่อต่อ JSON RPC)
- ติดตั้ง Rust, Solana CLI, Yarn และ Node.js เรียบร้อยแล้ว ถ้าไม่มีแนะนำ Step 1 - ติดตั้งโปรแกรม Solana
หนังสือ Anchor Framework
- The Anchor Book - น่าจะเป็น Official book แล้วแทน docs เก่า ปัจจุบัน v0.23.0
- Getting Started - Anchor - Doc เวอร์ชั่นก่อนหน้านี้ ซึ่งคาดว่าน่าจะย้ายไปเขียนใน Anchor Book แทน แต่เนื้อหาทั้งสอง ณ ตอนนี้ก็ยังอ่านได้ครับ
ติดตั้ง Anchor
ขั้นตอนการติดตั้ง Anchor เราจะใช้ avm (Anchor Version Manager) นะครับ เผื่ออนาคตมีเวอร์ชั่นใหม่ๆ เราสามารถสลับเวอร์ชั่นได้ง่ายๆ
ติดตั้ง avm ก่อน
cargo install --git https://github.com/project-serum/anchor avm --locked --force
จากนั้นใช้ avm เพื่อติดตั้ง Anchor เวอร์ชั่นล่าสุด แล้ว set Anchor เป็น latest
avm install latest
avm use latest
ทดลองเช็คว่า Anchor ติดตั้งเรียบร้อยมั้ย
anchor --Version
# ผลลัพธ์ ณ วันที่เขียนบทความ
anchor-cli 0.23.0
สร้างโปรเจ็คด้วย Anchor
เราจะใช้คำสั่ง anchor init <program_name>
เพื่อสร้างโปรเจ็คขึ้นมาใหม่ด้วย Anchor นะครับ ตัวอย่างผมตั้งชื่อโปรเจ็คว่า hello-anchor ก็จะได้เป็นแบบนี้
anchor init hello-anchor
ตัว Anchor จะทำการ generate folder ให้เรา โครงสร้างไฟล์ประมาณนี้
tree -L 4 -I node_modules
├── Anchor.toml
├── Cargo.toml
├── app
├── migrations
│ └── deploy.ts
├── package.json
├── programs
│ └── hello-anchor
│ ├── Cargo.toml
│ ├── Xargo.toml
│ └── src
│ └── lib.rs
├── tests
│ └── hello-anchor.ts
├── tsconfig.json
└── yarn.lock
6 directories, 10 files
- programs - โฟลเดอร์นี้จะเป็นไฟล์ Solana Program ของเรา
- Anchor.toml และ Cargo.toml - เป็นไฟล์ config ของ Cargo และ Anchor ครับ ตัว Cargo.toml ข้างนอกแค่ระบุ workspace ส่วน metadata จะอยู่ที่ programs/hello-anchor/Cargo.toml ครับ
- tests - ไฟล์สำหรับ test
ข้างในไฟล์ hello-anchor
มีอะไรบ้างนะ
use anchor_lang::prelude::*;
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod hello_anchor {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize {}
use anchor_lang::prelude::*;
- เป็นเหมือนกับการ import macro และ attributes ต่างๆ ของ Anchordeclare_id!
- กำหนด Program addrses เพื่อเอาไว้ให้ Anchor generate#[program]
- เป็น attribute ที่กำหนดให้เป็น entrypoint ของ Program และ function ข้างใน ก็จะเอาไว้ handle RPC request.Context<Initialize>
- เป็น paramter แรก ของทุกๆ RPC handler ต้องมี โดยเป็น Generic ตาม struct ที่เรากำหนด ตัวอย่างคือ structInitialize
#[derive(Accounts)]
- attribute นี้รับAccounts
macro มองง่ายๆ คือ ช่วยให้ struct นี้ deserialized input accounts ได้
ทดลอง build:
anchor build
--> programs/hello-anchor/src/lib.rs:9:23
|
9 | pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
| ^^^ help: if this is intentional, prefix it with an underscore: `_ctx`
|
= note: `#[warn(unused_variables)]` on by default
warning: `hello-anchor` (lib) generated 1 warning
Finished release [optimized] target(s) in 0.39s
จะเห็นว่า build ผ่าน แค่มี warning เพราะมีตัวแปรที่ไม่ได้ใช้ เฉยๆ
ต่อมาสร้าง Wallet (Keypair) ขึ้นมา เพื่อเอาไว้ใช้ทดสอบ ตัวอย่างผมสร้างไฟล์ชื่อ dev-wallet.json
solana-keygen new -o ./dev-wallet.json
ไม่ควรนำ Wallet ที่ generate ไปใช้งานจริงนะครับ แต่ถ้าจะใช้งานจริงๆ ห้ามเปิดเผยไฟล์
dev-wallet.json
รวมถึงอย่าลืมจด seed phrase ไว้ด้วยนะครับ
จากนั้นไฟล์ Anchor.toml
ให้เปลี่ยน wallet เป็นกระเป๋าที่เราเพิ่งสร้าง
[provider]
cluster = "localnet"
wallet = "./dev-wallet.json"
จากนั้น Start local cluster validator ครับ
solana-test-validator
ถ้าเราไม่ได้ใช้ test-ledger เดิม หรือ account เราไม่มี Sol ก็ต้องทำการ airdrop ให้มันก่อนนะครับ
solana airdrop 10 $(solana address -k ./dev-wallet.json)
# มันคือการแสดง address จากไฟล์ dev-wallet.json และ solana airdrop 10 <address> นะครับ
จากนั้นลอง Deploy ด้วย anchor
anchor deploy
ผลลัพธ์หน้าตาประมาณนี้
Deploying workspace: http://localhost:8899
Upgrade authority: ./dev-wallet.json
Deploying program "hello-anchor"...
Program path: /your-folder/hello-anchor/target/deploy/hello_anchor.so...
Program Id: GXFtM6h99kckybSfyLBDPwh583mDKjSZqTqDYHR5ix5Z
Deploy success
เอา Program Id ที่ได้ อย่างตัวอย่างคือ GXFtM6h99kckybSfyLBDPwh583mDKjSZqTqDYHR5ix5Z ไปเปลี่ยนที่ไฟล์ Anchor.toml และ lib.rs และเดี๋ยวจะเอาไปใช้ในไฟล์ Client ด้วย
[features]
seeds = false
[programs.localnet]
hello_anchor = "GXFtM6h99kckybSfyLBDPwh583mDKjSZqTqDYHR5ix5Z"
declare_id!("GXFtM6h99kckybSfyLBDPwh583mDKjSZqTqDYHR5ix5Z");
จากนั้น Build อีกรอบ
anchor build
สร้างไฟล์ app/client.js
ต่อมาสร้างไฟล์ app/client.js
เพื่อติดต่อกับ Program ที่เราเขียน
หากใครที่ใช้ Anchor v0.24.x อาจจะมีบางฟังค์ชั่นที่ไม่ตรงกัน แนะนำอ่าน - Notes - อัพเดท Anchor v0.24 เพิ่มเติมครับ
const anchor = require("@project-serum/anchor");
require("dotenv").config();
// Configure the local cluster.
anchor.setProvider(anchor.Provider.local());
async function main() {
// #region main
// Read the generated IDL.
const idl = JSON.parse(
require("fs").readFileSync("./target/idl/hello_anchor.json", "utf8")
);
// Address of the deployed program.
const programId = new anchor.web3.PublicKey(
"<YOUR_PROGRAM_ID>"
);
// Generate the program client from IDL.
const program = new anchor.Program(idl, programId);
// Execute the RPC.
await program.methods.initialize().rpc();
// #endregion main
}
console.log("Running client.");
main().then(() => console.log("Success"));
- สิ่งที่ต้องเปลี่ยนคือ
<YOUR_PROGRAM_ID>
- เป็น Program Id ของเราครับ - ดูว่า file target IDL ตัว path ถูกต้องหรือไม่ ถ้าตั้งชื่อโปรเจ็คคนละชื่อ ต้องเปลี่ยนด้วยนะครับ
สุดท้ายลองรันด้วยคำสั่ง
ANCHOR_WALLET=./dev-wallet.json node app/client.js
จะได้ผลลัพธ์
Running client.
Success
จะเห็นว่า ตัว Client เราต้องใช้ ANCHOR_WALLET
เวลาที่เรา setProvider
ฉะนั้นย้ายไปใช้ .env
แทน น่าจะสะดวกกว่า
yarn add dotenv --dev
สร้างไฟล์ .env
และเก็บค่า
ANCHOR_WALLET=./dev-wallet.json
ไฟล์ app/client.js
ก็ให้โหลด dotenv ก่อน ทีนี้เวลารัน client ก็ให้อ่านจาก .env
แทน
const anchor = require("@project-serum/anchor");
require("dotenv").config();
ลองรันใหม่
node app/client.js
ลอง test
ต่อมาสุดท้ายแล้ว ลองมาดูไฟล์เทส ที่ตัว Anchor generate มาให้ครับ
import * as anchor from "@project-serum/anchor";
import { Program } from "@project-serum/anchor";
import { HelloAnchor } from "../target/types/hello_anchor";
describe("hello-anchor", () => {
// Configure the client to use the local cluster.
anchor.setProvider(anchor.Provider.env());
const program = anchor.workspace.HelloAnchor as Program<HelloAnchor>;
it("Is initialized!", async () => {
// Add your test here.
const tx = await program.rpc.initialize({});
console.log("Your transaction signature", tx);
});
});
- มีการเรียก
HelloAnchor
ที่เป็น type ที่ Anchor auto generate ให้ anchor.workspace.*
สามารถเรียก program instance ได้จากคำสั่งนี้เลย (workspace ใช้ได้เฉพาะตอนใช้anchor test
,build
และdeploy
นะครับ)
ลองเทส
anchor test
จะเห็นว่าเวลาเรารัน
anchor test
ไม่ต้องใช้ solana-test-validator เพราะตัว anchor มันจะรัน local cluster ให้ตอนเทส แต่ถ้าเราเรียกnode app/client.js
เราต้องมี local cluster เอง
พอมาดูไฟล์ใน target/types
จะเห็นว่า Anchor ทำการ generate ทั้ง Type แล้วก็ IDL ให้เราแล้ว
export type HelloAnchor = {
version: '0.1.0';
name: 'hello_anchor';
instructions: [
{
name: 'initialize';
accounts: [];
args: [];
}
];
};
export const IDL: HelloAnchor = {
version: '0.1.0',
name: 'hello_anchor',
instructions: [
{
name: 'initialize',
accounts: [],
args: []
}
]
};
ถ้าเขียน Solidity มา ก็จะคุ้นๆ ว่ามันคล้ายๆ ABI เลย
สุดท้าย เปลี่ยนไปใช้ methods
ซักนิด พอดีเห็นว่ามันแจ้งเตือน deprecated อนาคต อาจจะอัพเดทแล้วเอาออกไป
จาก
const tx = await program.rpc.initialize({});
เป็น
const tx = await program.methods.initialize().rpc();
สุดท้ายไฟล์ tests/hello-anchor.ts
import * as anchor from "@project-serum/anchor";
import { Program } from "@project-serum/anchor";
import { HelloAnchor } from "../target/types/hello_anchor";
describe("hello-anchor", () => {
// Configure the client to use the local cluster.
anchor.setProvider(anchor.Provider.env());
const program = anchor.workspace.HelloAnchor as Program<HelloAnchor>;
it("Is initialized!", async () => {
// Add your test here.
const tx = await program.methods.initialize().rpc();
console.log("Your transaction signature", tx);
});
});
ลองเทสใหม่ ผลลัพธ์ต้องเหมือนเดิม
anchor test
p ./tsconfig.json -t 1000000 'tests/**/*.ts'
hello-anchor
Your transaction signature 463wu91g6KnFYr2J82u5d5DZaGgbBH4iuQV7XV3vRcmLC74dHQwQHcKHak4hm9RYavtfBxQNgZ9STsV1McBXXUH7
✔ Is initialized! (158ms)
1 passing (160ms)
🎉 เป็นอันเรียบร้อย โปรเจ็คนี้ก็เป็น Basic เริ่มต้น ยังไม่มีอะไรมาก แค่เห็น flow การทำงานของมัน เดี๋ยวบทความหน้า จะมีการรับ Input และเก็บ state (Account) นะครับ
สรุป
จะเห็นว่าใช้ Anchor แล้วดูสะดวกสบายหลายๆอย่างเลย บทความนี้ก็เป็น Example ให้เห็นภาพ Anchor ง่ายๆ ครับ หากใครสนใจลองอ่านเพิ่มเติมครับ
หรือถ้าใครอยาก challenge ก็ลองดู Example basic1-4 ดูเพิ่มเติมครับ สุดท้าย ถ้าใครเคยเขียน Web3/Etheres.js มาเห็นส่วน Client ก็น่าจะเข้าใจไม่ยากครับ
Happy Coding ❤️
- Authors
- Name
- Chai Phonbopit
- Website
- @Phonbopit