Devahoy Logo
PublishedAt

Solana

มาลองหัดเขียน Smart Contract บน Solana กัน ด้วยแอพ Hello World

มาลองหัดเขียน Smart Contract บน Solana กัน ด้วยแอพ Hello World

สวัสดีครับ วันนี้มาพบกับบทความเกี่ยวกับการเขียน Smart Contract บน Solana กันนะครับ จริงๆ บทความนี้เกิดขึ้นมาเพราะผมอยากทบทวนและเรียบเรียงสิ่งที่เรียนรู้มาครับ และอยากลองดูว่าเข้าใจขั้นตอนการทำงานของมันมั้ย โดยการดูจาก Example HelloWorld ครับ

และเมื่อเสาร์ อาทิตย์ที่ผ่านมา (จริงๆคือ 4ทุ่ม - ตี1 เวลาไทย) ไปลองเรียน Solana Bootcamp - Chainlink วันละ 3ชั่วโมง 2วัน แล้วรู้สึกว่าได้รู้อะไรเยอะขึ้นมาก (แม้ว่าโค๊ดที่เขียนๆ พิมพ์ๆ จะเข้าใจไม่ถึง 50% ก็เถอะ) หลังจากไปนั่งอ่านเพิ่ม นั่งฝึกเขียนเพิ่ม ก่อนหน้านี้เคยลอง Anchor แต่ก็ยังไม่ค่อยเข้าใจมาก ส่วนนึงเพราะ syntax Rust ที่ไม่ชิน และไม่คล่อง จนพอเริ่มเข้าใจ Rust มากขึ้น มาอ่าน Solana หรือ Anchor อีกรอบ ก็เข้าใจมากขึ้นไปด้วย เลยลองเขียนเป็นบทความดูว่าจะออกมาเป็นยังไง

เตรียมความพร้อม

  • เข้าใจ Rust เบื้องต้น (หรืออ่านโค๊ดแล้วพอเข้าใจ ก็โอเคครับ) - สำหรับคนมีพื้นฐานโปรแกรมมิ่ง ลองอ่านแบบเร็วๆ สั้นๆ Rust Playground และ Learn Rust in X minutes
  • ใช้งาน Command Line พื้นฐานเป็น
  • ติดตั้ง Node.js เรียบร้อยแล้ว เข้าใจ JavaScript หรือ TypeScript

Step 0 - Solana คร่าวๆ

  • หน่วยคือ SOL และ Lamports โดย 1 Lamport มีค่า 0.000000001 SOL
  • มี clusters หลักๆคือ Local (Localnet/Test Validator), Devnet, Testnet และ Mainnet beta
  • Program หรือเรียกอีกอย่างว่า Smart Contract (ใน chain อื่นๆ)
  • Program หลักๆ มี Native Program และ Solana Program Library (SPL)

Solana Overview

  • จากรูปด้านบน จะเห็นว่า เราสามารถเขียน Program ได้หลายภาษาไม่ว่าจะเป็น Rust, C หรือ C++
  • สามารถส่ง transaction หรือ query ด้วย Client ต่างๆ ผ่าน JSON-RPC API
  • ฝั่ง Client ทำได้ทั้ง CLI, JavaScript SDK, Rust SDK หรืออื่นๆ
  • Account ใน Solana ใช้สำหรับเก็บ state แบ่งหลักได้ 3 แบบ Data Account, Program Account และ Native Account.
  • Data account มี System owned account และ Program derived address (PDA) account.
  • Program account จะไม่เก็บ state
  • สมมติเราสร้าง Program (Smart Contract) ขึ้นมา 1 ตัว เป็น counter ง่ายๆ นับเลข ต้องมี 2 account คือ 1. ไว้เก็บโค๊ด และ 2. ไว้เก็บข้อมูล state.

Solana Accounts

Reference : Solana Cookbook

Step 1 - ติดตั้งโปรแกรม

ติดตั้ง Rust

อย่างแรก ติดตั้ง Rust ก่อนครับ โดยเข้าไปที่เว็บ rustup จะมีขั้นตอนการติดตั้ง (ถ้าเป็น Windows ก็จะเป็นตัว urstup-init.exe ดาวน์โหลดไป install ได้เลย)

Terminal window
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

ตัว rustup จะติดตั้งพร้อมกับ rustc ที่เป็น compiler และ cargo เป็นตัว Package Manager คล้ายๆกับ Yarn / npm ของฝั่ง Node.js

เช็คว่า ติดตั้งเรียบร้อยมั้ย ด้วยคำสั่ง:

Terminal window
rustup --version
Terminal window
rustc --version

และ

Terminal window
cargo --version

ติดตั้ง Solana CLI

ขั้นตอนนี้ เราจะติดตั้งตัว Solana CLI กันนะครับ โดยเราสามารถเลือกติดตั้ง แต่ละเวอร์ชั่นได้ เช่น ด้านล่าง ติดตั้ง v1.10.6 ซึ่งเป็นเวอร์ชั่น beta

Terminal window
sh -c "$(curl -sSfL https://release.solana.com/v1.10.6/install)"

หากใครอยากได้ที่เป็น stable version ก็สามารถติดตั้งได้ ด้วยการเปลี่ยน v1.10.6 เป็น stable แบบนี้

Terminal window
sh -c "$(curl -sSfL https://release.solana.com/stable/install)"

เช็คว่า solana ติดตั้งเรียบร้อยแล้ว :

Terminal window
solana version

ติดตั้ง Node.js

สำหรับใครยังไม่มี Node.js สามารถติดตั้งได้ โดยเลือกดาวน์โหลดแบบ 16.14.2 LTS (ณ ช่วงเวลาที่เขียน)

หรือผ่าน Homebrew ก็แค่

Terminal window
brew install node

สำหรับ Windows สามารถเลือกไฟล์ .msi เพื่อทำการติดตั้งได้เลยครับ

เช็คเวอร์ชั่น

Terminal window
node --version

สรุป เวอร์ชันในเครื่องของผม ดังนี้

  • Rustup - 1.24.3
  • Rustc - 1.59.0
  • Cargo - 1.59.0
  • Solana - 1.9.14
  • Node.js - 16.14.2

Step 2 - Solana CLI

ต่อมา เราจะ setup และตั้งค่า CLI รวมถึง local cluster กันก่อนครับ ก่อนที่จะไปเริ่มสร้างโปรเจ็คกัน

ขั้นแรก ตั้งค่า config ให้เป็น localhost

Terminal window
solana config set --url localhost

ต่อมาสร้าง Keypair ขึ้นมา

Terminal window
solana-keygen new

ระบบจะให้เราใส่รหัส BIP39 Passphrase เพื่อเพิ่มความปลอดภัยขึ้น (สำหรับ dev ไม่ต้องใส่ก็ได้ครับ) แนะนำว่ากระเป๋า dev ไม่ควรเอาไปใช้กับเงินจริงๆนะครับ

1
BIP39 Passphrase (empty for none):
2
``
3
4
จากนั้นระบบก็จะ generate 12 คำ ซึ่งก็เหมือน Wallet อื่นๆ เราต้องจดไว้
5
6
```bash
7
Wrote new keypair to /Users/chai/.config/solana/id.json
8
==============================================================================
9
pubkey: G2s5SoQt4uxpnt8soSdcfGL4Gt78cpt1MmyruP4BUoss
10
==============================================================================
11
Save this seed phrase and your BIP39 passphrase to recover your new keypair:
12
<--------12 คำ อยู่ตรงนี้------>
13
==============================================================================

คำ 12 คำที่เราต้องจดไว้ หากเราต้องการสร้าง KeyPair เพื่อใช้งานจริงๆ อย่าให้ใครรู้ Seedphrase รวมถึง ข้อมูล Keypair ที่เป็น id.json นะครับ เพราะข้างในคือ public key + private key สามารถเอาไปใช้งานได้เลย (แต่ถ้า dev ก็ไม่เป็นไร เราสร้างแล้วท้ิง ไม่ได้ใช้อยู่แล้ว)

เช็คว่าถูกต้องมั้ย ด้วยคำสั่ง

Terminal window
solana config get

จะได้ผลลัพธ์ประมาณนี้

Terminal window
Config File: /Users/chai/.config/solana/cli/config.yml
RPC URL: http://localhost:8899
WebSocket URL: ws://localhost:8900/ (computed)
Keypair Path: /Users/chai/.config/solana/id.json
Commitment: confirmed

ต่อมา ต้องรัน local cluster (ควรเปิด Terminal อีกหน้าไว้) ด้วยคำสั่ง

Terminal window
solana-test-validator

สามารถดู logs ได้ ด้วยคำสั่ง (เปิด Terminal อีกแท็ป)

Terminal window
solana logs

Step 3 - สร้างโปรเจ็ค

เริ่มสร้างโปรเจ็ค โดยอ้างอิงจาก ตัว Source Code จาก โปรเจ็ค Example Hello World ของ Solana นะครับ ซึ่งวิธีการเรียนรู้ที่ดีที่สุด คือ อ่านโค๊ดและลงมือทำ แม้จะเป็น Hello World ก็ตาม

ต่อมาสร้างโปรเจ็คขึ้นมาด้วย cargo ผมตั้งชื่อว่าโปรเจ็คว่า solana-helloworld (ชื่อแล้วแต่เพื่อนๆเลย)

Terminal window
cargo init solana-helloworld --lib

ตัว Cargo จะสร้างไฟล์ให้เราดังนี้

1
├── Cargo.toml
2
└── src
3
└── lib.rs

เราสร้างโดยใช้ --lib จะได้ไฟล์ lib.rs แต่ถ้าปกติจะเป็น main.rs นะครับ

ไฟล์ Cargo.toml จะได้เป็นแบบนี้

Cargo.toml
1
[package]
2
name = "solana-helloworld"
3
version = "0.1.0"
4
edition = "2021"
5
6
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
7
8
[features]
9
no-entrypoint = []
10
11
[dependencies]
12
borsh = "0.9.3"
13
borsh-derive = "0.9.3"
14
solana-program = "=1.10.6"
15
16
[lib]
17
name = "solana_helloworld"
18
crate-type = ["cdylib", "lib"]

Dependencies ที่ใช้ แบ่งเป็น

  • borsh และ borse-derive - สำหรับทำ Deserialize และ Serialize
  • solana-program - สำหรับการเขียน Program บน Solana (EVM เรียก Smart Contract แต่ Solana เรียก Program)

ต่อมาที่ไฟล์ src/lib.rs พิมพ์โค๊ดนี้ลงไป

src/lib.rs
1
use borsh::{BorshDeserialize, BorshSerialize};
2
use solana_program::{
3
account_info::{next_account_info, AccountInfo},
4
entrypoint,
5
entrypoint::ProgramResult,
6
msg,
7
program_error::ProgramError,
8
pubkey::Pubkey,
9
};
10
11
/// Define the type of state stored in accounts
12
#[derive(BorshSerialize, BorshDeserialize, Debug)]
13
pub struct GreetingAccount {
14
/// number of greetings
15
pub counter: u32,
16
}
17
18
// Declare and export the program's entrypoint
19
entrypoint!(process_instruction);
20
21
// Program entrypoint's implementation
22
pub fn process_instruction(
23
program_id: &Pubkey, // Public key of the account the hello world program was loaded into
24
accounts: &[AccountInfo], // The account to say hello to
25
_instruction_data: &[u8], // Ignored, all helloworld instructions are hellos
26
) -> ProgramResult {
27
msg!("Hello World Rust program entrypoint");
28
29
// Iterating accounts is safer than indexing
30
let accounts_iter = &mut accounts.iter();
31
32
// Get the account to say hello to
33
let account = next_account_info(accounts_iter)?;
34
35
// The account must be owned by the program in order to modify its data
36
if account.owner != program_id {
37
msg!("Greeted account does not have the correct program id");
38
return Err(ProgramError::IncorrectProgramId);
39
}
40
41
// Increment and store the number of times the account has been greeted
42
let mut greeting_account = GreetingAccount::try_from_slice(&account.data.borrow())?;
43
greeting_account.counter += 1;
44
greeting_account.serialize(&mut &mut account.data.borrow_mut()[..])?;
45
46
msg!("Greeted {} time(s)!", greeting_account.counter);
47
48
Ok(())
49
}

ทดลอง build โปรแกรมดู ตัว Cargo จะทำการ download dependencies และ compile ครับ

Terminal window
cargo build --manifest-path=./Cargo.toml

จะได้ผลลัพธ์ว่า build เรียบร้อย แสดงว่าไม่ติดปัญหา

Terminal window
Compiling solana-helloworld v0.1.0 (/Users/chai/dev/solana-helloworld)
Finished dev [unoptimized + debuginfo] target(s) in 20.31s

ทีนี้ตัว Solana เราต้อง build เป็น BPF - Berkeley Packet Filter ก็เลยเปลี่ยน script เป็นแบบนี้

Terminal window
cargo build-bpf --manifest-path=./Cargo.toml --bpf-out-dir=dist/program

โดยกำหนด output ไฟล์คือโฟลเดอร์ dist/program

หลังจาก build เสร็จ สังเกตไฟล์ dist/program จะมี 2 ไฟล์คือ

Terminal window
├── solana_helloworld-keypair.json
└── solana_helloworld.so

สิ่งที่เราต้องทำคือ deploy ตัว solana_helloworld.so ลง on-chain ที่ local-cluster ของเรา

Deploy ด้วยคำสั่ง

Terminal window
solana program deploy dist/program/solana_helloworld.so

จะได้ผลลัพธ์เป็นเลข Program Id ของเรา

Terminal window
Program Id: 2rQ5YJQ7tG6xEyY2M37frBi7Y1u3wWT1ap8hZDs9Gcwe

เท่านี้ ก็เรียบร้อย ในการ deploy ลง local cluster (สามารถดู logs ได้)

4. เชื่อม Client SDK

ต่อมาเราต้องใช้ JavaScript/ TypeScript เป็น Client เพื่อเรียก Program ที่เรา deploy ลง local cluster ผ่าน JSON-RPC ครับ

สร้าง package.json เปล่าๆ ขึ้นมาก่อน ด้วยคำสั่ง

Terminal window
yarn init -y
# หรือ npm
npm init -y

จากนั้นติดตั้ง solana/web3.js, borsh และ typescript ดังนี้

Terminal window
npm install borsh @solana/web3.js yaml
npm install @types/yaml @tsconfig/recommended ts-node typescript --save

ไฟล์ package.json จะได้แบบนี้

package.json
1
{
2
"name": "solana-helloworld",
3
"version": "1.0.0",
4
"main": "index.js",
5
"author": "Chai Phonbopit",
6
"license": "MIT",
7
"dependencies": {
8
"@solana/web3.js": "1.37.1",
9
"borsh": "0.7.0",
10
"yaml": "2.0.0"
11
},
12
"devDependencies": {
13
"@tsconfig/recommended": "1.0.1",
14
"@types/yaml": "1.9.7",
15
"ts-node": "10.7.0",
16
"typescript": "4.6.3"
17
}
18
}

สร้างไฟล์ tsconfig.json ขึ้นมา

tsconfig.json
1
{
2
"extends": "@tsconfig/recommended/tsconfig.json",
3
"ts-node": {
4
"compilerOptions": {
5
"module": "commonjs"
6
}
7
},
8
"compilerOptions": {
9
"declaration": true,
10
"moduleResolution": "node",
11
"module": "es2015"
12
},
13
"include": ["src/**/*"],
14
"exclude": ["node_modules"]
15
}

สร้างโฟลเดอร์ client เอาไว้เก็บไฟล์ ดังนี้

  • main.ts - เป็นไฟล์หลักเอาไว้รัน program
  • hello_world.ts - ไฟล์ business logic ต่างๆ
  • utils.ts - ไฟล์ utils
client/main.ts
1
import {
2
establishConnection,
3
establishPayer,
4
checkProgram,
5
sayHello,
6
reportGreetings
7
} from './hello_world'
8
9
async function main() {
10
console.log("Let's say hello to a Solana account...")
11
12
// Establish connection to the cluster
13
await establishConnection()
14
15
// Determine who pays for the fees
16
await establishPayer()
17
18
// Check if the program has been deployed
19
await checkProgram()
20
21
// Say hello to an account
22
await sayHello()
23
24
// Find out how many times that account has been greeted
25
await reportGreetings()
26
27
console.log('Success')
28
}
29
30
main().then(
31
() => process.exit(),
32
(err) => {
33
console.error(err)
34
process.exit(-1)
35
}
36
)

หากเราดู client/main.ts เราจะเห็นว่า การทำงานคร่าวๆ แม้จะยังไม่เห็น implementation คือ เริ่มจาก

  1. เช็ค connection local cluster ว่าเชื่อมต่อมั้ย
  2. เช็ค program ที่เรา deploy ไว้ถูกต้องมั้ย
  3. sayHello เป็นการส่ง transaction ไปที่ program
  4. reportGreetings เป็นการดึงข้อมูล state (query) ของ program มาแสดง

ไฟล์ utils ก็ไม่มีอะไรมาก เป็นแค่ helper ที่ช่วย อ่านไฟล์ต่างๆ เช่น config หรือ keypair

client/utils.ts
1
import os from 'os'
2
import fs from 'fs/promises'
3
4
import path from 'path'
5
import yaml from 'yaml'
6
import { Keypair } from '@solana/web3.js'
7
8
/**
9
* @private
10
*/
11
async function getConfig(): Promise<any> {
12
// Path to Solana CLI config file
13
const CONFIG_FILE_PATH = path.resolve(os.homedir(), '.config', 'solana', 'cli', 'config.yml')
14
const configYml = await fs.readFile(CONFIG_FILE_PATH, { encoding: 'utf8' })
15
return yaml.parse(configYml)
16
}
17
18
/**
19
* Load and parse the Solana CLI config file to determine which RPC url to use
20
*/
21
export async function getRpcUrl(): Promise<string> {
22
try {
23
const config = await getConfig()
24
if (!config.json_rpc_url) throw new Error('Missing RPC URL')
25
return config.json_rpc_url
26
} catch (err) {
27
console.warn('Failed to read RPC url from CLI config file, falling back to localhost')
28
return 'http://127.0.0.1:8899'
29
}
30
}
31
32
/**
33
* Load and parse the Solana CLI config file to determine which payer to use
34
*/
35
export async function getPayer(): Promise<Keypair> {
36
try {
37
const config = await getConfig()
38
if (!config.keypair_path) throw new Error('Missing keypair path')
39
return await createKeypairFromFile(config.keypair_path)
40
} catch (err) {
41
console.warn(
42
'Failed to create keypair from CLI config file, falling back to new random keypair'
43
)
44
return Keypair.generate()
45
}
46
}
47
48
/**
49
* Create a Keypair from a secret key stored in file as bytes' array
50
*/
51
export async function createKeypairFromFile(filePath: string): Promise<Keypair> {
52
const secretKeyString = await fs.readFile(filePath, { encoding: 'utf8' })
53
const secretKey = Uint8Array.from(JSON.parse(secretKeyString))
54
return Keypair.fromSecretKey(secretKey)
55
}

ต่อมาไฟล์หลัก hello_world.ts

client/hello_world.ts
1
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
2
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
3
4
import {
5
Keypair,
6
Connection,
7
PublicKey,
8
LAMPORTS_PER_SOL,
9
SystemProgram,
10
TransactionInstruction,
11
Transaction,
12
sendAndConfirmTransaction
13
} from '@solana/web3.js'
14
import fs from 'fs'
15
import path from 'path'
16
import * as borsh from 'borsh'
17
18
import { getPayer, getRpcUrl, createKeypairFromFile } from './utils'
19
20
let connection: Connection
21
22
let payer: Keypair
23
24
let programId: PublicKey
25
26
let greetedPubkey: PublicKey
27
28
const PROGRAM_PATH = path.resolve(__dirname, '../dist/program')
29
const PROGRAM_SO_PATH = path.join(PROGRAM_PATH, 'solana_helloworld.so')
30
const PROGRAM_KEYPAIR_PATH = path.join(PROGRAM_PATH, 'solana_helloworld-keypair.json')
31
32
/**
33
* The state of a greeting account managed by the hello world program
34
*/
35
class GreetingAccount {
36
counter = 0
37
constructor(fields: { counter: number } | undefined = undefined) {
38
if (fields) {
39
this.counter = fields.counter
40
}
41
}
42
}
43
44
/**
45
* Borsh schema definition for greeting accounts
46
*/
47
const GreetingSchema = new Map([
48
[GreetingAccount, { kind: 'struct', fields: [['counter', 'u32']] }]
49
])
50
51
/**
52
* The expected size of each greeting account.
53
*/
54
const GREETING_SIZE = borsh.serialize(GreetingSchema, new GreetingAccount()).length
55
56
/**
57
* Establish a connection to the cluster
58
*/
59
export async function establishConnection(): Promise<void> {
60
const rpcUrl = await getRpcUrl()
61
connection = new Connection(rpcUrl, 'confirmed')
62
const version = await connection.getVersion()
63
console.log('Connection to cluster established:', rpcUrl, version)
64
}
65
66
/**
67
* Establish an account to pay for everything
68
*/
69
export async function establishPayer(): Promise<void> {
70
let fees = 0
71
if (!payer) {
72
const { feeCalculator } = await connection.getRecentBlockhash()
73
74
// Calculate the cost to fund the greeter account
75
fees += await connection.getMinimumBalanceForRentExemption(GREETING_SIZE)
76
77
// Calculate the cost of sending transactions
78
fees += feeCalculator.lamportsPerSignature * 100 // wag
79
80
payer = await getPayer()
81
}
82
83
let lamports = await connection.getBalance(payer.publicKey)
84
if (lamports < fees) {
85
// If current balance is not enough to pay for fees, request an airdrop
86
const sig = await connection.requestAirdrop(payer.publicKey, fees - lamports)
87
await connection.confirmTransaction(sig)
88
lamports = await connection.getBalance(payer.publicKey)
89
}
90
91
console.log(
92
'Using account',
93
payer.publicKey.toBase58(),
94
'containing',
95
lamports / LAMPORTS_PER_SOL,
96
'SOL to pay for fees'
97
)
98
}
99
100
/**
101
* Check if the hello world BPF program has been deployed
102
*/
103
export async function checkProgram(): Promise<void> {
104
// Read program id from keypair file
105
try {
106
const programKeypair = await createKeypairFromFile(PROGRAM_KEYPAIR_PATH)
107
programId = programKeypair.publicKey
108
} catch (err) {
109
const errMsg = (err as Error).message
110
throw new Error(
111
`Failed to read program keypair at '${PROGRAM_KEYPAIR_PATH}' due to error: ${errMsg}. Program may need to be deployed with \`solana program deploy dist/program/helloworld.so\``
112
)
113
}
114
115
// Check if the program has been deployed
116
const programInfo = await connection.getAccountInfo(programId)
117
if (programInfo === null) {
118
if (fs.existsSync(PROGRAM_SO_PATH)) {
119
throw new Error(
120
'Program needs to be deployed with `solana program deploy dist/program/helloworld.so`'
121
)
122
} else {
123
throw new Error('Program needs to be built and deployed')
124
}
125
} else if (!programInfo.executable) {
126
throw new Error(`Program is not executable`)
127
}
128
console.log(`Using program ${programId.toBase58()}`)
129
130
// Derive the address (public key) of a greeting account from the program so that it's easy to find later.
131
const GREETING_SEED = 'hello'
132
greetedPubkey = await PublicKey.createWithSeed(payer.publicKey, GREETING_SEED, programId)
133
134
// Check if the greeting account has already been created
135
const greetedAccount = await connection.getAccountInfo(greetedPubkey)
136
if (greetedAccount === null) {
137
console.log('Creating account', greetedPubkey.toBase58(), 'to say hello to')
138
const lamports = await connection.getMinimumBalanceForRentExemption(GREETING_SIZE)
139
140
const transaction = new Transaction().add(
141
SystemProgram.createAccountWithSeed({
142
fromPubkey: payer.publicKey,
143
basePubkey: payer.publicKey,
144
seed: GREETING_SEED,
145
newAccountPubkey: greetedPubkey,
146
lamports,
147
space: GREETING_SIZE,
148
programId
149
})
150
)
151
await sendAndConfirmTransaction(connection, transaction, [payer])
152
}
153
}
154
155
/**
156
* Say hello
157
*/
158
export async function sayHello(): Promise<void> {
159
console.log('Saying hello to', greetedPubkey.toBase58())
160
const instruction = new TransactionInstruction({
161
keys: [{ pubkey: greetedPubkey, isSigner: false, isWritable: true }],
162
programId,
163
data: Buffer.alloc(0) // All instructions are hellos
164
})
165
await sendAndConfirmTransaction(connection, new Transaction().add(instruction), [payer])
166
}
167
168
/**
169
* Report the number of times the greeted account has been said hello to
170
*/
171
export async function reportGreetings(): Promise<void> {
172
const accountInfo = await connection.getAccountInfo(greetedPubkey)
173
if (accountInfo === null) {
174
throw 'Error: cannot find the greeted account'
175
}
176
const greeting = borsh.deserialize(GreetingSchema, GreetingAccount, accountInfo.data)
177
console.log(greetedPubkey.toBase58(), 'has been greeted', greeting.counter, 'time(s)')
178
}

ถ้าหากว่าไม่ได้ใช้ชื่อโปรเจ็คว่า solana_helloworld อย่าลืมเปลี่ยน path ให้ตรงด้วยนะครับ

ลองรัน client ดูผลลัพธ์ครับ

Terminal window
ts-node client/main.ts

จะได้ผลลัพธ์แบบนี้

Terminal window
Let's say hello to a Solana account...
Connection to cluster established: http://localhost:8899 { 'feature-set': 1070292356, 'solana-core': '1.9.14' }
Using account 2B82KUoFQXHjGz6aV8hzJgvCuCfgPTSi9eAW9TMpqbv1 containing 499999999.15446967 SOL to pay for fees
Using program 2rQ5YJQ7tG6xEyY2M37frBi7Y1u3wWT1ap8hZDs9Gcwe
Creating account 2mhCSXCeAK4A5uyw6TSq527vpw9pbdAuoSoWWZrp7juS to say hello to
Saying hello to 2mhCSXCeAK4A5uyw6TSq527vpw9pbdAuoSoWWZrp7juS
2mhCSXCeAK4A5uyw6TSq527vpw9pbdAuoSoWWZrp7juS has been greeted 1 time(s)
Success

และถ้าเรารันอีกรอบ ค่า counter ก็จะเพิ่มเรื่อยๆ

5. Deserialize/Serialize

เราจะเห็นว่า ตอนเราใช้ JavaScript SDK เรียกผ่าน JSON RPC เราต้องทำการแปลง Deserialize/Serialize รวมถึงต้องกำหนด Schema ให้ตรงกันด้วย ดูแล้วยุ่งยากนิดๆ ใช่มั้ย?

ถ้าใช้ Anchor จะสะดวก และลดขั้นตอนนี้ลงได้เยอะเลย เพราะ Anchor จะ auto De/Serialize ให้เราเลย

สังเกต ไฟล์ client/hello_world.ts เราต้องกำหนด schema ด้วยแบบนี้

1
class GreetingAccount {
2
counter = 0
3
constructor(fields: { counter: number } | undefined = undefined) {
4
if (fields) {
5
this.counter = fields.counter
6
}
7
}
8
}
9
10
const GreetingSchema = new Map([
11
[GreetingAccount, { kind: 'struct', fields: [['counter', 'u32']] }]
12
])
  • โดย GreetingAccount เป็น class ที่เราต้องกำหนด ให้มันตรงกับ hello world program ของเรา
  • GreetingSchema ก็ต้องกำหนด struct ให้ตรง (IDL Spec) (คล้ายๆ ABI ของ Solidity)

จะเห็นว่าตอน initial ค่า counter เป็น 0 เพราะเราไม่ได้ส่งอะไรไปให้ program เลย และ program ก็ไม่ได้รับค่าใดๆ

ทีนี้ลองมาปรับแก้ client/hello_world.ts ซักนิด ให้ส่งค่า counter ไปแบบกำหนดเอง

hello_world.ts
1
export async function sayHello(): Promise<void> {
2
console.log('Saying hello to', greetedPubkey.toBase58())
3
4
let greetingAccount = new GreetingAccount({
5
counter: 0
6
})
7
8
let data = borsh.serialize(GreetingSchema, greetingAccount)
9
const instructionData = Buffer.from(data)
10
console.log('data to send', instructionData.toString('utf-8'))
11
12
const instruction = new TransactionInstruction({
13
keys: [{ pubkey: greetedPubkey, isSigner: false, isWritable: true }],
14
programId,
15
data: instructionData
16
})
17
18
await sendAndConfirmTransaction(connection, new Transaction().add(instruction), [payer])
19
}

จะเห็นว่าสิ่งที่ต้องเพิ่มคือ ทำ serialize โดยใช้ Schema กับ instance ที่สร้างไว้ พร้อม initial state สุดท้าย ส่งค่า instructionData ที่เป็น Buffer ไป (ก่อนหน้านี้เป็น Buffer.alloc(0))

เราปรับแก้ src/lib.rs ให้รับค่าได้ โดยแก้เป็น

src/lib.rs
1
pub fn process_instruction(
2
program_id: &Pubkey, // Public key of the account the hello world program was loaded into
3
accounts: &[AccountInfo], // The account to say hello to
4
instruction_data: &[u8], // Ignored, all helloworld instructions are hellos
5
) -> ProgramResult {
6
msg!("Hello World Rust program entrypoint");
7
8
// Iterating accounts is safer than indexing
9
let accounts_iter = &mut accounts.iter();
10
11
// Get the account to say hello to
12
let account = next_account_info(accounts_iter)?;
13
14
// The account must be owned by the program in order to modify its data
15
if account.owner != program_id {
16
msg!("Greeted account does not have the correct program id");
17
return Err(ProgramError::IncorrectProgramId);
18
}
19
20
let input_data = GreetingAccount::try_from_slice(&instruction_data).unwrap();
21
msg!("Input Data {:?}", input_data);
22
23
// Increment and store the number of times the account has been greeted
24
let mut greeting_account = GreetingAccount::try_from_slice(&account.data.borrow())?;
25
26
if input_data.counter > 0 {
27
greeting_account.counter = input_data.counter;
28
} else {
29
greeting_account.counter += 1;
30
}
31
32
greeting_account.serialize(&mut &mut account.data.borrow_mut()[..])?;
33
34
msg!("Greeted {} time(s)!", greeting_account.counter);
35
36
Ok(())
37
}

คือ เช็คว่า ถ้า client ส่ง counter มามากกว่า 0 ก็ใช้ counter จาก client แต่ถ้าส่งมา counter = 0 ก็ให้มัน += 1 แบบตอนแรก

ทีนี้ function เราก็จะได้ 2 แบบคือ นับ counter ปกติ เพิ่มทีละหนึ่ง ต่อการ sayHello() 1 ครั้ง กับ แบบที่ set counter จาก client ได้เลย

msg!() เราสามารถดู log ได้ จาก solana logs นะครับ

เมื่อเราแก้ Program ก็ต้อง build, compile และ deploy ใหม่

ทีนี้ก็ลองรัน

Terminal window
cargo build-bpf --manifest-path=./Cargo.toml --bpf-out-dir=dist/program
solana program deploy dist/program/solana_helloworld.so
./node_modules/.bin/ts-node client/main.ts

ลองปรับแก้ counter ใน client/hello_world.ts และลองสั่งรัน client ดู ว่าค่าเป็นค่าที่เราส่งไปมั้ย?

Terminal window
./node_modules/.bin/ts-node client/main.ts

ได้ผลลัพธ์ เป็นอันเรียบร้อย

Terminal window
Let's say hello to a Solana account...
Connection to cluster established: http://localhost:8899 { 'feature-set': 1070292356, 'solana-core': '1.9.14' }
Using account 2B82KUoFQXHjGz6aV8hzJgvCuCfgPTSi9eAW9TMpqbv1 containing 499999998.305227 SOL to pay for fees
Using program FKb3rRG72e8ZukMhR1TKv58eWRH93WTJ9mPHTzEiWaD3
Saying hello to 86rXpsW7ApWABdsyq69YsTrVtcAyhgMQQqKVoYbCYr2R
data to send <Buffer 00 00 00 00>
86rXpsW7ApWABdsyq69YsTrVtcAyhgMQQqKVoYbCYr2R has been greeted 121 time(s)
Success

ก่อนจบ พวก script ต่างๆ ต้องมานั่งพิมพ์ตลอด ก็สร้างเป็น Makefile หรือใส่ใน script package.json ก็ได้ แบบนี้

สร้าง script มาไว้เพื่อให้ง่าย Makefile

1
// Makefile
2
run: build deploy run-client
3
build:
4
cargo build-bpf --manifest-path=./Cargo.toml --bpf-out-dir=dist/program
5
deploy:
6
solana program deploy dist/program/solana_helloworld.so
7
run-client:
8
./node_modules/.bin/ts-node client/main.ts

เวลาใช้งานก็แค่รันคำสั่งสั้นๆ (แต่ต้องมี make ในเครื่องนะ)

Terminal window
make run

หรือ

Terminal window
make build

หรือ package.json ก็แค่เพิ่ม script ลงไป

package.json
1
{
2
"scripts": {
3
"run": "npm run build && npm run deploy && npm run run-client",
4
"build": "cargo build-bpf --manifest-path=./Cargo.toml --bpf-out-dir=dist/program",
5
"deploy": "solana program deploy dist/program/solana_helloworld.so",
6
"run-client": "./node_modules/.bin/ts-node client/main.ts"
7
}
8
}

และก็ใช้คำสั่งได้เหมือนกัน

Terminal window
yarn build
yarn deploy
yarn run-client
# เนื่องจากคำสั่งชื่อไปชนกันกับ run :)
yarn run run

อื่นๆ เพิ่มเติม

หากเราไม่ต้องการ local cluster ต้องการ deploy ไป devnet หรือ testnet เราสามารถเปลี่ยน config และ deploy ไม่ต้องใส่ก็ได้ครับ

Terminal window
solana config set --url https://api.devnet.solana.com

Airdrop ให้กับตัวเอง ก่อน เพราะ devnet ต้องใช้ SOL และไม่มี SOL ให้เหมือน local (ขอได้มากสุด 2 SOL ต่อครั้ง 24 SOL ต่อวัน)

Terminal window
solana airdrop 2

ขอแบบระบุ address

Terminal window
solana airdrop 2 <addresss>

เช็ค address จาก keypair ได้ด้วยคำสั่ง

Terminal window
solana address -K path/your/keypair.json

ถ้าต่อ devnet อย่าลืมปิด solana logs ด้วยนะ หรืออยากดู log ก็ไม่ว่ากันครับ 🤣

สรุป

ถึงแม้ว่าบทความนี้ส่วนใหญ่จะเป็น Example Hello World แต่ก็มีหลายๆ อย่างให้เราเรียนรู้ครับ ให้เราเข้าใจขั้นตอนการสร้าง, build, deploy และการเรียก JSON RPC ผ่าน JavaScript SDK Client

แม้ว่าจะไม่เข้าใจโค๊ดบางส่วน หรือเข้าใจไม่หมด ก็ไม่เป็นไร อย่างน้อย เราก็เห็นภาพ และรู้ว่าเราไม่เข้าใจส่วนไหน ก็แค่ไปทำความเข้าใจส่วนนั้นเพิ่มเติม ลองเล่น ลองปรับแก้ ลองรันโปรแกรม สิ่งสำคัญคือ การลงมือทำต่างหาก หวังว่าบทความนี้จะเป็นประโยชน์ไม่มากก็น้อย แล้วเจอกันบทความถัดไปครับ

อ่านเพิ่มเติม

สำหรับอ่านเพิ่มเติม แนะนำ Solana Developer Resources ในนั้นรวมเว็บต่างๆ ที่น่าสนใจครับ เช่น Solana Cookbook และ soldev. ส่วนถ้าอยากอ่าน Rust แนะนำเว็บ Official ของ Rust ครับ มีหนังสือและ Rustling รวมถึง Rust by Example เช่นกัน

Authors
avatar

Chai Phonbopit

เป็น Web Dev ในบริษัทแห่งหนึ่ง ทำงานมา 10 ปีกว่าๆ ด้วยภาษาและเทคโนโลยี เช่น JavaScript, Node.js, React, Vue และปัจจุบันกำลังสนใจในเรื่องของ Blockchain และ Crypto กำลังหัดเรียนภาษา Rust

Related Posts