本文介绍了如何使用 LiteSVM 在 Solana 上测试依赖于时间的程序,以 Dutch auction 为例,展示了如何创建拍卖程序,并使用 LiteSVM 模拟时间流逝,验证价格随时间线性下降的逻辑。LiteSVM 允许开发者在本地测试环境中控制区块链时钟,加速测试过程。
在 Solana 中,编写依赖于时间流逝的测试用例是很棘手的。 我们可能想测试在一天过去后我们的代码中会发生什么,但是我们不能让我们的测试用例花费一天的时间来运行,因为这会使我们的测试不切实际。 LiteSVM 通过让你立即将区块链时钟向前移动来解决这个问题,就像为本地测试进行时间旅行一样。
为了展示这在实践中是如何运作的,我们将使用 Anchor 为 NFT 构建一个基本的荷兰式拍卖。 荷兰式拍卖以一个高价开始,该价格会随着时间的推移自动降低,直到买家接受当前价格。 这是一个时间敏感行为的清晰示例,LiteSVM 使测试变得更加简单。
LiteSVM 的工作方式类似于 Solana 的本地验证器 (solana-test-validator
),但让我们能够更好地控制测试环境的本地区块链状态。 它可以用于我们的 TypeScript 测试中,并且可以轻松测试基于时间的逻辑,比如拍卖或 vesting。
如果你熟悉以太坊开发,LiteSVM 的时间操作能力类似于 Foundry 的 vm.warp
(用于推进区块时间戳),但针对 Solana 基于 slot 的架构进行了定制。
以下是本文将涵盖的内容概述:
现在让我们创建荷兰式拍卖程序。
如前文所述,荷兰式拍卖以一个高价开始,并且随着时间的推移而降低,直到买家接受。 为了保证交付,我们将 NFT 锁定在程序控制的 vault(escrow)中。 这可以防止卖家撤回或重复出售 NFT,并让买家避免在付款后依赖卖家来释放 NFT。 vault 允许程序在买家接受后立即结算交换。
荷兰式拍卖程序将仅包含两个函数:
initialize_auction
函数,用于创建所需的帐户,并将卖家的 NFT 存入由我们的程序拥有的 vault 帐户buy
函数,允许买家以当前拍卖价格使用 SOL 购买 NFT。接下来,我们将创建以下帐户:
Auction
:一个由我们的程序拥有的 PDA,用于存储拍卖详细信息,例如起始价格、拍卖持续时间等。Vault Authority
:这将是一个由我们的程序拥有的 PDA,用于授权在发生销售后将 NFT 转移给买家。 我们将在本文的“为什么我们有一个 vault authority PDA?”部分中详细介绍这一点。Vault
:一个关联的 token 帐户,用于保存存入的 NFT。 这由 Vault Authority
PDA 拥有。现在创建一个名为 dutch-auction
的新 Anchor 项目,并使用以下依赖项修改 programs/dutch-auction/Cargo.toml
文件:
anchor-spl
用于 SPL token 功能anchor-spl/idl-build
在 features
下,用于在生成的 IDL 文件中包含 SPL 类型[package]
name = "dutch-auction"
version = "0.1.0"
description = "Created with Anchor"
edition = "2021"
[lib]
crate-type = ["cdylib", "lib"]
name = "dutch_auction"
[features]
default = []
cpi = ["no-entrypoint"]
no-entrypoint = []
no-idl = []
no-log-ix-name = []
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]
[dependencies]
anchor-lang = "0.30.1"
anchor-spl = "0.30.1"
你可以运行 anchor build
来确认依赖项没有问题。
现在我们准备好依赖项了,用下面的代码替换 programs/dutch-auction/src/lib.rs
中的程序代码,其中包含 initialize_auction
函数,该函数执行以下操作:
use anchor_lang::prelude::*;
use anchor_lang::solana_program::{program::invoke, system_instruction};
use anchor_spl::associated_token::AssociatedToken;
use anchor_spl::token::{self, Mint, Token, TokenAccount, Transfer};
declare_id!("GKP6La354ejTfqNDW4gdzC2mzNjnP6cMY1vtH6EN15zq");
#[program]
pub mod dutch_auction {
use super::*;
pub fn initialize_auction(
ctx: Context<InitializeAuction>,
starting_price: u64,
floor_price: u64,
duration: i64, // 以秒为单位
) -> Result<()> {
// 初始化拍卖帐户并设置卖家详细信息
let auction = &mut ctx.accounts.auction;
auction.seller = ctx.accounts.seller.key();
auction.starting_price = starting_price;
auction.floor_price = floor_price;
auction.duration = duration;
auction.start_time = Clock::get()?.unix_timestamp;
auction.token_mint = ctx.accounts.mint.key();
// 将 1 个 token 从卖家 ATA 转移到 vault escrow
let cpi_accounts = Transfer {
from: ctx.accounts.seller_ata.to_account_info(),
to: ctx.accounts.vault.to_account_info(),
authority: ctx.accounts.seller.to_account_info(),
};
let cpi_ctx = CpiContext::new(ctx.accounts.token_program.to_account_info(), cpi_accounts);
token::transfer(cpi_ctx, 1)?;
Ok(())
}
}
运行 anchor keys sync
以将程序 ID 替换为你自己的 ID。
然后,将带有 Auction
状态的 InitializeAuction
帐户结构添加到程序代码中。
InitializeAuction
指定了拍卖初始化期间涉及的以下帐户:
auction
:用于存储拍卖状态(起始价格、持续时间、卖家信息等)的帐户。seller
:创建拍卖并签署交易的 NFT 所有者。seller_ata
:卖家的关联 token 帐户,其中包含要拍卖的 NFT。vault_auth
:一个 PDA(程序派生地址),用作 vault 帐户的授权。 这使我们的程序可以控制 NFT 转移。vault
(escrow):一个关联的 token 帐户,用于在拍卖期间保存卖家存入的 NFT。 这由 Vault Authority
PDA 拥有。mint
:NFT mint 帐户,表示正在拍卖的 token。#[derive(Accounts)]
pub struct InitializeAuction<'info> {
#[account(init, payer = seller, space = 8 + Auction::INIT_SPACE)]
pub auction: Account<'info, Auction>,
#[account(mut)]
pub seller: Signer<'info>,
#[account(\
mut,\
associated_token::mint = mint,\
associated_token::authority = seller\
)]
pub seller_ata: Account<'info, TokenAccount>,
/// CHECK: 这是将拥有 vault 的 PDA
#[account(\
seeds = [b"vault", auction.key().as_ref()],\
bump\
)]
pub vault_auth: UncheckedAccount<'info>,
#[account(\
init,\
payer = seller,\
associated_token::mint = mint,\
associated_token::authority = vault_auth\
)]
pub vault: Account<'info, TokenAccount>,
pub mint: Account<'info, Mint>,
pub token_program: Program<'info, Token>,
pub associated_token_program: Program<'info, AssociatedToken>,
pub system_program: Program<'info, System>,
}
#[account]
#[derive(InitSpace)]
pub struct Auction {
pub seller: Pubkey,
pub starting_price: u64,
pub floor_price: u64,
pub duration: i64,
pub start_time: i64,
pub token_mint: Pubkey,
pub sold: bool,
}
我们现在不测试此函数,我们将在整个程序到位后稍后进行测试。
现在让我们添加购买拍卖 token 的函数。
在我们的拍卖设置中,NFT 位于由程序控制的 escrow (vault) 帐户中,直到它被出售。 购买拍卖的 token 意味着将 lamports 从买方转移到卖方,以换取 NFT。
我们现在将添加一个函数,使我们能够以当前的荷兰式拍卖价格购买 NFT。 这是该函数的作用
AuctionNotStarted
错误;如果拍卖持续时间已过,则会显示 AuctionEnded
错误。 pub fn buy(ctx: Context<Buy>) -> Result<()> {
// 检查 NFT 是否已售出
require!(
ctx.accounts.auction.sold == false,
AuctionError::NFTAlreadySold
);
let auction = &mut ctx.accounts.auction;
let now = Clock::get()?.unix_timestamp; // 从时钟 sysvar 中获取当前时间
// 验证拍卖时间
require!(now >= auction.start_time, AuctionError::AuctionNotStarted);
require!(
now < auction.start_time + auction.duration,
AuctionError::AuctionEnded
);
// 根据经过的时间计算当前价格(线性衰减)
let elapsed_time = (now - auction.start_time).min(auction.duration) as u64;
let total_price_drop = auction.starting_price - auction.floor_price;
let price_dropped_so_far = total_price_drop * elapsed_time / auction.duration as u64;
let price = auction.starting_price - price_dropped_so_far;
// 验证资金并转移付款
require!(
ctx.accounts.buyer.lamports() >= price,
AuctionError::InsufficientFunds
);
invoke(
&system_instruction::transfer(
&ctx.accounts.buyer.key(),
&ctx.accounts.seller.key(),
price,
),
&[\
ctx.accounts.buyer.to_account_info(),\
ctx.accounts.seller.to_account_info(),\
ctx.accounts.system_program.to_account_info(),\
],
)?;
// 将 NFT 转移到买家
let auction_key = ctx.accounts.auction.key();
let vault_auth_bump = ctx.bumps.vault_auth;
let vault_signer_seeds = &[b"vault", auction_key.as_ref(), &[vault_auth_bump]]; // vault PDA 的签名者种子
token::transfer(
CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.vault.to_account_info(),
to: ctx.accounts.buyer_ata.to_account_info(),
authority: ctx.accounts.vault_auth.to_account_info(),
},
&[vault_signer_seeds],
),
1, // 转移 1 个 token(拍卖的 NFT)
)?;
Ok(())
}
我们使用 vault ATA 在卖家存入 NFT 后保存它。 我们需要一个 vault authority PDA,以便我们的程序可以为该 ATA 签名转移,而无需外部密钥对或签名者。
回想一下 Token Sale with Total Supply 文章,我们展示了如何使 mint PDA 成为其自身的授权,以便程序可以自行 mint 新 token。 在这里,我们对 vault ATA 使用相同的概念,但授予我们的程序移动现有 token 的权力。 我们从 ["vault", auction.key().as_ref()]
派生 vault_auth
,并将其设置为 ATA 的授权。
在 buy()
函数中,我们使用这些种子调用 CpiContext::new_with_signer
。 Solana 运行时看到我们的程序控制 vault_auth
并允许它为 vault ATA 签名。 这允许我们的程序自动将 NFT 转移给买家,而无需外部签名者。
现在添加 Buy
帐户结构。 Buy
结构指定了我们的拍卖程序中 NFT 购买期间涉及的帐户:
auction
:包含拍卖详细信息和状态的拍卖帐户。seller
:原始 NFT 卖家,将收到 SOL 付款。buyer
:以当前拍卖价格购买 NFT 的帐户,也是交易签名者。buyer_ata
:买家的关联 token 帐户,将收到购买的 NFT。vault_auth
:控制 vault 并授权将 NFT 转移给买家的 PDA 授权。vault
:保存 escrow NFT 的 vault 帐户,由 vault_auth
PDA 拥有。
#[derive(Accounts)]
pub struct Buy<'info> {
#[account(mut, has_one = seller)] // 确保我们传递正确的拍卖帐户
pub auction: Account<'info, Auction>, // 拍卖帐户
/// CHECK: 卖家帐户
#[account(mut)]
pub seller: AccountInfo<'info>, // 卖家帐户
#[account(mut)]
pub buyer: Signer<'info>, // 买家帐户
#[account(\
mut,\
associated_token::mint = auction.token_mint,\
associated_token::authority =...
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!