这篇文章旨在为了解 Solana 与智能合约相关背景知识的开发者介绍 Anchor。在这篇文章中,首先会介绍我们的目标:写一个 SPL Token 水龙头。然后,我们会用Anchor实现一个Faucet,通过这个过程来了解Anchor这个框架。
需要了解关于Solana相关的背景知识,可以先阅读以下文档:
数字货币的水龙头(以后都会用faucet替代),是一种可以让用户快速获得某种特定数字货币的装置。它可以是一个服务,可以是一系列接口,也可以是一个运行在链上的智能合约。
一个典型的『Faucet』会包含这些信息:
- 可以赚什么币?(对本文来说,是
SPL Token) - 要怎么赚?(发起一个
transaction) - 能赚到多少?(我们准备通过配置参数来控制)
关于Faucet更多的信息可以参考这篇文章
那么,我们终于进入正题,开始用Anchor写一个Faucet项目。
读者可以选择跟随本文一起了解 Anchor ,或者直接阅读 Anchor官方文档。本文中用到的所有命令与代码范式,都可以在 Anchor官方文档或者官方例子中找到。
在最开始,我们需要安装Anchor依赖,这部分在官方文档中写得非常详细,本文就不再赘述。
让我们快速进入下一步,正式开始写代码。
$ anchor init faucet
$ cd faucet可以看到Anchor为我们生成好了目录结构,这个结构基本保持与Solana Program一致
|- app
|- migrations // 迁移脚本
|- programs
| |- faucet
| | |- src
| | | |- lib.rs // Program 代码
|- tests // 测试脚本
|- Cargo.lock
|- Cargo.toml
|- Anchor.toml // Anchor 配置可以看到src目录里已经生成了一个lib.rs文件,里面已经包含了基本的代码结构
use anchor_lang::prelude::*;
#[program]
pub mod faucet {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> ProgramResult {
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize {}首先,代码中的#[program]宏定义了一个Solana Program。这意味着,这个模块所有的方法都应该对应到一个Instruction。
Context<Initialize>参数中包含了当前这个Program的program_id,还有这个Instruction中需要用到的所有Account。具体包含哪些Account则是在 #[derive(Accounts)]装饰的struct中定义。
让我们回忆一下我们的Faucet Program具体的功能 -- 当用户通过rpc调用了某个特定的Instruction之后,我们就给用户指定的账号发送一些SPL Token
- 发送的
token种类由用户在我们提供的一个列表中选择; - 发送的
token数量则由Faucet Program配置决定;
那么在开放给用户使用之前,我们需要先初始化Faucet Program,为他配置支持的token种类和每次发送给用户的token数量。
由于Solana Program本身不会存储状态,所有的状态都会存储在Account中。那么我们需要先声明用于存储Faucet配置的Account数据结构。
我们在代码的最下方添加
#[account]
pub struct FaucetConfig {
token_program: Pubkey,
token_mint: Pubkey,
token_authority: Pubkey,
nonce: u8,
drip_volume: u64,
}#[account]: 这个宏为struct增加了Account序列化和反序列化的实现。Pubkey: 类型代表Solana账号的公钥。存储在FaucetConfig中的公钥主要是给客户端读取或者作为Instruction的约束条件使用token_program: 是指一个特定的Solana Program,它实现了SPL Token的一些公共Instruction(例如我们会用到的Mint_to),关于Token的解释可以参考: Solana Token- token_mint: 我们需要通过
FaucetConfig中token_mint来确定Faucet支持的Token具体是哪一种,token_mint是TokenProgram的Account地址,当中存储了关于Token的信息(比如 PRT 的地址) - token_authority + nonce: 之后关于
PDA & CPI的部分会介绍,现在可以简单的认为我们将通过 token_authority + nonce 获取操作TokenProgram的权限,然后给用户发一些Token
为了在initialize方法中能够修改FaucetConfig的配置,我们需要为initialize函数添加参数
pub fn initialize(ctx: Context<InitializeFaucet>, nonce: u8, drip_volume: u64) -> ProgramResult {
Ok(())
}接下来,需要在 InitializeFaucet 中添加需要的 account。在添加之前,我们需要增加一些依赖项。
我们需要在Cargo.toml里加上依赖库,修改后的依赖声明变成了这样
# Cargo.toml
# ...
[dependencies]
anchor-lang = "0.4.1"
anchor-spl = "0.4.1"回到lib.rs,在文件顶端添加import
// lib.rs
use anchor_spl::token;然后就可以在struct中添加我们需要的Account了
// lib.rs
#[derive(Accounts)]
pub struct InitializeFaucet<'info> {
#[account(init)]
faucet_config: ProgramAccount<'info, FaucetConfig>,
#[account("token_program.key == &token::ID")]
token_program: AccountInfo<'info>,
#[account(mut)]
token_mint: AccountInfo<'info>,
#[account()]
token_authority: AccountInfo<'info>,
rent: Sysvar<'info, Rent>,
}#[account(init)]: 由于faucet_config是一个新创建的account,我们需要通过这个Instruction为它初始化数据,所以必须添加#[account(init)]宏,同时还需要增加rent:Sysvar<'info, Rent>定义。否则transaction会失败。关于Rent,可以通过这里了解更多Rent#[account(mut)]:mut标记和Solana中mut account一样,让Program能够把数据持久化到account.data中#[account("token_program.key == &token:ID")]: 这里的作用是检查token_program是否正确。其他可用的宏参数可以在这里找到。
好,现在需要的数据和账号都已经准备好,下一步就是完成我们的initialize方法,在initialize方法中我们只需要把数据保存到account中就可以,所以,修改后的initialize方法是:
#[program]
pub mod faucet {
use super::*;
pub fn initialize(ctx: Context<InitializeFaucet>, nonce: u8, drip_volume: u64) -> ProgramResult {
let faucet_config = &mut ctx.accounts.faucet_config;
faucet_config.token_program = *ctx.accounts.token_program.key;
faucet_config.token_mint = *ctx.accounts.token_mint.key;
faucet_config.token_authority = *ctx.accounts.token_authority.key;
faucet_config.nonce = nonce;
faucet_config.drip_volume = drip_volume;
Ok(())
}
}下一步我们需要实现Drip方法
由于大部分信息都已经配置好,Drip方法就只需要指定『把token发给谁』就可以。所以我们在 faucet mod 中添加一个函数
pub mod faucet {
pub fn drip(ctx: Context<Drip>) -> ProgramResult {
Ok(())
}
}然后,我们需要定义所需的Account
#[derive(Accounts)]
pub struct Drip<'info> {
#[account()]
faucet_config: ProgramAccount<'info, FaucetConfig>,
#[account("token_program.key == &token::ID")]
token_program: AccountInfo<'info>,
#[account(mut, "&faucet_config.token_mint == token_mint.key")]
token_mint: AccountInfo<'info>,
#[account("&faucet_config.token_authority == token_authority.key")]
token_authority: AccountInfo<'info>,
#[account(mut)]
receiver: AccountInfo<'info>,
}在 #[account(...)] 中我们添加了一些验证,确保用户传入的账号与配置中的账号一致。
接下来开始实现Drip方法。
为了在Drip方法中调用 TokenProgram::MintTo 方法(在Solana中被称为CPI,关于CPI可以通过这个文档了解更多CPI),我们需要获得能够为TokenProgram::MintTo授权的Account。
但Drip方法的Signer是希望获得一些Airdrop的用户TokenAccount,它一定不会是tokenMint::token_authority,无法获得授权,所以在这里,我们需要用到PDA完成签名。关于 CPI 与 PDA 的详细介绍,可以参看官方文档
Anchor简化了CPI调用的方式,并且在生成PDA的场合也变得简单了许多。首先我们需要获取保存在 FaucetConfig 中的nonce,并构建生成用于生成PDA的seed
pub mod faucet {
// ... initialize
pub fn drip(ctx: Context<Drip>) -> ProgramResult {
let faucet_config = ctx.accounts.faucet_config.clone();
let seeds = &[
faucet_config.to_account_info().key.as_ref(),
&[faucet_config.nonce],
];
Ok(())
}
}在Solana中,当我们需要通过一个PDA为Instruction签名并调用另一个Program的方法时,需要通过调用invoke_signed来实现。
invoke_signed(instruction, accounts, signer_seeds);在anchor中,这个方法被分解成了两部分:
let cpi_ctx = CpiContext::new_with_signer(cpi_program, cpi_accounts, signer_seeds);
token::mint_to(cpi_ctx)?;这里的signer_seeds是指生成PDA的seeds加上和PDA一同返回的bump_seed。Solana将通过signer_seeds来验证PDA的签名是否有效。
在这里我们通过faucet_config.publicKey与nonce作为signer_seeds,所以我们需要保证创建SPL Token的时候用faucet_config.publicKey参数生成PDA,将生成的PDA与bump_seed存到faucet_config中,并设置PDA地址为token_mint.authority。
这部分代码将会在接下来的 Migration 模块详细介绍。
那么在加入CPI与PDA部分的代码后,Drip方法将会变成这样
pub fn drip(ctx: Context<Drip>) -> ProgramResult {
let faucet_config = ctx.accounts.faucet_config.clone();
let seeds = &[
faucet_config.to_account_info().key.as_ref(),
&[faucet_config.nonce],
];
let signer_seeds = &[&seeds[..]];
let cpi_accounts = MintTo {
mint: ctx.accounts.token_mint.to_account_info(),
to: ctx.accounts.receiver.to_account_info(),
authority: ctx.accounts.token_authority.to_account_info(),
};
let cpi_program = ctx.accounts.token_program.clone();
let cpi_ctx = CpiContext::new_with_signer(cpi_program, cpi_accounts, signer_seeds);
token::mint_to(cpi_ctx, faucet_config.drip_volume)?;
Ok(())
}这时候由于我们还没引入MintTo这个 struct,所以会有一个编译错误,让我们修改一下anchor_spl�的引入代码
use anchor_spl::token::{self, MintTo};到这里我们的Program部分就完成了。
最终代码可以参考这里
因为 Program 中的程序验证全都依靠 Anchor 提供的宏命令完成,所以并没有自定义 Error 的出场机会。这里我们简单的介绍一下 Anchor 中的 Error。
// in processor
pub fn error_testing() -> ProgramError {
return Err(FaucetError::WhateverError.into());
}
// ...
#[error]
pub enum FaucetError {
#[msg("Error message")]
WhateverError,
}通过以上的代码,可以让Instruction失败。
在部署之前,还是写一些集成测试比较好。普通的Solana项目主要是通过TypeScript(JavaScript)进行测试,我们同样也使用 TypeScript。
第一步,我们在当前目录中需要初始化一个NodeJS项目。在项目根目录创建一个package.json文件。
{
"name": "faucet",
"version": "1.0.0",
"scripts": {
"test": "anchor test"
},
"private": true
}接下来,我们创建一个单元测试的文件,可以直接把 tests 目录下的 JS 文件重命名成: ./tests/faucet.spec.ts
在开始写单元测试之前,我们需要安装一些依赖库。在终端中执行以下命令:
$ npm install --save @project-serum/anchor @project-serum/serum @project-serum/common @solana/spl-token
$ npm install --save-dev @types/mocha assert因为我们计划用TypeScript编写测试脚本,所以还需要添加 tsconfig.json 文件
// tsconfig.json
{
"compilerOptions": {
"esModuleInterop": true,
"module": "CommonJS",
"moduleResolution": "node",
"strictNullChecks": true,
"baseUrl": "."
},
"exclude": [
"node_modules"
],
"include": [
"./tests/**/*"
]
}然后把 faucet.test.ts 文件内容替换成
import * as anchor from "@project-serum/anchor";
describe("faucet", () => {
// Configure the client to use the local cluster.
anchor.setProvider(anchor.Provider.env());
it("Is initialized!", async () => {
// Add your test here.
const program = anchor.workspace.Faucet;
const tx = await program.rpc.initialize();
console.log("Your transaction signature", tx);
});
});这时候执行npm run test,会获得一个错误信息:Error: Unable to read keypair file
因为我们没有给Anchor指定正确的Account文件地址。
如果还没有创建过solana keypair可以执行:solana-keygen new。
接着修改 ./Anchor.toml
cluster = "localnet"
wallet = "~/.config/solana/id.json"接下来可以正式开始写单元测试。具体单元测试的内容可以参看: faucet.spec.ts
在本地测试执行通过之后,就可以部署到测试网络。Anchor提供了非常方便的命令行工具,只需要几条简单的指令就可以完成部署和初始化。
我们还需要为我们的Faucet写一个初始化脚本。
首先添加依赖
const { Program, Provider, Wallet, web3, workspace, BN } = require("@project-serum/anchor");
const { TokenInstructions } = require('@project-serum/serum');
const { createMint } = require("@project-serum/common")为了方便我们为Faucet创建SPL Token,我们需要在 deploy.js 中增加一个新的函数
/**
* tokenConfig: { symbol: string, name: string, decimals: number }
*/
const createToken = async (provider, program, tokenConfig) => {
const tokenOwnerAccount = new web3.Account();
const [tokenAuthority, tokenNonce] = await web3.PublicKey.findProgramAddress(
[tokenOwnerAccount.publicKey.toBuffer()],
program.programId
);
const splToken = await createMint(
provider,
tokenAuthority,
tokenConfig.decimals
);
console.log(`Created ${tokenConfig.symbol} Token`, splToken.toBase58());
return {
tokenOwnerAccount,
splToken,
tokenNonce,
tokenAuthority,
};
}这个函数会创建一种新的TokenMint并且把MintAuthority、创建PDA时的seed、bump_seed一同返回。
还记得在drip方法中,我们尝试通过一个PDA签名调用MintTo。为了保证PDA确实有mint权限,我们需要在CreateMint时将相同的PDA设置成mintAuthority。
所以我们需要将CreateToken的返回值作为初始化参数传入到FaucetProgram中。
接下来修改deploy主函数
module.exports = async function (provider) {
anchor.setProvider(provider);
const faucetProgram = workspace.Faucet;
const wallet = provider.wallet;
// 币种配置
const tokenConfigs = [
{
symbol: 'btc',
name: 'Wrapped Bitcoin',
decimals: 8,
dripVolume: new BN(10 ** 8)
},
{
symbol: 'eth',
name: 'Wrapped Ether',
decimals: 8,
dripVolume: new BN(10 ** 8)
}
];
for (const tokenConfig of tokenConfigs) {
const { tokenOwnerAccount: faucetConfigAccount, splToken, tokenNonce, tokenAuthority } = await createToken(provider, faucetProgram, tokenConfig);
await faucetProgram.rpc.initialize(tokenNonce, tokenConfig.dripVolume, {
accounts: {
faucetConfig: faucetConfigAccount.publicKey,
tokenMint: splToken,
tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID,
tokenAuthority,
rent: web3.SYSVAR_RENT_PUBKEY
},
signers: [faucetConfigAccount],
instructions: [
await faucetProgram.account.faucetConfig.createInstruction(faucetConfigAccount)
],
});
}
}这样就完成了migration脚本。
我们在package.json里加上部署指令
// ...
"scripts": {
// ...
"build": "anchor build",
"predeploy:devnet": "npm run build",
"deploy:devnet": "anchor deploy --url https://devnet.solana.com"
},
// ...在部署之前需要获取一些SOL作为燃料
solana airdrop 5 <你的钱包地址> --url https://devnet.solana.com然后就可以通过以下指令部署到开发网络
npm run deploy:devnet部署完成之后可以看到控制台输出
Program Id: <program-id>
Deploy success