前面我们实现了一个链上数据存储器项目,并且将简单链上数据存储程序扩展为可交易的代币程序,这一节,我们用 Anchor 重写简单链上数据存储程序。
前面, 我们将第一个想法带上 solana, 我们编写了一个可以存储任意数据的简单数据存储程序. 我们使用原生 rust 编写了这个程序, 但过程中需要我们直面账户校验, 序列化和客户端打包这些杂事, 往往会消磨兴致. Anchor 出场的意义, 正是把这些粗重活接过去, 让你把精力放在要实现什么, 而不是怎么让代码"跑"起来.
Anchor 是一种为 solana 区块链设计的开发框架, 用于快速, 安全地构建和部署链上程序. 它通过提供工具和抽象来简化开发流程, 包括自动处理账户和指令数据的序列化, 内置安全检查, 生成客户端库以及提供测试工具.
我们在这里用 Anchor 重写那个数据存储程序, 让你体会它的魔力. 我们不会在这里做说明书式的工具介绍, 如果您需要它, 请参考官方文档. 我们只会准备一张干净的工作台来组装代码, 让你专注于实现核心功能. 你会看到 anchor 的核心心智模型, 完成一次从零到一的本地运行, 并学会辨认路上的几个小坑.
如果你的机器还没有这些工具, 请先安好: rust, solana cli, node.js 与 yarn, 以及 anchor 本体. 下面的命令可以直接复用; 若你已有其一, 可跳过相应小节.
安装 anchor(使用 avm 管理版本):
$ cargo install --git https://github.com/coral-xyz/anchor avm --locked --force
$ avm install latest
$ avm use latest
$ anchor --version
准备 solana cli 与本地链:
$ sh -c "$(curl -sSfL https://release.solana.com/stable/install)"
$ solana --version
$ solana config set --url http://127.0.0.1:8899
$ solana-test-validator -r
准备 node.js 与 yarn, 因为 anchor 的测试与客户端默认使用 ts:
$ npm install -g yarn
本章节的配套代码在这里. 如果你正在阅读该配套仓库, Anchor.toml 已预设本地网络与钱包路径, tests/ 里也放好了 ts 的测试脚本. 进入仓库根目录, 装上依赖即可:
$ yarn install
小提示: 第一次跑本地链时, 别忘了给默认钱包要点启动资金.
$ solana airdrop 2
我们先使用 anchor 搭一个最小可用的程序, 看看它长什么样.
$ anchor init pxsol-ss-anchor
$ cd pxsol-ss-anchor
脚手架会生成一套目录:
programs/<name>/src/lib.rs 是合约入口. 你会看到 #[program] 模块和一两个演示方法.Anchor.toml 是配置中心, 记录 program id, 要连接的集群, 测试脚本等.tests/ 放着 ts 测试, 等会儿它会代表客户端来"按按钮".先试着构建它:
$ anchor build
如果你还没启动本地链, 开一个终端让验证器常驻:
$ solana-test-validator -r
接着跑一次测试:
$ anchor test --skip-local-validator
这条命令做了三件事:
tests/ 下的 ts 测试用例当我们开始实现真正的业务, 可以沿着这条最小路径前进:
programs/<name>/src/lib.rs 里新增一个方法, 先写出期望的 accounts 结构与约束tests/ 写一个最小的调用脚本, 跑 anchor test 观察失败信息当你跨过这些门槛, anchor 就会像一把顺手的扳手. 你不用每天都去记 torx 和内六角的尺寸, 只管拧紧你真正关心的那颗螺丝.
本章节的配套代码在这里.
这一节, 我们用 anchor 实现一个数据存储合约, 走一遍从建模到程序构建的过程. 你会看到三个关键点: 账户心智模型, 两条指令(init/update), 以及动态重分配与租金的细枝末节. 代码出自 programs/pxsol-ss-anchor/src/lib.rs, 但我们以文字的方式来理解它.
我们知道用户数据实际上是存储在 pda 程序扩展账户里的. 在我们使用原生 rust 编写该程序时, 我们其实并没有对 pda 账户里的数据格式做过多约束, 只要能序列化与反序列化就行. 但在 anchor 里, 我们可以定义一个结构体来描述它, 并用 #[account] 标记它. 这种做法可以方便我们后续的开发, 也方便我们对链上数据的分析和理解.
#[account]
pub struct Data {
pub auth: Pubkey, // The owner of this pda account
pub bump: u8, // The bump to generate the PDA
pub data: Vec<u8> // The content, arbitrary bytes
}
impl Data {
pub fn space_for(data_len: usize) -> usize {
// 8 (discriminator) + 32 (auth) + 1 (bump) + 4 (vec len) + data_len
8 + 32 + 1 + 4 + data_len
}
}
方法 space_for() 用来计算账户所需空间. 这里的空间由五部分组成. 我们需要使用该函数来计算账户的租赁豁免金额.
我们设计了两条指令: init 和 update. init 用来初始化程序扩展账户, update 用来更新内容. 下面我们逐一分析它们的实现.
指令 init 做了三件事: 记住谁是拥有者, 记录 bump, 并把内容置空.
pub fn init(ctx: Context<Init>) -> Result<()> {
let account_user = &ctx.accounts.user;
let account_user_pda = &mut ctx.accounts.user_pda;
account_user_pda.auth = account_user.key();
account_user_pda.bump = ctx.bumps.user_pda;
account_user_pda.data = Vec::new();
Ok(())
}
账户约束里, init 会在第一次调用时分配账户与租金, 由拥有者 payer = user 支付:
#[derive(Accounts)]
pub struct Init<'info> {
#[account(mut)]
pub user: Signer<'info>,
#[account(
init,
payer = user,
seeds = [SEED, user.key().as_ref()],
bump,
space = Data::space_for(0)
)]
pub user_pda: Account<'info, Data>,
pub system_program: Program<'info, System>,
}
此时程序扩展账户里的数据字段是空的, 但已具备完整身份与归属, 并达成了租赁豁免.
更新内容时, 我们允许程序扩展账户变大或变小. 变大需要补齐租金, 变小则把多出来的 lamports 退给拥有者. 逻辑可以读作三步: 验权, 重分配, 找零. Anchor 框架会帮我们处理租赁豁免与扣费, 但找零需要我们自己来做. 也就是当新数据比老数据大时, 我们不需要做什么, anchor 会自动帮我们补齐租赁豁免资金; 但当新数据比老数据小时, 我们要把多出来的 lamports 退给拥有者.
pub fn update(ctx: Context<Update>, data: Vec<u8>) -> Result<()> {
let account_user = &ctx.accounts.user;
let account_user_pda = &mut ctx.accounts.user_pda;
// Authorization: only the stored authority can update.
require_keys_eq!(account_user_pda.auth, account_user.key(), PxsolError::Unauthorized);
// At this point, Anchor has already reallocated the account according to the `realloc = ...` constraint
// (using `new_data.len()`), pulling extra lamports from auth if needed to maintain rent-exemption.
account_user_pda.data = data;
// If the account was shrunk, Anchor won't automatically refund excess lamports. Refund any surplus (over the
// new rent-exempt minimum) back to the user.
let account_user_pda_info = account_user_pda.to_account_info();
let rent = Rent::get()?;
let rent_exemption = rent.minimum_balance(account_user_pda_info.data_len());
let hold = **account_user_pda_info.lamports.borrow();
if hold > rent_exemption {
let refund = hold.saturating_sub(rent_exemption);
// Transfer lamports from PDA to user using the PDA as signer.
let signer_seeds: &[&[u8]] = &[SEED, account_user.key.as_ref(), &[account_user_pda.bump]];
let signer = &[signer_seeds];
let cpictx = CpiContext::new_with_signer(
ctx.accounts.system_program.to_account_info(),
system_program::Transfer { from: account_user_pda_info.clone(), to: account_user.to_account_info() },
signer,
);
// It's okay if refund equals current - min_rent; system program enforces balances.
system_program::transfer(cpictx, refund)?;
}
Ok(())
}
相配套的账户约束清晰地约定了该指令的一些策略细节.
#[derive(Accounts)]
#[instruction(new_data: Vec<u8>)]
pub struct Update<'info> {
#[account(mut)]
pub user: Signer<'info>,
#[account(
mut,
seeds = [SEED, user.key().as_ref()],
bump = user_pda.bump,
realloc = Data::space_for(new_data.len()),
realloc::payer = user,
realloc::zero = false,
constraint = user_pda.auth == user.key() @ PxsolError::Unauthorized,
)]
pub user_pda: Account<'info, Data>,
pub system_program: Program<'info, System>,
}
require_keys_eq!(…)new_with_signer, seeds 里别忘了 bump.user 出, 余额不足会失败.我们 anchor 版本的数据存储器很平凡, 却把 anchor 最常用的几块能力都连接在了一起: 账户约束, 动态重分配, pda 代签. 把它跑通之后, 我们可以继续加上一些更加复杂的逻辑. 代码总共只有不到 100 行, 但它是个很好的起点. 您应该能很快阅读懂它, 因此在这里不再过多赘述.
当你写下第一行合约代码, 测试就是它开口说的第一句话. 我们希望它既能在框架下顺畅表达, 也能在底层协议里自证严谨. 本节把测试当成一段小旅程: 先用 anchor 自带的 ts 测试框架走一条铺好的大道, 再用 python 下的 pxsol 客户端走一条原野小路(直接按二进制协议构造交易数据).
目标很朴素: 在本地链上, 初始化一个数据存储器, 多次更新内容, 然后把它读回来确认数据无误. 路径与代码都在仓库的 tests/ 目录里.
这条路最省心. 你只需要告诉 anchor: 我要哪个程序, 要调用这个程序的哪个指令, 带上哪些账户与参数. 其余的编解码与账户核验, 由 anchor 和 idl 替你完成.
Anchor 的 idl 会在你第一次构建程序时自动生成. 它记录了程序 id, 每个指令的账户与参数, 以及每个账户的数据结构. 你可以把它想象成一个桥梁, 连接链上程序与链下客户端.
我们的测试很比较简单, 先调用一次 init, 然后调用两次 update, 每次都传入不同长度的内容. 每次调用后, 我们都 fetch 一次账户数据, 确认内容正确.
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { PxsolSsAnchor } from "../target/types/pxsol_ss_anchor";
describe("pxsol-ss-anchor", () => {
// Configure the client to use the local cluster.
anchor.setProvider(anchor.AnchorProvider.env());
const program = anchor.workspace.pxsolSsAnchor as Program<PxsolSsAnchor>;
const provider = anchor.getProvider() as anchor.AnchorProvider;
const wallet = provider.wallet as anchor.Wallet;
const walletPda = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("data"), wallet.publicKey.toBuffer()],
program.programId
)[0];
it("Init with content and then update (grow and shrink)", async () => {
// Airdrop SOL to fresh authority to fund rent and tx fees
await provider.connection.confirmTransaction(await provider.connection.requestAirdrop(
wallet.publicKey,
2 * anchor.web3.LAMPORTS_PER_SOL
), "confirmed");
const poemInitial = Buffer.from("");
const poemEnglish = Buffer.from("The quick brown fox jumps over the lazy dog");
const poemChinese = Buffer.from("片云天共远, 永夜月同孤.");
const walletPdaData = async (): Promise<Buffer<ArrayBuffer>> => {
let walletPdaData = await program.account.data.fetch(walletPda);
return Buffer.from(walletPdaData.data);
}
await program.methods
.init()
.accounts({
user: wallet.publicKey,
userPda: walletPda,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([wallet.payer])
.rpc();
if (!(await walletPdaData()).equals(poemInitial)) throw new Error("mismatch");
await program.methods
.update(poemEnglish)
.accounts({
user: wallet.publicKey,
userPda: walletPda,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([wallet.payer])
.rpc();
if (!(await walletPdaData()).equals(poemEnglish)) throw new Error("mismatch");
await program.methods
.update(poemChinese)
.accounts({
user: wallet.publicKey,
userPda: walletPda,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([wallet.payer])
.rpc();
if (!(await walletPdaData()).equals(poemChinese)) throw new Error("mismatch");
});
});
运行:
# 自动构建, 部署到本地链并运行 ts 测试
$ anchor test
这条路更贴近协议本身. 我们会亲手排列账户列表, 拼接 8 字节方法 discriminator, 再把 4 字节小端长度与原始字节流接在后头. 它适合跨语言集成, 或在没有 anchor 客户端的环境里验算每一步.
代码如下:
import argparse
import base64
import pxsol
parser = argparse.ArgumentParser()
parser.add_argument('--net', type=str, choices=['develop', 'mainnet', 'testnet'], default='develop')
parser.add_argument('--prikey', type=str, default='11111111111111111111111111111112')
parser.add_argument('args', nargs='+')
args = parser.parse_args()
user = pxsol.wallet.Wallet(pxsol.core.PriKey.base58_decode(args.prikey))
prog_pubkey = pxsol.core.PubKey.base58_decode('GS5XPyzsXRec4sQzxJSpeDYHaTnZyYt5BtpeNXYuH1SM')
data_pubkey = prog_pubkey.derive_pda(b'data' + user.pubkey.p)
def init():
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.data = bytearray().join([
bytearray([220, 59, 207, 236, 108, 250, 47, 100]),
])
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)
def update():
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.data = bytearray().join([
bytearray([219, 200, 88, 176, 158, 63, 253, 127]),
len(args.args[1].encode()).to_bytes(4, 'little'),
args.args[1].encode(),
])
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)
def load():
info = pxsol.rpc.get_account_info(data_pubkey.base58(), {})
print(base64.b64decode(info['data'][0])[8 + 32 + 1 + 4:].decode())
if __name__ == '__main__':
eval(f'{args.args[0]}()')
运行:
$ solana-test-validator -l /tmp/solana-ledger
$ anchor deploy
# Program Id: GS5XPyzsXRec4sQzxJSpeDYHaTnZyYt5BtpeNXYuH1SM
$ python tests/pxsol-ss-anchor.py update "The quick brown fox jumps over the lazy dog"
$ python tests/pxsol-ss-anchor.py load
# The quick brown fox jumps over the lazy dog
$ python tests/pxsol-ss-anchor.py update "片云天共远, 永夜月同孤."
$ python tests/pxsol-ss-anchor.py load
# 片云天共远, 永夜月同孤.