Solana —— SPL TOKEN 简单合约

本节实现了一个 Solana SPL Token 程序,包括链上智能合约和客户端调用代码。主要功能包括:

  • 创建新的 SPL Token
  • 铸造代币到用户账户

1. 核心概念

1.1 什么是 SPL Token

SPL Token 是 Solana 上的标准代币协议,类似于以太坊的 ERC-20。它定义了在 Solana 区块链上创建和管理可替代代币的规范。

1.2 关键账户类型

账户类型 所有者 用途 大小
Mint Account SPL Token Program 存储代币元数据(总供应量、小数位数、铸币权限等) 82 字节
Token Account SPL Token Program 存储用户持有的特定代币余额 165 字节
Associated Token Account (ATA) SPL Token Program 与用户钱包关联的标准代币账户 165 字节

1.3 核心程序

  • SPL Token Program:管理代币铸造、转账等操作的核心程序
  • Associated Token Account Program:管理 ATA 的创建和查询
  • System Program:Solana 系统程序,用于创建账户和转账 SOL

2. 链上程序实现

2.1 程序入口

1
2
3
4
5
6
7
8
9
10
11
pub fn process(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
let instruction = TokenInstruction::try_from_slice(instruction_data)?;
match instruction {
TokenInstruction::CreateToken { decimals } => Self::create_token(accounts, decimals),
TokenInstruction::Mint { amount } => Self::mint(accounts, amount),
}
}

代码解析:

  • try_from_slice:使用 Borsh 反序列化指令数据
  • match:根据指令类型路由到不同的处理函数
  • 支持两种指令:创建代币和铸造代币

2.2 创建代币流程

步骤一:账户准备

1
2
3
4
5
6
7
let accounts_iter = &mut accounts.iter();
let mint_account = next_account_info(accounts_iter)?; // Mint 账户
let mint_authority = next_account_info(accounts_iter)?; // 铸币权限
let payer = next_account_info(accounts_iter)?; // 支付者
let rent_sysvar = next_account_info(accounts_iter)?; // 租金系统变量
let system_program = next_account_info(accounts_iter)?; // 系统程序
let token_program = next_account_info(accounts_iter)?; // SPL Token 程序

关键点:

  • next_account_info:按顺序获取账户信息
  • 账户顺序必须与客户端传入的顺序一致
  • 每个账户都有特定的用途和权限要求

步骤二:创建 Mint 账户

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
invoke(
&system_instruction::create_account(
payer.key, // 支付者
mint_account.key, // 新账户地址
(Rent::get()?).minimum_balance(Mint::LEN), // 租金
Mint::LEN as u64, // 账户大小
token_program.key, // 所有者程序
),
&[
mint_account.clone(),
payer.clone(),
system_program.clone(),
token_program.clone(),
],
)?;

代码解析:

  • invoke:跨程序调用(CPI),调用系统程序创建账户
  • minimum_balance(Mint::LEN):计算存储 Mint 数据所需的最低租金
  • Mint::LEN:Mint 账户固定大小为 82 字节
  • token_program.key:将账户所有权转给 SPL Token 程序

租金机制:

Solana 要求账户保持最低余额以避免被垃圾回收。82 字节 Mint 账户约需 0.00144 SOL(具体金额取决于网络)。这个余额类似于押金,关闭账户时可以完全收回。

步骤三:初始化 Mint

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let ix = initialize_mint(
&spl_token::id(), // SPL Token 程序 ID
&mint_account.key, // Mint 账户地址
&mint_authority.key, // 铸币权限所有者
None, // 冻结权限(可选)
decimals, // 小数位数
)?;

invoke_signed(
&ix,
&[
mint_account.clone(),
rent_sysvar.clone(),
token_program.clone(),
mint_authority.clone(),
],
&[], // 签名种子(此处为空)
)?;

代码解析:

  • initialize_mint:构造初始化 Mint 的指令
  • decimals:代币小数位数(如 USDC 使用 6,表示 1 USDC = 1,000,000 基础单位)
  • None:不设置冻结权限(设置后可冻结用户账户)
  • invoke_signed:使用签名调用(这里虽然传空数组,但保持接口一致性)

为什么分两步?

  1. 系统程序只负责创建账户并分配空间
  2. SPL Token 程序负责初始化 Mint 的具体数据结构

这种设计符合 Solana 的所有权模型:只有账户的所有者程序才能修改其数据。

2.3 铸造代币流程

步骤一:账户准备

1
2
3
4
5
6
7
8
let accounts_iter = &mut accounts.iter();
let mint_account = next_account_info(accounts_iter)?; // Mint 账户
let associated_token_account = next_account_info(accounts_iter)?; // ATA
let rent_sysvar = next_account_info(accounts_iter)?; // 租金
let payer = next_account_info(accounts_iter)?; // 支付者
let system_program = next_account_info(accounts_iter)?; // 系统程序
let token_program = next_account_info(accounts_iter)?; // Token 程序
let associated_token_program = next_account_info(accounts_iter)?; // ATA 程序

步骤二:检查并创建 ATA

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
if associated_token_account.lamports() == 0 {
msg!("Creating associated token account...");
invoke(
&spl_associated_token_account::instruction::create_associated_token_account(
payer.key, // 支付者
payer.key, // ATA 所有者
mint_account.key, // 代币类型
token_program.key, // Token 程序
),
&[
payer.clone(),
associated_token_account.clone(),
mint_account.clone(),
system_program.clone(),
token_program.clone(),
rent_sysvar.clone(),
associated_token_program.clone(),
],
)?;
}

代码解析:

  • lamports() == 0:检查账户是否存在(未创建的账户余额为 0)
  • ATA 地址通过确定性算法生成:derive([owner, token_program, mint])
  • 首次铸造时自动创建,后续无需重复创建

ATA 的优势:

  • 每个用户对每种代币只有一个标准地址
  • 地址可预测,无需提前告知
  • 简化钱包管理

步骤三:执行铸币

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
invoke(
&mint_to(
token_program.key, // Token 程序
mint_account.key, // Mint 账户
associated_token_account.key, // 接收账户
payer.key, // 铸币权限所有者
&[payer.key], // 签名者列表
amount, // 铸造数量
)?,
&[
mint_account.clone(),
payer.clone(),
associated_token_account.clone(),
token_program.clone(),
],
)?;

代码解析:

  • mint_to:SPL Token 程序的铸币指令
  • payer.key:必须是 Mint 的 mint_authority
  • amount:最小单位数量(需要考虑 decimals)

权限验证:

SPL Token 程序会验证 payer 是否为 mint_authority。只有拥有铸币权限的账户才能铸造新代币。

3. 客户端实现

3.1 指令定义

1
2
3
4
5
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub enum TokenInstruction {
CreateToken { decimals: u8 },
Mint { amount: u64 },
}

代码解析:

  • 枚举类型定义两种指令
  • BorshSerialize/BorshDeserialize:实现 Borsh 序列化
  • 必须与链上程序的定义完全一致

3.2 创建代币

基础配置

1
2
3
4
let rpc_client = RpcClient::new("http://127.0.0.1:8899".to_string());
let payer = read_keypair_file("/home/sol/.config/solana/wallet.json")?;
let program_id = Pubkey::from_str("4KKcAuZrm8y7wxoVa4PpR8YQqx96bSSuD6bdVsYmvNWE")?;
let mint_account = Keypair::new();

代码解析:

  • RpcClient:连接到本地测试网(也可连接 devnet、mainnet)
  • read_keypair_file:从文件加载钱包私钥
  • Keypair::new():生成新的 Mint 账户密钥对

构建指令数据

1
let instruction_data = borsh::to_vec(&TokenInstruction::CreateToken { decimals })?;

序列化过程:

  1. 创建 CreateToken 指令实例
  2. Borsh 序列化为字节数组
  3. 字节数组会被传递给链上程序

配置账户元数据

1
2
3
4
5
6
7
8
let accounts = vec![
AccountMeta::new(mint_account.pubkey(), true), // 可写 + 签名
AccountMeta::new_readonly(*mint_authority, false), // 只读
AccountMeta::new_readonly(payer.pubkey(), false), // 只读
AccountMeta::new_readonly(sysvar::rent::id(), false), // 只读
AccountMeta::new_readonly(system_program::id(), false), // 只读
AccountMeta::new_readonly(spl_token::id(), false), // 只读
];

AccountMeta 详解:

方法 说明 用途
new(pubkey, is_signer) 可写账户 需要修改数据或余额的账户
new_readonly(pubkey, is_signer) 只读账户 只读取数据的账户

**重要提示:**账户顺序必须与链上程序 next_account_info 的顺序完全一致!

构建和发送交易

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let token_instruction = Instruction {
program_id: *program_id,
accounts,
data: instruction_data,
};

let latest_blockhash = rpc_client.get_latest_blockhash()?;
let tx = Transaction::new_signed_with_payer(
&[token_instruction], // 指令列表
Some(&payer.pubkey()), // 手续费支付者
&[payer, mint_account], // 签名者列表
latest_blockhash, // 最新区块哈希
);

let signature = rpc_client.send_and_confirm_transaction(&tx)?;

代码解析:

  • get_latest_blockhash():获取最新区块哈希防止重放攻击
  • 双重签名:payer 支付手续费,mint_account 授权创建
  • send_and_confirm_transaction:发送并等待确认

3.3 铸造代币

计算 ATA 地址

1
2
3
4
let ata = get_associated_token_address(
&payer.pubkey(),
&mint_account.pubkey(),
);

确定性推导:

  • 基于 owner 和 mint 确定性计算
  • 无需提前创建,地址可预测
  • 公式:findProgramAddress([owner, TOKEN_PROGRAM_ID, mint], ASSOCIATED_TOKEN_PROGRAM_ID)

配置账户元数据

1
2
3
4
5
6
7
8
9
let accounts = vec![
AccountMeta::new(mint_account.pubkey(), true), // 可写 + 签名
AccountMeta::new(ata, false), // 可写
AccountMeta::new_readonly(sysvar::rent::id(), false), // 只读
AccountMeta::new(payer.pubkey(), true), // 可写 + 签名
AccountMeta::new_readonly(system_program::id(), false), // 只读
AccountMeta::new_readonly(spl_token::id(), false), // 只读
AccountMeta::new_readonly(spl_associated_token_account::id(), false), // 只读
];

权限说明:

  • Mint 账户可写:需要更新 supply 字段
  • ATA 可写:需要更新代币余额
  • Payer 可写:需要支付 ATA 创建费用(如果尚未创建)

发送交易

1
2
3
4
5
6
7
8
let tx = Transaction::new_signed_with_payer(
&[token_instruction],
Some(&payer.pubkey()),
&[payer, mint_account], // Mint 需要签名验证铸币权限
latest_blockhash,
);

let signature = rpc_client.send_and_confirm_transaction(&tx)?;

4. 完整工作流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
客户端                              Solana 网络                          链上程序
| | |
|-------- CreateToken 交易 ----------->| |
| |---------- 调用 process() --------->|
| | |
| | create_token() |
| | 1. 创建 Mint 账户 |
| | 2. 初始化 Mint 数据 |
| |<---------- 返回成功 ----------------|
|<--------- 交易确认 ------------------| |
| | |
|---------- Mint 交易 ---------------->| |
| |---------- 调用 process() --------->|
| | |
| | mint() |
| | 1. 检查/创建 ATA |
| | 2. 铸造代币到 ATA |
| |<---------- 返回成功 ---------------|
|<--------- 交易确认 ------------------| |

5. 核心知识点

5.1 CPI(跨程序调用)

1
2
3
4
5
// 普通调用
invoke(&instruction, &accounts)?;

// 使用 PDA 签名调用
invoke_signed(&instruction, &accounts, &[&seeds])?;

使用场景:

调用方式 使用场景 示例
invoke 调用其他程序的公开功能 调用 System Program 创建账户
invoke_signed 程序代表 PDA 签名 PDA 作为铸币权限

5.2 账户所有权模型

账户类型 所有者 可修改数据
Mint Account SPL Token Program SPL Token Program
Token Account SPL Token Program SPL Token Program
Wallet System Program System Program
PDA 自定义程序 自定义程序

核心原则: 只有账户的所有者程序才能修改账户数据

5.3 交易签名规则

1
&[payer, mint_account]

签名者列表规则:

  • 所有标记为 is_signer: true 的账户都需要签名
  • 手续费支付者(payer)必须签名

5.4 Decimals 设计原则

代币类型 decimals 示例 1 代币 = ? 基础单位
稳定币 6 USDC, USDT 1,000,000
治理代币 9 SOL 1,000,000,000
NFT 0 不可分割 1

6. 实战技巧

6.1 调试日志

1
2
msg!("Creating mint account...");
msg!("Mint: {}", mint_account.key);

查看日志:

1
solana logs

6.2 账户大小计算

1
2
3
// Mint 账户:82 字节
// Token 账户:165 字节
let rent = Rent::get()?.minimum_balance(size);

6.3 权限管理最佳实践

1
2
3
4
5
// 创建时设置 mint_authority
initialize_mint(..., &mint_authority.key, None, decimals)?;

// 铸造完成后可撤销权限(不可逆)
set_authority(..., None, AuthorityType::MintTokens)?;

6.4 错误处理

1
let result = invoke(...)?;  // ? 操作符传播错误

常见错误:

错误类型 原因 解决方案
AccountNotFound 账户不存在 检查账户地址是否正确
InsufficientFunds 余额不足 确保账户有足够的 SOL
InvalidAccountData 数据格式错误 检查序列化/反序列化逻辑
MissingRequiredSignature 缺少必要签名 检查签名者列表

7. 常见问题

Q1: 为什么需要租金?

A: Solana 通过租金机制防止状态膨胀,账户需要保持最低余额。但这更像是押金,关闭账户时可以完全收回。

Q2: ATA 地址会冲突吗?

A: 不会冲突,ATA 通过 [owner, mint] 确定性生成,每个组合唯一。

Q3: 可以修改 decimals 吗?

A: 不可以,Mint 初始化后 decimals 不可更改。

Q4: 如何转移铸币权限?

A: 使用 set_authority 指令修改 mint_authority。

Q5: 代币可以销毁吗?

A: 可以,使用 burn 指令销毁持有的代币。

Q6: 为什么要分两步创建账户?

A: 遵循 Solana 的所有权模型。System Program 创建账户并分配空间,然后将所有权转给 Token Program,最后由 Token Program 初始化具体数据。

9. 总结

  • 账户是核心:Mint Account 和 Token Account 是 SPL Token 的基础
  • 所有权很重要:只有所有者程序可以修改账户数据
  • CPI 是关键:通过跨程序调用实现功能组合
  • ATA 简化管理:确定性地址生成简化代币账户管理
  • 租金是押金:不是真正的费用,关闭账户时可以收回
  • 程序和数据分离:遵循 Solana 的账户模型设计

Hooray!SPL Token 开发学习完成!!!