#写在前面
这篇文章是我学习 Solana 开发时整理的笔记,目标是让零基础的开发者也能看懂。
我会用「银行」的比喻来解释 Solana 的核心概念:
| Solana 概念 | 银行比喻 |
|---|---|
| Account(账户) | 保险柜 |
| Program(程序) | 银行柜员 |
| Transaction(交易) | 业务单据 |
| Instruction(指令) | 具体操作(存款/取款) |
每段代码我都会加上详细的语法注释,帮你同时学习 Rust 语法 和 Solana 概念。
#第一部分:理解账户结构(保险柜长什么样)
Solana 上的一切都是账户。钱包是账户,程序是账户,数据也存在账户里。
#账户的数据结构
pub struct Account {
// 【语法】pub = 公开的,外部可以访问
// 【语法】lamports: u64 = 字段名: 类型
// 【语法】u64 = 无符号64位整数(只能是正数,最大约 1800 亿亿)
pub lamports: u64, // 余额(1 SOL = 1,000,000,000 lamports)
// 【语法】Vec<u8> = 动态数组,里面装的是 u8(0-255 的数字)
// Vec 类似于 JavaScript 的 Array
// u8 = 无符号8位整数(一个字节,0-255)
pub data: Vec<u8>, // 存储的数据(字节数组)
// 【语法】Pubkey = Solana 定义的类型,表示一个地址(32字节)
pub owner: Pubkey, // 谁拥有这个账户(哪个程序能改它)
// 【语法】bool = 布尔值,true 或 false
pub executable: bool, // 是不是程序代码
pub rent_epoch: u64, // 租金相关
}
#账户结构图解
┌─────────────────────────────────────────────────────────────────┐
│ Account 结构 (内存布局) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┬─────────────────────────────────────────────┐ │
│ │ lamports │ u64 (8 bytes) - SOL 余额 │ │
│ │ 1000000000 │ 1 SOL = 1,000,000,000 lamports │ │
│ ├─────────────┼─────────────────────────────────────────────┤ │
│ │ data │ Vec<u8> - 可变长度数据 │ │
│ │ [82 bytes] │ 钱包:空 / 程序:字节码 / 数据账户:状态 │ │
│ ├─────────────┼─────────────────────────────────────────────┤ │
│ │ owner │ Pubkey (32 bytes) - 拥有者程序 │ │
│ │ TokenProg │ 只有 owner 能修改 data 字段 │ │
│ ├─────────────┼─────────────────────────────────────────────┤ │
│ │ executable │ bool (1 byte) - 是否可执行 │ │
│ │ false │ true = 程序账户, false = 数据账户 │ │
│ ├─────────────┼─────────────────────────────────────────────┤ │
│ │ rent_epoch │ u64 (8 bytes) - 已弃用 │ │
│ │ 0 │ 现在使用"租金豁免"机制 │ │
│ └─────────────┴─────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
#举例:普通钱包账户
Account {
lamports: 5_000_000_000, // 【语法】数字中的下划线 _ 只是为了好看,5_000 = 5000
data: [], // 【语法】[] = 空数组
owner: "11111111111111111111111111111111", // System Program 的地址
executable: false,
rent_epoch: 123,
}
#举例:游戏数据账户
Account {
lamports: 1_000_000, // 一点点租金
data: [5, 0, 100, 0], // 等级=5, 金币=100(存成字节)
owner: "GameProgram111111111111111111111", // 游戏程序的地址
executable: false,
rent_epoch: 123,
}
#Rust 基础类型速查
| 类型 | 说明 | 范围 |
|---|---|---|
u8 | 无符号 8位整数 | 0 ~ 255 |
u16 | 无符号 16位整数 | 0 ~ 65,535 |
u32 | 无符号 32位整数 | 0 ~ 42亿 |
u64 | 无符号 64位整数 | 0 ~ 很大 |
i8 | 有符号 8位整数 | -128 ~ 127 |
i32 | 有符号 32位整数 | -21亿 ~ 21亿 |
bool | 布尔值 | true / false |
String | 字符串 | - |
Vec<T> | 动态数组 | T 是元素类型 |
&str | 字符串切片 | 借用的字符串 |
#第二部分:定义数据结构(保险柜里放什么)
我们需要定义程序要存储的数据结构,并让它能够序列化成字节。
// 【语法】use = 导入其他模块的东西,类似 JS 的 import
// 【语法】:: = 路径分隔符,类似 JS 的 . 或 /
// borsh 是一个序列化库,把结构体变成字节,或者把字节变回结构体
use borsh::{BorshDeserialize, BorshSerialize};
// 【语法】#[derive(...)] = 派生宏,自动给结构体添加功能
// 这行的意思是:自动实现序列化、反序列化、调试打印功能
// - BorshSerialize: 能把结构体变成字节 (存到账户)
// - BorshDeserialize: 能把字节变回结构体 (从账户读取)
// - Debug: 能用 println!("{:?}", x) 打印
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct CounterAccount {
// 这是我们要存在账户里的数据
// 就像保险柜里的文件
pub count: u64, // 计数器的值
}
#序列化过程图解
【存入账户时】结构体 → 字节
CounterAccount { count: 42 }
↓ serialize()
[42, 0, 0, 0, 0, 0, 0, 0] (8个字节,小端序)
【从账户读取时】字节 → 结构体
[42, 0, 0, 0, 0, 0, 0, 0]
↓ deserialize()
CounterAccount { count: 42 }
#什么是小端序?
数字 42 的二进制是 00101010,存成 u64(8字节)时:
| 字节序 | 存储方式 | 说明 |
|---|---|---|
| 小端序(Solana用) | [42, 0, 0, 0, 0, 0, 0, 0] | 低位在前 |
| 大端序 | [0, 0, 0, 0, 0, 0, 0, 42] | 高位在前 |
#账户最终的样子
Account {
lamports: 1_000_000,
data: [42, 0, 0, 0, 0, 0, 0, 0], // <-- 我们的计数器数据
owner: CounterProgram,
executable: false,
rent_epoch: 123,
}
#第三部分:程序逻辑(柜员怎么工作)
这是 Solana 程序的核心部分,处理用户的请求。
// 【语法】use = 导入,类似 JS 的 import
// 【语法】{A, B, C} = 从同一个模块导入多个东西
use solana_program::{
// account_info 模块里的两个东西
account_info::{next_account_info, AccountInfo},
// 入口点宏
entrypoint,
// 入口点的返回类型
entrypoint::ProgramResult,
// 打印日志的宏
msg,
// 错误类型
program_error::ProgramError,
// 公钥类型
pubkey::Pubkey,
};
use borsh::{BorshDeserialize, BorshSerialize};
// 数据结构(和第二部分一样)
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct CounterAccount {
pub count: u64,
}
// 【语法】entrypoint!(函数名) = 宏调用,告诉 Solana 这是程序入口
// 宏就像一个代码生成器,会在编译时展开成更多代码
entrypoint!(process_instruction);
// 【语法】pub fn = 定义一个公开的函数
// 【语法】函数名(参数) -> 返回类型 { 函数体 }
pub fn process_instruction(
// 【语法】&Pubkey = 对 Pubkey 的引用(借用,不拥有)
// & 表示"借用",不会转移所有权,用完还给别人
program_id: &Pubkey, // 这个程序自己的地址
// 【语法】&[AccountInfo] = AccountInfo 数组的切片(引用)
// 切片是对数组一部分的引用
accounts: &[AccountInfo], // 传进来的账户列表
// 【语法】&[u8] = u8 数组的切片
instruction_data: &[u8], // 指令数据(告诉程序做什么)
// 【语法】-> ProgramResult = 返回类型是 ProgramResult
// ProgramResult = Result<(), ProgramError>
// 意思是:成功返回空,失败返回错误
) -> ProgramResult {
// 【语法】msg!() = 宏调用,打印日志
// ! 表示这是宏,不是普通函数
msg!("计数器程序开始执行!");
// 【语法】let = 声明变量
// 【语法】&mut = 可变引用(借用且可以修改)
// 【语法】.iter() = 获取迭代器
let accounts_iter = &mut accounts.iter();
// 【语法】? = 错误传播操作符
// 如果 next_account_info 返回错误,整个函数立即返回这个错误
// 如果成功,取出里面的值
let counter_account = next_account_info(accounts_iter)?;
// ⚠️ 重要检查:这个账户是不是归我管?
// 【语法】if 条件 { 代码块 }
// 【语法】!= = 不等于
if counter_account.owner != program_id {
msg!("错误:这个账户不归我管!");
// 【语法】return = 提前返回
// 【语法】Err() = 创建一个错误结果
return Err(ProgramError::IncorrectProgramId);
}
// 【语法】let mut = 声明可变变量(之后可以修改)
// 【语法】::try_from_slice() = 关联函数调用,类似静态方法
// 【语法】.borrow() = 借用 RefCell 里的值
// 【语法】& = 取引用
let mut counter_data = CounterAccount::try_from_slice(
&counter_account.data.borrow()
)?;
// ↑ 从字节反序列化成结构体 ↑ 借用账户的 data 字段
// 【语法】{} = 格式化占位符,会被后面的变量替换
msg!("当前计数: {}", counter_data.count);
// 【语法】+= = 加等于
counter_data.count += 1;
msg!("新的计数: {}", counter_data.count);
// 把新数据写回账户
// 【语法】.serialize() = 序列化成字节
// 【语法】&mut &mut ... = 双重可变引用(Solana 的特殊写法)
// 【语法】[..] = 完整切片,表示整个数组
// 【语法】.borrow_mut() = 可变借用
counter_data.serialize(
&mut &mut counter_account.data.borrow_mut()[..]
)?;
// 【语法】Ok(()) = 返回成功,值是空元组 ()
Ok(())
}
#Rust 所有权和借用(最难的概念!)
Rust 最独特的设计就是所有权系统,简单理解:
1. 所有权:每个值只有一个主人
let s1 = String::from("hello");
let s2 = s1; // s1 的所有权转移给 s2,s1 不能再用了!
2. 借用 &:临时借来用,用完还回去
let s1 = String::from("hello");
let s2 = &s1; // s2 借用 s1,s1 还能用
3. 可变借用 &mut:借来且可以修改
let mut s1 = String::from("hello");
let s2 = &mut s1; // s2 可以修改 s1
为什么这样设计?防止多个地方同时修改同一个数据,避免 bug。
#执行流程图解
用户发起交易: "我要给计数器+1"
│
▼
┌─────────────────┐
│ Counter Program │ (程序/柜员)
│ - 检查账户归属 │
│ - 读取当前值 │
│ - 计算新值 │
│ - 写回账户 │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Counter Account │ (数据账户/保险柜)
│ data: count = 42 │ ──► data: count = 43
│ owner: Program │
└─────────────────┘
#第四部分:客户端调用(用户怎么用)
前端使用 TypeScript 和 @solana/web3.js 来调用链上程序。
#基础设置
// 【语法】import { A, B } from "模块" = ES6 导入语法
// 从 @solana/web3.js 导入需要的类和函数
import {
Connection, // 连接到 Solana 网络
Keypair, // 密钥对(公钥+私钥)
PublicKey, // 公钥(地址)
Transaction, // 交易
TransactionInstruction, // 交易指令
SystemProgram, // 系统程序
sendAndConfirmTransaction, // 发送并确认交易
} from "@solana/web3.js";
// 【语法】const 变量名 = 值 = 声明常量
// 【语法】new 类名(参数) = 创建实例
// 连接到 Solana 开发网络
const connection = new Connection("https://api.devnet.solana.com");
// 【语法】Keypair.generate() = 调用静态方法,生成随机密钥对
// 你的钱包(有私钥,能签名)
const payer = Keypair.generate();
// 程序地址(部署后会得到)
const programId = new PublicKey("你的程序地址");
#步骤1:创建数据账户(创建保险柜)
// 【语法】async function 函数名(): 返回类型 { } = 异步函数
// 【语法】Promise<Keypair> = 返回一个 Promise,最终值是 Keypair
async function createCounterAccount(): Promise<Keypair> {
// 生成新账户的密钥对
const counterAccount = Keypair.generate();
// 计算需要多少空间(8字节存 u64)
const space = 8;
// 【语法】await = 等待异步操作完成
// 计算需要多少租金才能免租
const rentExemptBalance = await connection.getMinimumBalanceForRentExemption(space);
// 【语法】new Transaction().add() = 链式调用
// 创建交易,添加"创建账户"指令
const transaction = new Transaction().add(
// 【语法】对象字面量 { key: value }
SystemProgram.createAccount({
fromPubkey: payer.publicKey, // 谁出钱(从哪个账户扣)
newAccountPubkey: counterAccount.publicKey, // 新账户的地址
lamports: rentExemptBalance, // 给多少钱(租金)
space: space, // 数据大小(字节)
programId: programId, // 谁来管这个账户 ← 重要!
})
);
// 【语法】await = 等待交易确认
// 【语法】[payer, counterAccount] = 数组,包含需要签名的账户
await sendAndConfirmTransaction(
connection,
transaction,
[payer, counterAccount]
);
// 【语法】.toBase58() = 把公钥转成可读的字符串
console.log("创建了计数器账户:", counterAccount.publicKey.toBase58());
// 【语法】return = 返回值
return counterAccount;
}
#步骤2:调用程序(让柜员干活)
async function incrementCounter(counterAccountPubkey: PublicKey) {
// 【语法】: PublicKey = 类型注解,说明参数是 PublicKey 类型
// 构建指令
const instruction = new TransactionInstruction({
// 【语法】keys: [] = 告诉程序要操作哪些账户
keys: [
{
pubkey: counterAccountPubkey, // 账户地址
isSigner: false, // 这个账户不需要签名
isWritable: true, // 这个账户会被修改
},
],
programId: programId, // 调用哪个程序
// 【语法】Buffer.from([]) = 创建空的字节数组
data: Buffer.from([]), // 指令数据(这里为空,程序默认+1)
});
const transaction = new Transaction().add(instruction);
await sendAndConfirmTransaction(
connection,
transaction,
[payer]
);
console.log("计数器已+1!");
}
#步骤3:读取账户数据(看看保险柜里有什么)
async function readCounter(counterAccountPubkey: PublicKey) {
// 获取账户信息
const accountInfo = await connection.getAccountInfo(counterAccountPubkey);
// 【语法】=== null = 严格等于 null
if (accountInfo === null) {
console.log("账户不存在");
return;
}
// accountInfo 的结构:
// {
// lamports: 1000000, // 余额
// data: Buffer([42, 0, ...]), // 数据(字节数组)
// owner: PublicKey(...), // 所有者程序
// executable: false, // 是否可执行
// rentEpoch: 123 // 租金周期
// }
// 【语法】.readBigUInt64LE(0) = 从位置0开始,读取小端序的64位无符号整数
// LE = Little Endian = 小端序
const count = accountInfo.data.readBigUInt64LE(0);
// 【语法】.toString() = 把 BigInt 转成字符串
console.log("当前计数:", count.toString());
// 看看谁拥有这个账户
console.log("账户所有者:", accountInfo.owner.toBase58());
}
#完整流程
async function main() {
// 1. 创建保险柜(数据账户)
const counterAccount = await createCounterAccount();
// 2. 让柜员干活(调用程序)
await incrementCounter(counterAccount.publicKey);
await incrementCounter(counterAccount.publicKey);
await incrementCounter(counterAccount.publicKey);
// 3. 看看保险柜里的数据
await readCounter(counterAccount.publicKey);
// 输出: 当前计数: 3
}
#TypeScript 类型语法速查
const x: number = 1; // 数字类型
const s: string = "hello"; // 字符串类型
const b: boolean = true; // 布尔类型
const arr: number[] = [1,2]; // 数字数组
function foo(x: number): string { } // 参数类型 : 返回类型
async function bar(): Promise<void> { } // 异步函数返回 Promise
interface Person { // 接口定义
name: string;
age: number;
}
#第五部分:PDA(程序派生地址)
PDA 是 Solana 最重要的概念之一,理解它才能开发复杂的应用。
#什么是 PDA?
PDA 是一种特殊的账户地址:
- 没有私钥(没人能直接控制)
- 只有程序能"代表"它签名
- 地址是确定性的(同样的输入 = 同样的地址)
#为什么需要 PDA?
场景:你做一个游戏,每个玩家有自己的数据账户。
问题:怎么给每个玩家创建账户?
- 随机生成?那怎么找回来?
- 用玩家钱包地址?但那个账户归 System Program 管
解决:用 PDA!
- 用
[玩家地址]作为种子 - 生成一个确定的地址
- 这个地址归游戏程序管
#PDA 生成代码
use solana_program::pubkey::Pubkey;
// 【语法】fn 函数名(参数) -> 返回类型 { }
// 【语法】(Pubkey, u8) = 元组类型,包含两个值
fn get_player_data_address(
player: &Pubkey,
program_id: &Pubkey
) -> (Pubkey, u8) {
// 【语法】Pubkey::find_program_address = 调用 Pubkey 的关联函数
// 关联函数类似于其他语言的静态方法
Pubkey::find_program_address(
// 【语法】&[...] = 创建切片(数组的引用)
&[
// 【语法】b"xxx" = 字节字符串字面量
// "player_data" 变成 [112, 108, 97, 121, 101, 114, 95, 100, 97, 116, 97]
b"player_data", // 固定前缀(种子1)
// 【语法】.as_ref() = 转换成引用/切片
player.as_ref(), // 玩家的公钥(种子2)
],
program_id, // 程序ID
)
// 返回 (pda地址, bump值)
// bump 是一个数字(0-255),用于确保地址不在椭圆曲线上
}
// 使用示例
fn example() {
// 【语法】Pubkey::new_unique() = 生成一个唯一的公钥(测试用)
let program_id = Pubkey::new_unique();
let player_wallet = Pubkey::new_unique();
// 【语法】let (a, b) = ... = 解构元组
// 把返回的元组拆成两个变量
let (pda, bump) = get_player_data_address(&player_wallet, &program_id);
// pda 就是这个玩家的数据账户地址
// bump 是一个数字,用于确保地址没有私钥
// 【语法】println!() = 打印宏
// 【语法】{} = 占位符,会被后面的参数替换
println!("玩家钱包: {}", player_wallet);
println!("玩家数据账户(PDA): {}", pda);
println!("Bump: {}", bump);
}
#PDA 的妙用:程序可以"签名"
use solana_program::{
pubkey::Pubkey,
account_info::AccountInfo,
program::invoke_signed,
system_instruction,
};
fn transfer_from_pda(
program_id: &Pubkey,
pda_account: &AccountInfo,
recipient: &AccountInfo,
amount: u64,
bump: u8,
) {
// 【语法】&[...] = 创建切片
// 【语法】&[bump] = 把 bump 放进一个数组再取引用
let seeds = &[
b"vault",
&[bump], // bump 是签名的一部分
];
// 【语法】invoke_signed() = 带签名的跨程序调用
// 程序可以代表 PDA 签名!
// 这就像:保险柜没有钥匙,但柜员有权限打开它
invoke_signed(
// 第一个参数:要执行的指令
&system_instruction::transfer(
pda_account.key, // 从 PDA 转出
recipient.key, // 转给接收者
amount, // 金额
),
// 第二个参数:涉及的账户
// 【语法】.clone() = 克隆一份
&[pda_account.clone(), recipient.clone()],
// 第三个参数:签名种子
// 【语法】&[seeds] = 种子数组的切片
&[seeds], // 提供种子来"签名"
// 【语法】.unwrap() = 如果是 Ok 取出值,如果是 Err 就 panic
).unwrap();
}
#PDA 图解
普通账户:
┌─────────────┐
│ 地址: ABC │ ◄── 有私钥,用户可以签名
│ owner: Sys │
└─────────────┘
PDA 账户:
┌─────────────┐
│ 地址: XYZ │ ◄── 没有私钥!
│ owner: Game │ 但 Game 程序可以代表它签名
└─────────────┘
生成过程:
hash("player_data" + 玩家地址 + program_id + bump) = PDA地址
特点:
- 确定性:同样输入 = 同样输出
- 安全:没有私钥,只有程序能控制
- 可查找:知道种子就能算出地址
#Rust 常见语法补充
#元组
let tuple: (i32, &str, bool) = (1, "hello", true);
let (a, b, c) = tuple; // 解构
let first = tuple.0; // 用索引访问
#Option 类型
// Option<T> = Some(值) 或 None
let x: Option<i32> = Some(5);
let y: Option<i32> = None;
#Result 类型
// Result<T, E> = Ok(值) 或 Err(错误)
let x: Result<i32, &str> = Ok(5);
let y: Result<i32, &str> = Err("出错了");
#? 操作符
// 如果是 Err,立即返回错误
// 如果是 Ok,取出里面的值
let value = some_function()?;
// 等价于下面的 match
let value = match some_function() {
Ok(v) => v,
Err(e) => return Err(e),
};
#总结
| 概念 | 银行比喻 | 说明 |
|---|---|---|
| Account | 保险柜 | 存储数据和余额 |
| Program | 柜员 | 处理业务逻辑 |
| owner | 保险柜归谁管 | 只有 owner 能修改数据 |
| PDA | 程序专属保险柜 | 没有钥匙,只有程序能操作 |
| lamports | 钱 | 1 SOL = 10^9 lamports |
| data | 保险柜里的文件 | 序列化的字节数组 |
#核心要点
- Solana 上一切皆账户 - 钱包、程序、数据都是账户
- 程序是无状态的 - 数据存在单独的账户里
- owner 决定权限 - 只有 owner 程序能修改账户数据
- PDA 让程序拥有账户 - 确定性地址,程序可签名
#下一步学习
- Solana PDA 详解 - 更深入理解 PDA
- Anchor 入门教程 - 使用 Anchor 框架简化开发
- Solana Rust 学习路线 - 系统学习路线
本文内容哈希已存证,点击下方按钮可将哈希写入 Polygon 链上。