本节作者:叶万标
学生: "教授, 我最近在想, 我们之前写的简单的链上数据存储器, 是否能扩展成一个能执行转账的泰铢币程序? 似乎每个人只需要在自己的数据账户中记录自己的余额就可以了, 对吧?"
老师: "哈哈, 你已经走到一个非常关键的阶段了. 其实, 任何一个链上程序, 本质上都是一个状态机. 你想实现什么功能, 仅取决于你怎么去解释数据."
学生: "对啊, 我认为, 只需要程序给每个用户创建一个数据账户, 存他们自己的余额."
老师: "完全正确. 你可以继续想想, 泰铢币程序需要实现哪些指令?"
学生: "可以这么简单开始, 程序支持两个指令, 分别是铸造和转移. 前者增加代币总供应量, 后者则在两个账户之间转移代币."
老师: "别忘记了, 你还需要明确涉及的账户列表."
学生: "是的, 教授. 我想我对 solana 程序的设计有更深刻的认识了. 我们总是需要遵循先设计数据格式, 然后设计指令以及最后明确账户列表这三个步骤."
老师: "很棒! 你已经开始触类旁通了. 那么泰铢币程序就作为你这周的家庭作业了!"
学生: "太好了! 我这就开始画图纸, 然后一步步把它写出来."
当我们在区块链世界中编写去中心化应用时, 往往都是从最简单的链上数据存储器起步. 大概在 8 年前, 我第一次接触到区块链世界, 我看到的第一个教程就是教学如何在以太坊上编写一个数据存储器. 如今, 我成为了一个新的教程编写者, 当我思考我应该选择哪个应用作为我的教学例子时, 我立即想到了它, 我必须承认, 这是一种开源精神的传承.
我很喜欢一句话: 算法 + 数据结构 = 程序. 我认为即使是去中心化应用也遵循这个道理. 当您理解如何在链上存储任意数据后, 您就能通过调整算法来实现任意您想实现的程序.
Algorithms + Data Structures = Programs 是 N. Wirth 老爷子的经典著作.
链上数据存储器的本质是用一个数据账户, 在链上存储用户自己的任意信息.
我们如果想把它发展成一个"泰铢币"程序, 只需要从数据格式, 指令交互, 账户管理上这三个方面做一些改变. 下面, 我们就从这些角度, 看看它是怎么从数据存储器一步步进化的.
在最初的存储器中, 数据账户的结构很简单, 用户可以存储任意格式和长度的数据. 每个用户都有自己专属的数据账户, 合约只要校验 pda 地址和用户签名即可写入数据.
到了泰铢币程序, 我们就得让数据账户不仅仅是一个可以任意读写的个人空间, 而是真正的余额账户. 我们规定数据账户中只能存储一个 64 位无符号型整数, 且以大端序进行编码.
这样, 每个用户的数据账户就好像是在代币合约账本里的子账户, 明确记载了该用户拥有多少泰铢币.
在链上数据存储器阶段, 程序只有一个存储或更新数据的指令. 现在我们需要基于这个指令, 开发出两个新的指令:
这两个指令不仅要对余额账户读写, 还要进行基本的检查:
设计两条指令的接收数据格式. 简单来说, 泰铢币程序只接收 9 个字节的数据, 第一个字节用于区分您是想铸造还是转账, 剩余的字节表示为铸造代币的数量或转账代币的数量.
0x00 + u640x01 + u64每个指令都要明确声明它用到的账户(accounts 参数), 否则无法在 solana 运行. 需要额外注意的地方在于, 如果用户还不存在数据账户, 我们需要为他创建新的数据账户.
总结账户列表如下:
铸造
| 账户索引 | 地址 | 需要签名 | 可写 | 权限(0-3) | 角色 |
|---|---|---|---|---|---|
| 0 | ... | 是 | 是 | 3 | 铸造权限所有者的普通钱包账户 |
| 1 | ... | 否 | 是 | 1 | 铸造权限所有者的数据账户 |
| 2 | 1111111111... |
否 | 否 | 0 | System |
| 3 | SysvarRent... |
否 | 否 | 0 | Sysvar rent |
转账
| 账户索引 | 地址 | 需要签名 | 可写 | 权限(0-3) | 角色 |
|---|---|---|---|---|---|
| 0 | ... | 是 | 是 | 3 | 发送者的普通钱包账户 |
| 1 | ... | 否 | 是 | 1 | 发送者的数据账户 |
| 2 | ... | 否 | 否 | 0 | 接收者的普通钱包账户 |
| 3 | ... | 否 | 是 | 1 | 接收者的数据账户 |
| 4 | 1111111111... |
否 | 否 | 0 | System |
| 5 | SysvarRent... |
否 | 否 | 0 | Sysvar rent |
跟以太坊的 erc20 不同, solana 的合约世界非常灵活. 我们需要管理铸造的权限. 在本教程中, 我们选择把铸造权限写死在合约里, 当然您也可以单独搞个"权限账户"来管理铸造权限.
您也可以随时添加别的功能, 比如销毁或者批量转账, 这些功能虽然不是很常用, 但对于某些场景至关重要, 例如您想批量空投代币到上百万个用户: 如果没有批量转账功能, 这花费的手续费以及时间很可能是您无法接受的.
从最初的链上数据存储器, 到一个真正的泰铢币程序, 关键在于:
世界由您来定义, 见证您的泰铢币的诞生!
这篇文章介绍泰铢币的实现原理, 核心机制和背后的一些趣事点.
泰铢币的合约主函数 process_instruction(), 像个小开关盒子:
0x00, 就执行铸造操作, ada 亲自印钞, 往自己的账户里塞钱.0x01, 就执行两个账户之间的转账操作.切换指令全靠这一个字节, 简单粗暴, 也非常有 solana 的狂野风格.
#![allow(unexpected_cfgs)]
use solana_program::sysvar::Sysvar;
solana_program::entrypoint!(process_instruction);
pub fn process_instruction_mint(
_: &solana_program::pubkey::Pubkey,
_: &[solana_program::account_info::AccountInfo],
_: &[u8],
) -> solana_program::entrypoint::ProgramResult {
Ok(())
}
pub fn process_instruction_transfer(
_: &solana_program::pubkey::Pubkey,
_: &[solana_program::account_info::AccountInfo],
_: &[u8],
) -> solana_program::entrypoint::ProgramResult {
Ok(())
}
pub fn process_instruction(
program_id: &solana_program::pubkey::Pubkey,
accounts: &[solana_program::account_info::AccountInfo],
data: &[u8],
) -> solana_program::entrypoint::ProgramResult {
assert!(data.len() >= 1);
match data[0] {
0x00 => process_instruction_mint(program_id, accounts, &data[1..]),
0x01 => process_instruction_transfer(program_id, accounts, &data[1..]),
_ => unreachable!(),
}
}
在每次转账或铸币之前, 合约都会检查目标 pda 数据账户有没有被初始化. 如果没有的话, 立刻用 invoke_signed() 调用 solana_program::system_instruction::create_account() 创建账户并帮 pda 数据账户交齐租金, 保证租赁豁免.
数据账户里写上 8 字节的 u64::MIN, 表示 0 泰铢余额.
这个自动开户逻辑非常贴心, 让用户转账时不用先自己去初始化自己的数据账户. 铸造指令与转账指令初始化 pda 数据账户代码如下:
pub fn process_instruction_mint(
program_id: &solana_program::pubkey::Pubkey,
accounts: &[solana_program::account_info::AccountInfo],
data: &[u8],
) -> solana_program::entrypoint::ProgramResult {
let accounts_iter = &mut accounts.iter();
let account_user = solana_program::account_info::next_account_info(accounts_iter)?;
let account_user_pda = solana_program::account_info::next_account_info(accounts_iter)?;
let _ = solana_program::account_info::next_account_info(accounts_iter)?; // Program system
let _ = solana_program::account_info::next_account_info(accounts_iter)?; // Program sysvar rent
// Data account is not initialized. Create an account and write data into it.
if **account_user_pda.try_borrow_lamports().unwrap() == 0 {
let rent_exemption = solana_program::rent::Rent::get()?.minimum_balance(8);
let bump_seed =
solana_program::pubkey::Pubkey::find_program_address(&[&account_user.key.to_bytes()], program_id).1;
solana_program::program::invoke_signed(
&solana_program::system_instruction::create_account(
account_user.key,
account_user_pda.key,
rent_exemption,
8,
program_id,
),
accounts,
&[&[&account_user.key.to_bytes(), &[bump_seed]]],
)?;
account_user_pda.data.borrow_mut().copy_from_slice(&u64::MIN.to_be_bytes());
}
}
pub fn process_instruction_transfer(
program_id: &solana_program::pubkey::Pubkey,
accounts: &[solana_program::account_info::AccountInfo],
data: &[u8],
) -> solana_program::entrypoint::ProgramResult {
let accounts_iter = &mut accounts.iter();
let account_user = solana_program::account_info::next_account_info(accounts_iter)?;
let account_user_pda = solana_program::account_info::next_account_info(accounts_iter)?;
let account_into = solana_program::account_info::next_account_info(accounts_iter)?;
let account_into_pda = solana_program::account_info::next_account_info(accounts_iter)?;
let _ = solana_program::account_info::next_account_info(accounts_iter)?; // Program system
let _ = solana_program::account_info::next_account_info(accounts_iter)?; // Program sysvar rent
// Data account is not initialized. Create an account and write data into it.
if **account_into_pda.try_borrow_lamports().unwrap() == 0 {
let rent_exemption = solana_program::rent::Rent::get()?.minimum_balance(8);
let bump_seed =
solana_program::pubkey::Pubkey::find_program_address(&[&account_into.key.to_bytes()], program_id).1;
solana_program::program::invoke_signed(
&solana_program::system_instruction::create_account(
account_user.key,
account_into_pda.key,
rent_exemption,
8,
program_id,
),
accounts,
&[&[&account_into.key.to_bytes(), &[bump_seed]]],
)?;
account_into_pda.data.borrow_mut().copy_from_slice(&u64::MIN.to_be_bytes());
}
}
别以为谁都能在 ada 的世界里印泰铢币! 在铸造操作的开头, 我们来一段硬性校验:
assert_eq!(*account_user.key, solana_program::pubkey!("6ASf5EcmmEHTgDJ4X4ZT5vT6iHVJBXPg5AN5YoTCpGWt"));
只能 ada 本人签名, 才能铸币. 别想偷懒, 别想作弊, 防止通胀从根本做起(注: 此限制对 ada 无效)!
铸造流程也很简单, 首先读取 ada 的余额, 之后交易 data 参数里取出要铸造的金额, 两数相加, 写回 pda 数据账户. 在这个例子里, 数字以大端序存储.
// Mint.
let mut buf = [0u8; 8];
buf.copy_from_slice(&account_user_pda.data.borrow());
let old = u64::from_be_bytes(buf);
buf.copy_from_slice(&data);
let inc = u64::from_be_bytes(buf);
let new = old.checked_add(inc).unwrap();
account_user_pda.data.borrow_mut().copy_from_slice(&new.to_be_bytes());
对于转账操作的话, 先把收款方的 pda 账户初始化好(如果还没开过户), 之后读取发送方和接收方 pda 数据账户里的余额, 接着从交易 data 里取出转账金额, 双方余额做加减, 最后写回各自的 pda 数据账户.
要注意的是, 转账操作时必须验证发送人的 pda 账户确实属于发送人, 防止让他人扣了您的钱!
let account_need_pda =
solana_program::pubkey::Pubkey::find_program_address(&[&account_user.key.to_bytes()], program_id).0;
assert_eq!(account_user_pda.key, &account_need_pda);
Rust 的 .checked_sub() 和 .checked_add() 有溢出检测, 可以防止你搞个负数变成链上亿万富翁. 转账流程如下:
// Transfer.
let mut buf = [0u8; 8];
buf.copy_from_slice(&account_user_pda.data.borrow());
let old_user = u64::from_be_bytes(buf);
buf.copy_from_slice(&account_into_pda.data.borrow());
let old_into = u64::from_be_bytes(buf);
buf.copy_from_slice(&data);
let inc = u64::from_be_bytes(buf);
let new_user = old_user.checked_sub(inc).unwrap();
let new_into = old_into.checked_add(inc).unwrap();
account_user_pda.data.borrow_mut().copy_from_slice(&new_user.to_be_bytes());
account_into_pda.data.borrow_mut().copy_from_slice(&new_into.to_be_bytes());
Ok(())
在本小节中, 我们给出完整泰铢币的代码.
#![allow(unexpected_cfgs)]
use solana_program::sysvar::Sysvar;
solana_program::entrypoint!(process_instruction);
pub fn process_instruction_mint(
program_id: &solana_program::pubkey::Pubkey,
accounts: &[solana_program::account_info::AccountInfo],
data: &[u8],
) -> solana_program::entrypoint::ProgramResult {
let accounts_iter = &mut accounts.iter();
let account_user = solana_program::account_info::next_account_info(accounts_iter)?;
let account_user_pda = solana_program::account_info::next_account_info(accounts_iter)?;
let _ = solana_program::account_info::next_account_info(accounts_iter)?; // Program system
let _ = solana_program::account_info::next_account_info(accounts_iter)?; // Program sysvar rent
// Only Ada can mint more Thai Baht.
assert_eq!(*account_user.key, solana_program::pubkey!("6ASf5EcmmEHTgDJ4X4ZT5vT6iHVJBXPg5AN5YoTCpGWt"));
// Data account is not initialized. Create an account and write data into it.
if **account_user_pda.try_borrow_lamports().unwrap() == 0 {
let rent_exemption = solana_program::rent::Rent::get()?.minimum_balance(8);
let bump_seed =
solana_program::pubkey::Pubkey::find_program_address(&[&account_user.key.to_bytes()], program_id).1;
solana_program::program::invoke_signed(
&solana_program::system_instruction::create_account(
account_user.key,
account_user_pda.key,
rent_exemption,
8,
program_id,
),
accounts,
&[&[&account_user.key.to_bytes(), &[bump_seed]]],
)?;
account_user_pda.data.borrow_mut().copy_from_slice(&u64::MIN.to_be_bytes());
}
// Mint.
let mut buf = [0u8; 8];
buf.copy_from_slice(&account_user_pda.data.borrow());
let old = u64::from_be_bytes(buf);
buf.copy_from_slice(&data);
let inc = u64::from_be_bytes(buf);
let new = old.checked_add(inc).unwrap();
account_user_pda.data.borrow_mut().copy_from_slice(&new.to_be_bytes());
Ok(())
}
pub fn process_instruction_transfer(
program_id: &solana_program::pubkey::Pubkey,
accounts: &[solana_program::account_info::AccountInfo],
data: &[u8],
) -> solana_program::entrypoint::ProgramResult {
let accounts_iter = &mut accounts.iter();
let account_user = solana_program::account_info::next_account_info(accounts_iter)?;
let account_user_pda = solana_program::account_info::next_account_info(accounts_iter)?;
let account_into = solana_program::account_info::next_account_info(accounts_iter)?;
let account_into_pda = solana_program::account_info::next_account_info(accounts_iter)?;
let _ = solana_program::account_info::next_account_info(accounts_iter)?; // Program system
let _ = solana_program::account_info::next_account_info(accounts_iter)?; // Program sysvar rent
let account_need_pda =
solana_program::pubkey::Pubkey::find_program_address(&[&account_user.key.to_bytes()], program_id).0;
assert_eq!(account_user_pda.key, &account_need_pda);
// Data account is not initialized. Create an account and write data into it.
if **account_into_pda.try_borrow_lamports().unwrap() == 0 {
let rent_exemption = solana_program::rent::Rent::get()?.minimum_balance(8);
let bump_seed =
solana_program::pubkey::Pubkey::find_program_address(&[&account_into.key.to_bytes()], program_id).1;
solana_program::program::invoke_signed(
&solana_program::system_instruction::create_account(
account_user.key,
account_into_pda.key,
rent_exemption,
8,
program_id,
),
accounts,
&[&[&account_into.key.to_bytes(), &[bump_seed]]],
)?;
account_into_pda.data.borrow_mut().copy_from_slice(&u64::MIN.to_be_bytes());
}
// Transfer.
let mut buf = [0u8; 8];
buf.copy_from_slice(&account_user_pda.data.borrow());
let old_user = u64::from_be_bytes(buf);
buf.copy_from_slice(&account_into_pda.data.borrow());
let old_into = u64::from_be_bytes(buf);
buf.copy_from_slice(&data);
let inc = u64::from_be_bytes(buf);
let new_user = old_user.checked_sub(inc).unwrap();
let new_into = old_into.checked_add(inc).unwrap();
account_user_pda.data.borrow_mut().copy_from_slice(&new_user.to_be_bytes());
account_into_pda.data.borrow_mut().copy_from_slice(&new_into.to_be_bytes());
Ok(())
}
pub fn process_instruction(
program_id: &solana_program::pubkey::Pubkey,
accounts: &[solana_program::account_info::AccountInfo],
data: &[u8],
) -> solana_program::entrypoint::ProgramResult {
assert!(data.len() >= 1);
match data[0] {
0x00 => process_instruction_mint(program_id, accounts, &data[1..]),
0x01 => process_instruction_transfer(program_id, accounts, &data[1..]),
_ => unreachable!(),
}
}
在之前的文章中, 我们已经展示过如何编译以及部署程序, 此处不再赘述, 仅再次给出相关步骤和代码如下.
使用下面的命令编译程序代码.
$ cargo build-sbf -- -Znext-lockfile-bump
使用下面的 python 代码部署目标程序上链.
import pathlib
import pxsol
# Enable log
pxsol.config.current.log = 1
ada = pxsol.wallet.Wallet(pxsol.core.PriKey.int_decode(0x01))
program_data = pathlib.Path('target/deploy/pxsol_thaibaht.so').read_bytes()
program_pubkey = ada.program_deploy(bytearray(program_data))
print(program_pubkey) # 9SP6msRytNxeHXvW38xHxjsBHspqZERDTMh5Wi8xh16Q
此处泰铢币部署地址为 9SP6msRytNxeHXvW38xHxjsBHspqZERDTMh5Wi8xh16Q.
铸造新泰铢币的过程是通过一个 solana 交易来完成的. Ada 可以这样为自己铸造新的 100 个泰铢币. 您可能需要注意下 data 的构造, 它的长度为 9 个字节, 第一个字节为 0, 代表铸造操作.
另外要注意, 只有 ada 有权利铸造新的代币, 此权限已经在泰铢币的链上程序中被强制硬编码.
import base64
import pxsol
def mint(user: pxsol.wallet.Wallet, amount: int) -> None:
assert user.pubkey.base58() == '6ASf5EcmmEHTgDJ4X4ZT5vT6iHVJBXPg5AN5YoTCpGWt' # Is ada?
prog_pubkey = pxsol.core.PubKey.base58_decode('9SP6msRytNxeHXvW38xHxjsBHspqZERDTMh5Wi8xh16Q')
data_pubkey = prog_pubkey.derive_pda(user.pubkey.p)
rq = pxsol.core.Requisition(prog_pubkey, [], bytearray())
rq.account.append(pxsol.core.AccountMeta(user.pubkey, 3))
rq.account.append(pxsol.core.AccountMeta(data_pubkey, 1))
rq.account.append(pxsol.core.AccountMeta(pxsol.program.System.pubkey, 0))
rq.account.append(pxsol.core.AccountMeta(pxsol.program.SysvarRent.pubkey, 0))
rq.data = bytearray([0x00]) + bytearray(amount.to_bytes(8))
tx = pxsol.core.Transaction.requisition_decode(user.pubkey, [rq])
tx.message.recent_blockhash = pxsol.base58.decode(pxsol.rpc.get_latest_blockhash({})['blockhash'])
tx.sign([user.prikey])
txid = pxsol.rpc.send_transaction(base64.b64encode(tx.serialize()).decode(), {})
pxsol.rpc.wait([txid])
r = pxsol.rpc.get_transaction(txid, {})
for e in r['meta']['logMessages']:
print(e)
if __name__ == '__main__':
ada = pxsol.wallet.Wallet(pxsol.core.PriKey.int_decode(1))
mint(ada, 100)
使用 rpc 接口查询自己的数据账户中的数据, 并将其转换为 64 位无符号整数, 该数字即表示用户的泰铢币余额.
import base64
import pxsol
def balance(user: pxsol.core.PubKey) -> int:
prog_pubkey = pxsol.core.PubKey.base58_decode('9SP6msRytNxeHXvW38xHxjsBHspqZERDTMh5Wi8xh16Q')
data_pubkey = prog_pubkey.derive_pda(user.p)
info = pxsol.rpc.get_account_info(data_pubkey.base58(), {})
return int.from_bytes(base64.b64decode(info['data'][0]))
if __name__ == '__main__':
ada = pxsol.wallet.Wallet(pxsol.core.PriKey.int_decode(1))
print(balance(ada.pubkey))
Ada 向 bob 转账 50 泰铢币, 转账完成后, 查询双方的余额.
import base64
import pxsol
def balance(user: pxsol.core.PubKey) -> int:
prog_pubkey = pxsol.core.PubKey.base58_decode('9SP6msRytNxeHXvW38xHxjsBHspqZERDTMh5Wi8xh16Q')
data_pubkey = prog_pubkey.derive_pda(user.p)
info = pxsol.rpc.get_account_info(data_pubkey.base58(), {})
return int.from_bytes(base64.b64decode(info['data'][0]))
def transfer(user: pxsol.wallet.Wallet, into: pxsol.core.PubKey, amount: int) -> None:
prog_pubkey = pxsol.core.PubKey.base58_decode('9SP6msRytNxeHXvW38xHxjsBHspqZERDTMh5Wi8xh16Q')
upda_pubkey = prog_pubkey.derive_pda(user.pubkey.p)
into_pubkey = into
ipda_pubkey = prog_pubkey.derive_pda(into_pubkey.p)
rq = pxsol.core.Requisition(prog_pubkey, [], bytearray())
rq.account.append(pxsol.core.AccountMeta(user.pubkey, 3))
rq.account.append(pxsol.core.AccountMeta(upda_pubkey, 1))
rq.account.append(pxsol.core.AccountMeta(into_pubkey, 0))
rq.account.append(pxsol.core.AccountMeta(ipda_pubkey, 1))
rq.account.append(pxsol.core.AccountMeta(pxsol.program.System.pubkey, 0))
rq.account.append(pxsol.core.AccountMeta(pxsol.program.SysvarRent.pubkey, 0))
rq.data = bytearray([0x01]) + bytearray(amount.to_bytes(8))
tx = pxsol.core.Transaction.requisition_decode(user.pubkey, [rq])
tx.message.recent_blockhash = pxsol.base58.decode(pxsol.rpc.get_latest_blockhash({})['blockhash'])
tx.sign([user.prikey])
txid = pxsol.rpc.send_transaction(base64.b64encode(tx.serialize()).decode(), {})
pxsol.rpc.wait([txid])
r = pxsol.rpc.get_transaction(txid, {})
for e in r['meta']['logMessages']:
print(e)
if __name__ == '__main__':
ada = pxsol.wallet.Wallet(pxsol.core.PriKey.int_decode(1))
bob = pxsol.core.PriKey.int_decode(2).pubkey()
transfer(ada, bob, 50)
print(balance(ada.pubkey))
print(balance(bob))
源码已经打包好放上 github 啦!
如果你懒得跟着一步步敲代码(我懂你), 可以直接去看我准备好的示例项目. 地址在这儿, 不用谢我, 除非你想请我喝杯奶茶.
我知道许多开发者喜欢咖啡, 但对于我而言, 奶茶总是最好的.
有时候人生就像一部小说, 总得给我们点儿 déjà vu(既视感) 的惊喜.
$ git clone https://github.com/mohanson/pxsol-thaibaht
$ cd pxsol-thaibaht
$ python make.py deploy
# 2025/05/20 16:06:38 main: deploy program pubkey="9SP6msRytNxeHXvW38xHxjsBHspqZERDTMh5Wi8xh16Q"
注意到程序地址会被保存在 res/info.json 中, 后续操作会直接从此文件获取程序地址.
# Mint 21000000 Thai Baht for Ada
$ python make.py mint 21000000
# Show ada's balance
$ python make.py balance 6ASf5EcmmEHTgDJ4X4ZT5vT6iHVJBXPg5AN5YoTCpGWt
# 21000000
# Transfer 100 Thai Baht to Bob
$ python make.py transfer 100 8pM1DN3RiT8vbom5u1sNryaNT1nyL8CTTW3b5PwWXRBH
# Show ada's balance
$ python make.py balance 6ASf5EcmmEHTgDJ4X4ZT5vT6iHVJBXPg5AN5YoTCpGWt
# 20999900
# Show bob's balance
$ python make.py balance 8pM1DN3RiT8vbom5u1sNryaNT1nyL8CTTW3b5PwWXRBH
# 100