本文介绍了如何使用 Anchor 框架在 Solana 上创建一个 Token Sale 程序。该程序允许用户通过支付 SOL 来购买指定 token,并使用 PDA(Program Derived Address)来管理 token 的铸造和 SOL 的存储,同时实现了管理员提款和防止非管理员提款的功能。
一个 token sale program 是一个智能合约,它以固定价格出售特定的 token,通常以 SOL 等原生 token 作为交换。销售会一直持续到预定义的供应量售完,或者所有者采取行动结束销售。
我们的实现遵循以下流程:
我们将构建的 Solana 程序直接将 SPL token 铸造给买家,而不需要我们作为铸币机构签署每笔交易。这是标准方法——否则,管理员需要手动批准每次购买,这不切实际。
首先,使用 Anchor 创建一个新的 token_sale 程序,并将 programs/token_sale/src/lib.rs 中的样板代码替换为以下代码。
以下代码导入了我们的程序依赖项,并定义了一个 initialize 函数。该函数执行以下操作:
use anchor_lang::prelude::*;
use anchor_lang::system_program::{transfer, Transfer};
use anchor_spl::token::{mint_to, Mint, MintTo, Token, TokenAccount};
declare_id!("Gm8bFHtX3TapZDqA2tjviP1Qn1f8bLjTf8tbhFcgzcFs"); // 用你的程序 ID 替换此项或运行 `anchor sync`
// 每个 SOL 的 Token 数量,例如,1 SOL == 100 个我们的 token
const TOKENS_PER_SOL: u64 = 100;
// 最大供应量:1000 个 token(带 9 位小数)
const SUPPLY_CAP: u64 = 1000e9 as u64;
#[program]
pub mod token_sale {
    use super::*;
    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        // 设置管理员密钥
       ctx.accounts.admin_config.admin = ctx.accounts.admin.key();
        Ok(())
    }
}
在上面的代码中,我们为 token sale 程序定义了常量:TOKENS_PER_SOL = 100 和 SUPPLY_CAP = 1000(带 9 位小数)。
接下来,为我们的函数添加 Initialize 账户结构体。它包含以下账户:
admin:支付交易费用并担任程序管理员的帐户admin_config:这是一个程序拥有的帐户,用于存储管理员的公钥,这样稍后在提款期间,我们可以验证签名者是否是同一管理员(就像在 Solidity 中检查 msg.sender == admin,其中 admin 是一个存储管理员公钥的状态变量)。mint:一个自引用 mint PDA,它既是 token 的 mint,又是它自己的授权机构(我们稍后会解释这个概念)treasury:一个 PDA,用于保存从 token 购买中收集的 SOL#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(mut)]
    pub admin: Signer<'info>, // 交易签名者
    #[account(\
        init,\
        payer = admin,\
        space = 8+AdminConfig::INIT_SPACE, // 8 用于鉴别器\
    )]
    pub admin_config: Account<'info, AdminConfig>,
    #[account(\
        init,\
        payer = admin,\
        seeds = [b"token_mint"],\
        bump,\
        mint::decimals = 9,\
        mint::authority = mint.key(),\
    )]
    pub mint: Account<'info, Mint>,
    /// CHECK: treasury 的 PDA
    #[account(\
        seeds = [b"treasury"],\
        bump\
    )]
    pub treasury: AccountInfo<'info>,
    pub token_program: Program<'info, Token>,
    pub system_program: Program<'info, System>,
}
// 存储管理员公钥
#[account]
#[derive(InitSpace)] // 这是一个由 anchor 提供的 derive attribute macro,它计算账户所需的空间,并让我们访问 AdminConfig::INIT_SPACE,如上所示
pub struct AdminConfig {
    pub admin: Pubkey,
}
#[error_code]
pub enum Errors {
    #[msg("达到最大的 token 供应量限制")]
    SupplyLimit,
    #[msg("数学溢出")]
    Overflow,
    #[msg("只有管理员才能提款")]
    UnauthorizedAccess,
    #[msg("treasury 中没有足够的 SOL")]
    InsufficientFunds,
}
让我们分解 Initialize 账户结构体中的每个账户,并了解它们的目的:
admin config
admin_config:此账户持有管理员的公钥(由 AdminConfig 结构体定义),用于确保只有管理员可以从 treasury 中提取 SOL。


mint 账户
这是我们的 SPL token(正在出售的 token)的 mint 账户。我们将其创建为 PDA,以便程序稍后可以为其签名(我们将在本文后面解释这一点)。
该账户在链上不存在,直到我们调用 initialize。在该调用中,Anchor 将:
"token_mint" 和程序 ID 计算 mint PDA 地址mint::decimals = 9 创建账户(这是我们设置的,如下所示)mint::authority = mint.key())。这部分很重要,因为通过使 PDA 成为其自身的授权机构,只有我们的程序可以使用相同的种子和 bump 签署 mint_to 指令(同样,我们将在本文后面解释这是如何工作的)。treasury
此 PDA 专门用于保存用户在销售期间发送的 SOL(lamports)。

现在使用以下内容更新 programs/token_sale/Cargo.toml 文件。
[package]
name = "token_sale"
version = "0.1.0"
description = "Created with Anchor"
edition = "2021"
[lib]
crate-type = ["cdylib", "lib"]
name = "token_sale"
[features]
default = []
cpi = ["no-entrypoint"]
no-entrypoint = []
no-idl = []
no-log-ix-name = []
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] # 添加了 "anchor-spl/idl-build"
[dependencies]
anchor-lang = "0.31.0"
anchor-spl = "0.31.0" # 添加了此项
现在更新我们程序的测试。
此测试与我们在之前的教程中看到的非常相似。它只是使用所需的账户调用我们程序的 initialize 指令,并断言新创建的 mint 账户(token)的属性。
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import {
  createAssociatedTokenAccount,
  getAccount,
  getMint,
  TOKEN_PROGRAM_ID
} from "@solana/spl-token";
import * as web3 from "@solana/web3.js";
import { assert } from "chai";
import { TokenSale } from "../target/types/token_sale";
describe("token_sale", async () => {
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);
  const program = anchor.workspace.TokenSale as Program<TokenSale>;
  const connection = provider.connection;
  const adminKp = provider.wallet.payer;
  const buyer = adminKp; // 使用相同的密钥对作为管理员和买家进行测试
  const TOKENS_PER_SOL = 100;
  // 为 admin config 账户生成密钥对(将作为签名者传递以授权 adminConfig 账户创建)
  const adminConfigKp = web3.Keypair.generate();
  let mint: anchor.web3.PublicKey;
  let treasuryPda: anchor.web3.PublicKey;
  let buyerAta: anchor.web3.PublicKey;
    it("creates mint", async () => {
    [mint] = web3.PublicKey.findProgramAddressSync(
      [Buffer.from("token_mint")],
      program.programId
    );
    [treasuryPda] = web3.PublicKey.findProgramAddressSync(
      [Buffer.from("treasury")],
      program.programId
    );
    const tx = await program.methods
      .initialize()
      .accounts({
        admin: adminKp.publicKey,
        adminConfig: adminConfigKp.publicKey,
        mint: mint,
        treasury: treasuryPda,
        tokenProgram: TOKEN_PROGRAM_ID,
        systemProgram: anchor.web3.SystemProgram.programId,
      })
      .signers([adminKp, adminConfigKp])
      .rpc();
    console.log("initialize tx:", tx);
    const mintInfo = await getMint(connection, mint);
    assert.equal(mintInfo.mintAuthority.toBase58(), mint.toBase58());
    assert.equal(Number(mintInfo.supply), 0);
    assert.equal(mintInfo.decimals, 9);
  });
});
运行 npm install @solana/spl-token 以更新依赖项。
运行测试,它会通过。

我们已经设置了 Token Sale 程序。我们现在将添加一个函数来 mint 新的 token 单位进行销售,以便用户可以购买我们的 token。
该代码执行以下操作:
    pub fn mint(ctx: Context<MintTokens>, lamports: u64) -> Result<()> {
        // 计算要 mint 的 token 数量(lamports * TOKENS_PER_SOL)
        let amount = lamports
            .checked_mul(TOKENS_PER_SOL)
            .ok_or(Errors::Overflow)?; // 如果溢出,则返回错误
        // 确保我们不超过最大供应量
        let current_supply = ctx.accounts.mint.supply;
        let new_supply = current_supply.checked_add(amount).ok_or(Errors::Overflow)?; // 如果溢出,则返回错误
        require!(new_supply <= SUPPLY_CAP, Errors::SupplyLimit);
        // 将 SOL 发送到 treasury
        let transfer_instruction = Transfer {
            from: ctx.accounts.buyer.to_account_info(),
            to: ctx.accounts.treasury.to_account_info(),
        };
        let cpi_context = CpiContext::new(
            ctx.accounts.system_program.to_account_info(),
            transfer_instruction,
        );
        transfer(cpi_context, lamports)?;
                // 为 mint PDA 创建签名者种子
        let bump = ctx.bumps.mint;
        let signer_seeds: &[&[&[u8]]] = &[&[b"token mint".as_ref(), &[bump]]];
        // 使用 mint 作为其自身的授权机构来设置 mint 指令
        let mint_to_instruction = MintTo {
            mint: ctx.accounts.mint.to_account_info(),
            to: ctx.accounts.buyer_token_account.to_account_info(),
            authority: ctx.accounts.mint.to_account_info(),
        };
        // 使用 `new_with_signer` 创建 CPI 上下文 - 允许我们的 token sale 程序为 mint PDA 签名。这是因为 Solana 运行时验证了我们的程序使用这些种子和 bump 派生了 mint PDA
        // 有关更多信息,请参见此处:<https://github.com/solana-foundation/developer-content/blob/main/content/guides/getstarted/how-to-cpi-with-signer.md>
        let cpi_ctx = CpiContext::new_with_signer(
            ctx.accounts.token_program.to_account_info(),
            mint_to_instruction,
            signer_seeds,
        );
        mint_to(cpi_ctx, amount)?;
        Ok(())
    }
在上面的 mint() 函数中,你可以看到我们如何使用 CpiContext::new_with_signer 来 mint token。这是因为我们之前设置 mint 账户的方式。回想一下,在初始化期间,我们设置了 mint::authority = mint.key(),使 mint PDA 成为其自身的授权机构。
以下是此模式至关重要的原因。
**token mint...
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!