Jacky Gu

Solana开发笔记: 使用anchor框架创建Mint代币

01 Jan 2025 Share to

使用anchor框架创建Mint代币

前面一篇中,详细介绍了旧版的SPL代币的命令行,在这篇中,使用anchor框架来创建一个代币程序。

如果spl代币的mint-authority和freeze-authority是mint账户的话,铸币、冻结、解冻等操作是无法通过命令行执行的,只能在程序中操作。

本篇就此方法做介绍。

本系列的源码:https://github.com/jackygu2006/tutorial_legacy_spl_token

创建anchor程序

anchor init legacy_token

配置rust库

打开programs/legacy_token/Cargo.toml,在[dependencies]添加:

[dependencies]
anchor-lang = {version = "0.30.1",  features = ["init-if-needed"]}
anchor-spl = {version = "0.30.1", features = ["metadata", "token", "associated_token"]}
mpl-token-metadata = "3.2.3"

然后设置 idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]

注意:上述配置的前提是当前开发环境中用的anchor-lang版本是0.30.1,如果版本不同,这里需要更改。

anchor程序对不同库的版本兼容性很差,所以一旦各个库确定好了能编译顺利通过的版本后,不要轻易换版本。

创建程序的目录结构

programs/legacy_token/目录下,创建以下目录结构和文件(lib.rs是系统自动生成的)

/src/
    lib.rs
    instructions/
        mod.rs
        initialize_token.rs
        ...
    states.rs
    errors.rs
    constants.rs
  • lib.rs: 入口程序
  • instructions: 指令目录,每条指令是一个命令,如initialize_token, mint_token, burn_token等。
  • state.rs: PDA账户的结构
  • errors.rs: 错误枚举数据
  • constants.rs: 常量

上面是一般中大型solana程序的目录结构,如果指令数很少,可以都放在lib.rs文件中。

完善states.rs

在states.rs中加入TokenMetadata数据结构,用来规范token的元数据。

use anchor_lang::prelude::*;

#[account]
#[derive(Debug)]
pub struct TokenMetadata {
    pub name: String,
    pub symbol: String,
    pub uri: String,
		pub decimals: u8,
}

完善errors.rs

这个文件集中保存了各种出错的提示信息,先暂时配置一个错误提示MetadataAccountInitialized,之后随着程序的壮大,会不断增加。

use anchor_lang::prelude::*;

#[error_code]
pub enum ErrorCode {
	#[msg("Metadata account already initialized")]
	MetadataAccountInitialized,
}

完善constants.rs

这个文件中保存了一些常量,如MINT_SEEDSMAX_SYMBOL_LENGTH等,先暂时配置如下:

pub const MINT_SEEDS: &str = "mint";

在instructions/initialize_token.rs中实现指令

代码如下:

use anchor_lang::prelude::*;
use crate::{
  constants::MINT_SEEDS,
  errors::ErrorCode,
  states::*,
};

use anchor_spl::{
	metadata::{
		mpl_token_metadata::types::DataV2, 
		CreateMetadataAccountsV3, 
		create_metadata_accounts_v3, 
		Metadata as Metaplex
	}, 
	token::{Mint, Token}
};

#[derive(Accounts)]
#[instruction(metadata_params: Box<TokenMetadata>)]
pub struct InitializeToken<'info> {
	/// CHECK: New Metaplex Account being created
	#[account(
    mut,
    constraint = metadata.to_account_info().data_is_empty() @ ErrorCode::MetadataAccountInitialized
  )]
	pub metadata: UncheckedAccount<'info>,
	#[account(
		init,
		seeds = [MINT_SEEDS.as_bytes(), metadata_params.name.as_bytes(), metadata_params.symbol.as_bytes()],
		bump,
		payer = payer,
		mint::decimals = metadata_params.decimals,
		mint::authority = mint,
		mint::freeze_authority = mint,
	)]
	pub mint: Account<'info, Mint>,
	#[account(mut)]
	pub payer: Signer<'info>,
	pub rent: Sysvar<'info, Rent>,
	pub system_program: Program<'info, System>,
	pub token_program: Program<'info, Token>,
	pub token_metadata_program: Program<'info, Metaplex>,
}

impl<'info> InitializeToken<'info> {
	pub fn initialize_token(
		ctx: Context<InitializeToken>,
		metadata_params: Box<TokenMetadata>,
	) -> Result<()> {
		let _metadata = metadata_params.clone();
		let seeds = &[
			MINT_SEEDS.as_bytes(),
			_metadata.name.as_bytes(),
			_metadata.symbol.as_bytes(),
			&[ctx.bumps.mint],
		];
		let signer = [&seeds[..]];

		let token_data: DataV2 = DataV2 {
			name: metadata_params.name,
			symbol: metadata_params.symbol,
			uri: metadata_params.uri,
			seller_fee_basis_points: 0,
			creators: None,
			collection: None,
			uses: None,
		};

		let metadata_ctx = CpiContext::new_with_signer(
			ctx.accounts.token_metadata_program.to_account_info(),
			CreateMetadataAccountsV3 {
				payer: ctx.accounts.payer.to_account_info(),
				update_authority: ctx.accounts.mint.to_account_info(),
				mint: ctx.accounts.mint.to_account_info(),
				metadata: ctx.accounts.metadata.to_account_info(),
				mint_authority: ctx.accounts.mint.to_account_info(),
				system_program: ctx.accounts.system_program.to_account_info(),
				rent: ctx.accounts.rent.to_account_info(),
			},
			&signer,
		);
		create_metadata_accounts_v3(metadata_ctx, token_data, false, true, None)?;

		msg!("Token mint initiazlied successfully.");
		Ok(())
	}
}

一般一个指令程序分三个部分:

  • 引用需要的crates
  • 定义账户结构
  • 定义方法

账户结构解释

1- metadata账户:是metaplex用于保存metadata的账户,把该账户定义为UncheckedAccount,并通过/// CHECK声明账户。

2- 为了防止使用已经定义过的metadata账户,使用约束检验:metadata.to_account_info().data_is_empty() @ ErrorCode::MetadataAccountInitialized,即如果metadata账户中有数据,则返回错误。

3- mint账户: 我们使用anchor框架来初始化mint账户,在之后的文章中会介绍如何手动创建mint账户。

有两种情况需要手动创建mint账户而不是使用anchor框架自动创建: 第一种情况是在一个指令中超出了anchor自动创建账户的上限,在anchor中,一条指令中最多创建3个账户,如果超过这个限制,需要手动创建。 第二种情况是有特殊需求,比如在创建Transfer fee扩展的Token2022代币mint时,因为anchor尚未开发该扩展的自动化创建,也就是没有类似:extensions::metadata_pointer::authority = mintextensions::metadata_pointer::metadata_address = mint的结构化程序时,需要手动创建。(在之后Transfer fee扩展编程时会介绍)

4- 在创建mint时,需要指定以下参数:

  • seeds: 用于指定生成mint账户的规则,mint账户本质上是PDA账户,也就是一个没有私钥的特殊账户,其地址由seeds生成。

    如例子中:seeds = [MINT_SEEDS.as_bytes(), metadata_params.name.as_bytes(), metadata_params.symbol.as_bytes()],说明该mint由标识符MINT_SEEDS,代币名称和代币符号构成,也就是相同的代币名称和符号会被视为相同的代币。

  • bump: 一般情况下直接写就行,即由anchor自动定义bump。

  • payer: 创建mint账户的付款人,必须指定,而且必须是一个Signer类型的账户。

  • 指定参数,包括

    • mint::decimals: 如果不写,则默认为9;
    • mint::authority: 即mint authority,铸造权账户
    • mint::freeze_authority: 即冻结、解冻权账户

5- 签名人账户

签名人负责支付创建mint账户的费用,因为在solana上创建每个账户都需要支付租金(rent),这笔租金将由该签名人支付。

签名人的账户类型必须是Signer<'info>,而且因为要支付费用,所以必须定义为mut

在本例子中,签名人的账户名定义为payer.

6- 系统账户

包括如下几个系统账户:

pub rent: Sysvar<'info, Rent>,
pub system_program: Program<'info, System>,
pub token_program: Program<'info, Token>,
pub token_metadata_program: Program<'info, Metaplex>,
  • rent:租金管理账户
  • system_program: 系统账户,也就是111111111111111111111111111111111
  • token_program: 代币程序账户,就两种:
    • 一种是Legacy代币程序,地址为TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA
    • 另一个是Token2022代币程序,地址为TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb.
  • token_metadata_program: 这是Metaplex的元数据管理程序地址: metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s

指令方法解释

1- 本指令是创建一个mint,同时初始化metadata账户。因为旧版的spl-token程序没有配置metadata的功能,需要借助Metaplex库完成,因为anchor已经集成了Metaplex库,所以只需要用以下代码即可导入:

use anchor_spl::{
	metadata::{
		mpl_token_metadata::types::DataV2, 
		CreateMetadataAccountsV3, 
		create_metadata_accounts_v3, 
		Metadata as Metaplex
	}, 
	token::{Mint, Token}
};

2- 另外,我们希望由mint程序来创建metadata程序,所以需要用mint账户进行签名,调用Metaplex的初始化metadata账户的CPI调用,所以需要构建mint的签名:

let _metadata = metadata_params.clone();
let seeds = &[
  MINT_SEEDS.as_bytes(),
  _metadata.name.as_bytes(),
  _metadata.symbol.as_bytes(),
  &[ctx.bumps.mint],
];
let signer = [&seeds[..]];

注意:在定义mint签名时,确保seeds和mint账户地址定义时的方法一模一样。

3- 创建metadata账户:

方法create_metadata_accounts_v3的定义如下:

pub fn create_metadata_accounts_v3<'info>(
  ctx: CpiContext<'_, '_, '_, 'info, CreateMetadataAccountsV3<'info>>, 
  data: mpl_token_metadata::types::DataV2, 
  is_mutable: bool, 
  update_authority_is_signer: bool, 
  collection_details: Option<mpl_token_metadata::types::CollectionDetails>
) -> Result<()>

其中ctx定义如下:

let metadata_ctx = CpiContext::new_with_signer(
  ctx.accounts.token_metadata_program.to_account_info(),
  CreateMetadataAccountsV3 {
    payer: ctx.accounts.payer.to_account_info(),
    update_authority: ctx.accounts.mint.to_account_info(),
    mint: ctx.accounts.mint.to_account_info(),
    metadata: ctx.accounts.metadata.to_account_info(),
    mint_authority: ctx.accounts.mint.to_account_info(),
    system_program: ctx.accounts.system_program.to_account_info(),
    rent: ctx.accounts.rent.to_account_info(),
  },
  &signer,
);

元数据data定义如下:

let token_data: DataV2 = DataV2 {
  name: metadata_params.name,
  symbol: metadata_params.symbol,
  uri: metadata_params.uri,
  seller_fee_basis_points: 0,
  creators: None,
  collection: None,
  uses: None,
};

在入口程序lib.rs中调用initialize_token指令

在lib.rs中添加:

pub fn initialize_token(ctx: Context<InitializeToken>, metadata_data: Box<TokenMetadata>) -> Result<()> {
    InitializeToken::initialize_token(ctx, metadata_data)
}

至此,程序部分完成。

编译与部署

设置调试环境

  • 如果是本地调试
    • 在本地终端启动solana-test-validator
    • 在本地终端执行solana config set --url localhost
    • Anchor.yaml文件中的[provider]部分配置cluster = "localnet"
  • 如果是在devnet上调试
    • 在本地终端执行solana config set --url devnet
    • Anchor.yaml文件中的[provider]部分配置cluster = "devnet"

注意:参与测试的账户中需要有足够的SOL。

编译

执行:

anchor build

编译完成后,在./target/目录中会生成(或更新)一系列文件。

部署

执行anchor deploy 

或者solana program deploy ./target/deploy/legacy_token.so

写前端测试脚本

1- 准备测试账户

为方便多账户之间测试,在tests目录下创建一个账户文件:accounts.json,保存几个账户的publicKey和secretKey。

[
  {
    "publicKey": "",
    "secretKey": [100, ..., 222]
  },
  {
    "publicKey": "",
    "secretKey": [100, ..., 222]
  }
]

在测试脚本legacy_token.ts中,定义几个操作账户:

const deployerAccount = anchor.web3.Keypair.fromSecretKey(Uint8Array.from(testAccounts[0].secretKey));
const user1Account =  anchor.web3.Keypair.fromSecretKey(Uint8Array.from(testAccounts[1].secretKey));
const user2Account =  anchor.web3.Keypair.fromSecretKey(Uint8Array.from(testAccounts[2].secretKey));
const user3Account =  anchor.web3.Keypair.fromSecretKey(Uint8Array.from(testAccounts[3].secretKey));

2- 空投SOL

定义一个空投函数,给每个账户一定数量SOL,确保测试时有足够SOL:

  const airdropSol = async (accounts: number) => {
    if(provider.connection.rpcEndpoint === 'http://localhost:8899' || provider.connection.rpcEndpoint === 'http://127.0.0.1:8899') { 
      for(let i = 0; i < accounts; i++) {
        const account = anchor.web3.Keypair.fromSecretKey(Uint8Array.from(testAccounts[i].secretKey));
        const airdropSignature = await provider.connection.requestAirdrop(new anchor.web3.PublicKey(account.publicKey), 10000000000);
        await provider.connection.confirmTransaction(airdropSignature, "confirmed");
        console.log(`Airdrop to #${i + 1} successfully, signature: ${airdropSignature}`);
      }
    }
  }

3- 写初始化代币的测试脚本

  it("Initialize token", async () => {
    const metadataParams = {
      name: "Solana test token",
      symbol: "STT01",
      uri: "https://node1.irys.xyz/F7qkMeZGbDwZbbo6E6XkOahuUlGEcXElWgGnH5zm4zQ",
      decimals: 9,
    };
    const mintAccount = anchor.web3.PublicKey.findProgramAddressSync(
      [Buffer.from("mint"), Buffer.from(metadataParams.name), Buffer.from(metadataParams.symbol)],
      program.programId,
    )[0];
    
    const metadataAccountPda = anchor.web3.PublicKey.findProgramAddressSync(
      [
        Buffer.from("metadata"),
        TOKEN_METADATA_PROGRAM_ID.toBuffer(),
        mintAccount.toBuffer()
      ],
      TOKEN_METADATA_PROGRAM_ID,
    )[0];
    
    const context = {
      metadata: metadataAccountPda,
      mint: mintAccount,
      payer: deployerAccount.publicKey,
      tokenProgram: TOKEN_PROGRAM_ID,
      tokenMetadataProgram: TOKEN_METADATA_PROGRAM_ID,
    };
    const tx = await program.methods.initializeToken(metadataParams).accounts(context).signers([deployerAccount]).rpc();
    console.log("Your transaction signature", tx);
  });

4- 使用anchor test编译+部署+测试

anchor test --skip-local-validator --skip-deploy --skip-build --skip-lint

5- 检查结果

如果一切正常,应该得到一个交易Hash,复制该交易Hash,在区块链浏览器中查询交易细节:

从上图可以看到mint和metadata账户都创建成功。