Skip to content

mosspig323/spl-token-faucet

Repository files navigation

用 Anchor 写 Solana program

简介

这篇文章旨在为了解 Solana 与智能合约相关背景知识的开发者介绍 Anchor。在这篇文章中,首先会介绍我们的目标:写一个 SPL Token 水龙头。然后,我们会用Anchor实现一个Faucet,通过这个过程来了解Anchor这个框架。 需要了解关于Solana相关的背景知识,可以先阅读以下文档:

『水龙头』是什么

数字货币的水龙头(以后都会用faucet替代),是一种可以让用户快速获得某种特定数字货币的装置。它可以是一个服务,可以是一系列接口,也可以是一个运行在链上的智能合约。 一个典型的『Faucet』会包含这些信息:

  • 可以赚什么币?(对本文来说,是SPL Token
  • 要怎么赚?(发起一个transaction
  • 能赚到多少?(我们准备通过配置参数来控制)

关于Faucet更多的信息可以参考这篇文章

使用Anchor实现『水龙头』program

那么,我们终于进入正题,开始用Anchor写一个Faucet项目。

读者可以选择跟随本文一起了解 Anchor ,或者直接阅读 Anchor官方文档。本文中用到的所有命令与代码范式,都可以在 Anchor官方文档或者官方例子中找到。

安装 Anchor 依赖

在最开始,我们需要安装Anchor依赖,这部分在官方文档中写得非常详细,本文就不再赘述。

让我们快速进入下一步,正式开始写代码。

通过Anchor cli生成新项目

$ 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>参数中包含了当前这个Programprogram_id,还有这个Instruction中需要用到的所有Account。具体包含哪些Account则是在 #[derive(Accounts)]装饰的struct中定义。

Initialize Faucet

让我们回忆一下我们的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: 我们需要通过FaucetConfigtoken_mint来确定Faucet支持的Token具体是哪一种,token_mintTokenProgramAccount地址,当中存储了关于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 标记和Solanamut 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

由于大部分信息都已经配置好,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方法。

PDA & CPI

为了在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,并构建生成用于生成PDAseed

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中,当我们需要通过一个PDAInstruction签名并调用另一个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是指生成PDAseeds加上和PDA一同返回的bump_seedSolana将通过signer_seeds来验证PDA的签名是否有效。

在这里我们通过faucet_config.publicKeynonce作为signer_seeds,所以我们需要保证创建SPL Token的时候用faucet_config.publicKey参数生成PDA,将生成的PDAbump_seed存到faucet_config中,并设置PDA地址为token_mint.authority

这部分代码将会在接下来的 Migration 模块详细介绍。

那么在加入CPIPDA部分的代码后,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部分就完成了。

最终代码可以参考这里

Error

因为 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

部署 Program

在本地测试执行通过之后,就可以部署到测试网络。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时的seedbump_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

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published