Solana 教程 - 如何实现 Token 出售
本文介绍了如何使用 Anchor 框架在 Solana 上创建一个 Token Sale 程序。该程序允许用户通过支付 SOL 来购买指定 token,并使用 PDA(Program Derived Address)来管理 token 的铸造和 SOL 的存储,同时实现了管理员提款和防止非管理员提款的功能。
一个 token sale program 是一个智能合约,它以固定价格出售特定的 token,通常以 SOL 等原生 token 作为交换。销售会一直持续到预定义的供应量售完,或者所有者采取行动结束销售。
我们的实现遵循以下流程:
- 用户根据我们的汇率存入 SOL,例如,1 SOL 兑换 100 个 token。
- 程序将 SOL 存储在 treasury Program Derived Address (PDA) 中,这是一个程序控制的账户。
- 一旦收到 SOL,token 就会被铸造给用户。
- 销售会持续到达到预定义的供应上限。
- 管理员可以从 treasury 中提取已收集的 SOL。
创建 Token Sale 程序
我们将构建的 Solana 程序直接将 SPL token 铸造给买家,而不需要我们作为铸币机构签署每笔交易。这是标准方法——否则,管理员需要手动批准每次购买,这不切实际。
创建 token sale 所需的账户
首先,使用 Anchor 创建一个新的 token_sale 程序,并将 programs/token_sale/src/lib.rs 中的样板代码替换为以下代码。
以下代码导入了我们的程序依赖项,并定义了一个 initialize 函数。该函数执行以下操作:
- 设置管理员帐户以控制 treasury 提款
- 为我们正在销售的新 token 创建一个 mint 账户
- 创建一个 treasury 账户来收集 token 购买的 SOL
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- 最后,我们传递与我们交互的 Token Program 和 System Program。
#[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 struct 账户
让我们分解 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 的授权机构设置为自身(
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 以更新依赖项。
运行测试,它会通过。

用 1 SOL 购买 100 个 token
我们已经设置了 Token Sale 程序。我们现在将添加一个函数来 mint 新的 token 单位进行销售,以便用户可以购买我们的 token。
该代码执行以下操作:
- 根据 lamport 输入计算要 mint 的 token 数量。
- 检查我们是否超过了总供应量。
- 将 SOL 从买家转移到 treasury。
- 准备签名者种子,以便程序可以代表 mint PDA 进行签名(更多内容请参见下面的代码块之后)。
- 使用 mint 账户作为其自身的授权机构来设置 mint 指令。
- 使用签名者种子创建一个 CPI 上下文。
- 将 token mint 到买家的 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 设置为其自身的授权机构
在上面的 mint() 函数中,你可以看到我们如何使用 CpiContext::new_with_signer 来 mint token。这是因为我们之前设置 mint 账户的方式。回想一下,在初始化期间,我们设置了 mint::authority = mint.key(),使 mint PDA 成为其自身的授权机构。
以下是此模式至关重要的原因。
**token mint...