返回文章列表

Solana 账户模型完全指南:从 Rust 语法到 PDA 实战

2025年12月29日|22 min read

#写在前面

这篇文章是我学习 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程序专属保险柜没有钥匙,只有程序能操作
lamports1 SOL = 10^9 lamports
data保险柜里的文件序列化的字节数组

#核心要点

  1. Solana 上一切皆账户 - 钱包、程序、数据都是账户
  2. 程序是无状态的 - 数据存在单独的账户里
  3. owner 决定权限 - 只有 owner 程序能修改账户数据
  4. PDA 让程序拥有账户 - 确定性地址,程序可签名

#下一步学习


本文内容哈希已存证,点击下方按钮可将哈希写入 Polygon 链上。

END

💬 评论与讨论

使用 GitHub 账号登录即可评论,欢迎讨论和提问!