Solana 入门: 实现一个简单的nft质押合约

2,185 阅读31分钟

Solana 介绍

Solana 是一个高性能的区块链平台,以其快速交易处理能力和低交易成本而闻名。它由 Solana Labs 于 2020 年推出,创始人是 Anatoly Yakovenko。Solana 的设计目标是解决区块链的可扩展性问题,提供去中心化的金融解决方案。

Solana使用的是Proof of History (PoH)与Proof of Stake (PoS)的结合,使得网络能够达到每秒数千笔交易处理速度。PoH 是 Solana 独有的创新性机制,用于记录和验证区块的时间戳和顺序。PoH 通过在每个区块中引入时间证明,使得节点能够迅速达成共识,而无需等待整个网络确认。而 Solana 的 PoS 机制用于选择验证者。验证者是通过抵押一定数量的代币来参与网络验证的。持有更多代币的验证者有更大的机会被选中生成新的区块和验证交易。因此,PoH 确保区块的时间戳和顺序,PoS 则确保网络的安全性和抗攻击性,这使得 Solana 成为一个适用于高性能去中心化应用和高频交易场景的区块链平台。

一些基础概念

账户

在以太坊中,我们将代码与数据、状态直接存储在智能合约中。而 Solana 账户最大的不同就是将两者分开存放在不同的账户上。所以 Solana 账户又分为程序账户和数据账户。(说明:Solana中的智能合约并不叫“智能合约”,而是“程序program”,尽管它们代表的是相似的概念。为了避免混淆,后续我们将统一使用“程序”这一术语。)

  • 程序账户(可执行账户):存储不可变的数据,主要用于存储程序的代码(BPF 字节码)。
  • 数据账户(不可执行账户):存储可变的数据,主要用于存储程序的状态。

Solana 链上程序是只读或无状态的,即程序的账户(可执行账户)只存储代码,不存储任何状态,程序会把状态存储在其他独立的账户(不可执行账户)中。如果一个程序账户是一个数据账户的所有者,那么它就可以改变数据账户中的状态。

PDA 账户

在Solana区块链中,PDA指的是“程序派生地址”(Program Derived Address)。这是一种特殊类型的地址,由 Solana 的程序生成,而不是由用户的私钥直接派生。PDA的主要目的是允许程序拥有和控制某些数据或资产,而不需要传统的私钥签名。

SPL 代币

在以太坊中,普通代币被一个叫ERC20的提案定了规范,可以认为普通代币合约统一叫做ERC20代币。而Solana世界里的ERC20代币则是SPL代币。所有的代币都受一个合约来管理,该合约代码在 github.com/solana-labs…

NFT

相较于以太坊上有专门为 NFT 设计的标准,如 ERC-721、ERC-1155,Solana NFT 的标准和发行依托于 Solana 的 SPL Token 标准,但在这个基础上进行了专门的设计,以适应 NFT 的独特性。也就是说,Solana 上的 NFT 跟其他普通的 Token 一样,都是通过 SPL Token 标准来实现的。 但它与普通 Token 的区别是:

  • 供应量:每个 NFT 的供应量设置为 1,确保了其唯一性。
  • 精度:NFT 的精度设置为0,因为它们是不可切割的。 精度指代币小数点后的位数,比如,代币精度为 2 的代币,最小单位为0.01。精度为 0 意味着最小单位为 1 ,没有小数部分,所以说是不可切割的。
  • 铸造权限:铸造权限被设置为 null ,也就意味着一旦铸造完成,就不能再创建新的同类 Token,确保供应量永不改变,保证其唯一性。

ATA 账户

类似 pda 的生成,ATA 是由钱包地址和代币的 Mint 地址组合生成的。主要用途是为每个钱包和每种SPL代币提供一个标准化的账户,用于存储代币余额。 file

Anchor 介绍

Anchor 是一个用于快速、安全的构建 Solana 程序的框架。它为您编写大量的样板代码,比如(反)序列化帐户和指令数据等,使您更专注于业务逻辑的开发。同时,它也会执行特定的安全检查、账号验证等,当然,也支持您轻松地实现自定义的其他检查。

Anchor 也为前端项目提供了一系列的库和工具,简化了跟链上程序交互的复杂度。它也对 PDA (程序衍生账户)、CPI(跨程序调用) 提供了一系列的支持。

总的来说,Anchor 使开发者能够更轻松地在 Solana 区块链上构建、部署和维护他们的去中心化应用。

Program 宏

该宏定义一个 Solana 程序模块,其中包含了程序的指令(instructions)和其他相关逻辑。它包含如下的功能:

  1. 理不同指令的函数:在程序模块中,开发者可以定义处理不同指令的函数。这些函数包含了具体的指令处理逻辑:
#[program]
mod anchor_counter {
   use super::*;
   // 初始化账户,并以传入的 instruction_data 作为计数器的初始值
   pub fn initialize(ctx: Context<InitializeAccounts>, instruction_data: u64) -> Result<()> {
       ctx.accounts.counter.count = instruction_data;
       Ok(())
   }
}
  • Context: Anchor 框架中定义的一个结构体,用于封装与 Solana 程序执行相关的上下文信息,包含了 instruction 指令元数据以及逻辑中所需要的所有账户信息
  1. Solana SDK 交互的功能:通过 #[program] 宏,Anchor 框架提供了一些功能,使得与 Solana SDK 进行交互变得更加简单。例如,可以直接使用 declare_id、Account、Context、Sysvar 等结构和宏,而不必手动编写底层的 Solana 交互代码,本单元第一节我们没有使用 Anchor 框架,所以需要手动迭代账户、判断账户权限等操作,现在 Anchor 已经替我们实现了这些功能。
  2. 生成 IDL(接口定义语言):#[program] 宏也用于自动生成程序的 IDL。IDL 是用于描述 Solana 程序接口的一种规范,它定义了合约的数据结构、指令等。Anchor 框架使用这些信息生成用于与客户端进行交互的 Rust 代码。

[derive(Accounts)] 宏

该宏应用于指令所要求的账户列表,实现了给定 struct 结构体数据的反序列化功能,因此在获取账户时不再需要手动迭代账户以及反序列化操作,并且实现了账户满足程序安全运行所需要的安全检查

#[derive(Accounts)]
pub struct InitializeAccounts<'info> {
   #[account(init, seeds = [b"my_seed", user.key.to_bytes().as_ref()], payer = user, space = 8 + 8)]
   pub pda_counter: Account<'info, Counter>,
   #[account(mut)]
   pub user: Signer<'info>,
   pub system_program: Program<'info, System>,
}

#[account]
pub struct Counter {
   pub my_data: u64,
}
  • Account:这里是我们自定义的合约账户
  • Signer类型:这个类型会检查给定的账户是否签署了交易,但并不做所有权的检查。只有在并不需要底层数据的情况下,才应该使用Signer类型
  • Program 类型:验证这个账户是个特定的程序。对于system_program 字段,Program 类型用于指定程序应该为系统程序,Anchor 会替我们完成校验

[account(..)] 宏

它是 Anchor 框架中的一个属性宏,提供了一种声明式的方式来指定账户的初始化、权限、空间(占用字节数)、是否可变等属性,从而简化了与 Solana 程序交互的代码。也可以看成是一种账户属性约束

#[account(
    init, 
    seeds = [b"my_seed"], 
    bump,
    payer = user, 
    space = 8 + 8
)]
pub pda_counter: Account<'info, Counter>,
pub user: Signer<'info>,
  • init:Anchor 会通过相关属性配置初始化一个派生帐户地址 PDA 。
  • seeds:种子(seeds)是一个任意长度的字节数组,通常包含了派生账户地址 PDA 所需的信息,在这个例子中我们仅使用了字符串my_seed作为种子。
  • payer:指定了支付账户,即进行账户初始化时,使用user这个账户支付交易费用。
  • space:指定账户的空间大小为16个字节,前 8 个字节存储 Anchor 自动添加的鉴别器,用于识别帐户类型。

小结

上面我们简单介绍了一下 Solana 的背景和它的框架 Anchor,整体来看 Anchor 通过 rust macro 封装了一系列基础功能和工具,使开发者能够更轻松地构建和部署智能合约。下面我们基于 Anchor 框架实现一个 NFT 质押系统。

实现一个质押合约

首先我们得了解一些和 defi 相关的概念。

什么是质押

在去中心化金融(DeFi)中,“质押”(staking)是一个非常重要的概念,它涉及将加密资产锁定在区块链网络中,以支持网络的安全性和运营,并从中获得奖励。质押通常与权益证明(Proof of Stake,PoS)或其变体相关。在这些共识机制中,网络节点通过质押一定数量的加密货币来参与区块的验证和生成。

这里有两种常见的质押概念:

  • 网络级别的质押(Staking for Network Security)  前面我们说到 Solana 用 POS 和 POH 实现快速高效交易。这里的 POS 就是网络级别的质押。用户将SOL代币委托给验证节点,以帮助维护网络安全和共识。委托者可以从验证节点的验证和出块奖励中获得部分收益。
  • 应用级别的质押(Application-Level Staking)  通常,用户可以质押特定的SPL代币(Solana上的代币标准),以获得某种形式的回报,如奖励代币、治理权利、或访问某些特定功能。

下面我们要完成的是应用级别的质押。以下我们进入开发实战。

开发逻辑

进入开发前先简单说一下我们的开发逻辑。我们现在需要创建一个质押系统。允许用户在我们的系统上参与质押并获取奖励。在 Solana 中,每一个操作对应一个指令,我们通过调用这些指令来完成我们想要实现功能。同时指令通过上下文对象的方式访问到所需要访问的账户。 如下是我们需要实现的指令:

  1. 初始化质押系统(InitializeStaking)

    角色:管理者

    依赖账户:

    • authority: 管理者的签名者账户,用于支付初始化的租金和费用。

    • reward_token_mint: 奖励代币的铸币账户,必须由质押实例的公钥控制。

    • staking_instance: 新创建的质押实例账户,存储质押系统的状态和参数。

    • allowed_collection_address: 允许质押的NFT集合地址。

    • system_program (系统账户): 用于系统相关操作,如创建账户。

    • rent (系统账户): 用于获取和支付账户租金。

    • time (系统账户): 提供当前的区块链时间。

    功能:初始化初始化质押实例,设置每秒奖励代币数量和允许质押的NFT集合。

  2. 用户初始化 (InitializeUser)

角色:质押用户

依赖账户:

  • authority: 用户的签名者账户,支付账户创建的租金。

  • user_instance: 新创建的用户质押账户,用于记录用户的质押和奖励信息。

  • staking_instance: 已初始化的质押实例账户,用于关联用户账户。

  • system_program (系统账户): 用于系统相关操作,如创建账户。

  • rent (系统账户): 用于获取和支付账户租金。

  • time (系统账户): 提供当前的区块链时间。

功能:初始化用户账户,使用户能够参与质押系统。用户账户记录质押的NFT数量和累积的奖励信息。

  1. 参与质押(EnterStaking)

角色:质押用户

依赖账户:

  • authority: 用户的签名者账户,用于签署质押交易。

  • reward_token_mint: 奖励代币的铸币账户,确保质押实例具有控制权以发放奖励。

  • nft_token_mint: 用户所质押的NFT的铸币账户。

  • nft_token_metadata: 质押NFT的元数据账户,用于验证NFT的合法性和来源。

  • nft_token_authority_wallet(ATA): 用户持有的NFT代币账户,质押时将NFT转移到系统账户。

  • nft_token_program_wallet(ATA): 系统接收并持有用户质押NFT的账户。

  • staking_instance (PDA): 存储质押系统状态的账户,由系统种子和管理员派生。

  • user_instance (PDA): 用户质押状态的账户,由系统种子和用户地址派生。

  • allowed_collection_address: 允许质押的NFT集合地址,确保质押的NFT属于被允许的集合。

  • token_program (系统账户): 用于处理代币转移的Solana代币程序账户。

  • nft_program_id (系统账户): NFT代币程序的账户,用于验证NFT相关操作。

  • system_program (系统账户): 用于系统相关操作,如账户创建和关闭。

  • rent (系统账户): 用于支付账户的租金。

  • time (系统账户): 提供当前的区块链时间。

功能:用户将他们的NFT质押到系统中,系统会验证NFT的合法性和授权情况。质押完成后,用户的NFT将被转移到系统账户,质押实例会记录相关信息

  1. 取消质押(CancelStaking)

角色:质押用户

依赖账户:

  • authority 用户的签名者账户,用于签署取消质押的交易。

  • reward_token_mint: 奖励代币的铸币账户,确保发放奖励的权限。

  • nft_token_mint: 用户质押的NFT的铸币账户。

  • nft_token_metadata: 质押NFT的元数据账户,用于验证NFT的合法性。

  • nft_token_authority_wallet(ATA): 用户持有的NFT代币账户。

  • nft_token_program_wallet(ATA): 系统接收并持有用户质押NFT的账户。

  • staking_instance (PDA): 存储质押系统状态的账户,由系统种子和管理员派生。

  • user_instance (PDA): 用户质押状态的账户,由系统种子和用户地址派生。

  • allowed_collection_address: 允许质押的NFT集合地址。

  • token_program (系统账户): 用于处理代币转移的Solana代币程序账户。

  • nft_program_id (系统账户): NFT代币程序的账户,用于验证NFT相关操作。

  • system_program (系统账户): 用于系统相关操作,如账户创建和关闭。

  • rent (系统账户): 用于支付账户的租金。

  • time (系统账户): 提供当前的区块链时间。

功能:用户取消质押他们的NFT,系统会从 nft_token_program_wallet 返还质押的NFT到用户的 nft_token_authority_wallet,并计算并发放到期的奖励

  1. 领取奖励(ClaimRewards)

角色:质押用户

依赖账户:

  • authority: 用户的签名者账户,用于签署领取奖励的交易。

  • reward_token_mint: 奖励代币的铸币账户,确保发放奖励的权限。

  • reward_token_authority_wallet(ATA): 用户接收奖励代币的账户。

  • staking_instance (PDA): 存储质押系统状态的账户,由系统种子和管理员派生。

  • user_instance (PDA): 用户质押状态的账户,由系统种子和用户地址派生。

  • token_program (系统账户): 用于处理代币转移的Solana代币程序账户。

  • system_program (系统账户): 用于系统相关操作。

  • rent (系统账户): 用于支付账户的租金。

  • time (系统账户): 提供当前的区块链时间。

功能:用户领取质押期间累积的奖励代币,系统从 reward_token_mint 账户中将奖励代币转移到用户的 reward_token_authority_wallet

上面的每个指令在初始化的时候都需要绑定一个 Context 对象,这个 Context 上下文也由我们来初始化,可以理解为为了完成我们的指令,我们需要自定义一个 Accounts 对象,通过这个对象能够访问到指令需要的账户。

环境准备

开发前我们需要先下载 Rust/Solana/Anchor/Yarn,安装教程:

www.anchor-lang.com/docs/instal… (这里不建议用 windows,太坑了...)

在 Anchor 框架中,有一个用来管理版本的工具叫 avm,类似 nodeJs 的 nvm,我们可以通过 avm 快速下载和切换 anchor-cli 的版本。

开始开发

前面铺垫了很多,现在开始进入实战。

1. 我们先使用 anchor-cli 初始化项目:

anchor new solana-staking

生成了一个 monorepor 项目,结构如下:

app/  -- 这里存放前端代码/Dapp
migrations/ -- 部署和初始化智能合约的脚本
programs/ 
   solana-staking -- 我们刚创建的合约
       src/
           lib.rs -- 一般为合约入口
target/ -- rust 打包后的产物
Anchor.toml -- anchor 依赖和配置
Cargo.lock 
Cargo.toml -- rust 依赖和配置

这里我们只需要在 src/lib.rs 中实现我们的逻辑即可。一般来说 src 中应该还有 /client 或者 /tests 来实现单元测试。

use anchor_lang::prelude::*;

declare_id!("5gpCMmPUU32CEDWMGrk3UBUYz4TMbH8sWb64fJguu2HZ");

#[program]
pub mod solana_staking {
   use super::*;

   pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
       Ok(())
   }
}

#[derive(Accounts)]
pub struct Initialize<'info> {}

新初始化的 src/lib.rs 大概长上面这个样子。其中 declare_id声明了合约的程序ID。pub mod solana_staking 则是我们的合约入口。 initialize为第一个初始化的指令。这个指令访问了一个自定义的账户,即 Initialize

2. 初始化指令

参照我们的业务逻辑,我们先初始化上述指令。

#[program]  // 声明程序模块
pub mod solana_staking {
   use super::*;
   // 初始化质押
   pub fn initialize_staking(
       ctx: Context<InitializeStaking>,  // 初始化质押上下文
       token_per_sec: u64,  // 每秒奖励的代币数量
   ) -> ProgramResult {
       Ok(())
   }

   // 初始化用户
   pub fn initialize_user(
       ctx: Context<InitializeUser>,  // 初始化用户上下文
   ) -> ProgramResult {
       Ok(())
   }

   // 进入质押
   pub fn enter_staking(
       ctx: Context<EnterStaking>,  // 进入质押上下文
   ) -> ProgramResult {
       Ok(())
   }

   // 取消质押
   pub fn cancel_staking(
       ctx: Context<CancelStaking>,  // 取消质押上下文
       staking_instance_bump: u8,  // 质押实例的 bump
   ) -> ProgramResult {
       Ok(())
   }

   // 领取奖励
   pub fn claim_rewards(
       ctx: Context<ClaimRewards>,  // 领取奖励上下文
       amount: u64,  // 领取的奖励数量
       staking_instance_bump: u8,  // 质押实例的 bump
   ) -> ProgramResult {
       Ok(())
   }
}

上面某些指令中传入了一个 bumpbump是用来避免地址生成时出现地址冲突的随机数。

3. 实现指令访问的 Context 结构体

上述逻辑中我们提到了两个 PDA 账户,StakingInstanceUser,我们先实现这两个结构体:

// 质押相关结构体
#[account]
#[derive(Copy, Default)]
pub struct StakingInstance {
   pub authority: Pubkey,  // 质押的授权公钥
   pub reward_token_per_sec: u64,  // 每秒奖励的代币数量
   pub reward_token_mint: Pubkey,  // 奖励代币的mint地址
   pub allowed_collection_address: Pubkey,  // 允许的NFT集合地址
   pub accumulated_reward_per_share: u64,  // 每份奖励的累积量
   pub last_reward_timestamp: u64,  // 上次奖励时间戳
   pub total_shares: u64,  // 总份额
}

#[account]
#[derive(Copy, Default)]
pub struct User {
   pub deposited_amount: u64,  // 用户存入的代币数量
   pub reward_debt: u64,  // 用户的奖励债务
   pub accumulated_reward: u64,  // 用户的累积奖励
}

然后依次实现指令所需的 Context 结构体,我们将这些结构体都放到 src/strctures 中:

  • InitializeStaking

src/strctures/initialize_staking.rs

use anchor_lang::prelude::*; 
use super::StakingInstance;  // 引入定义在同一模块中的StakingInstance结构体
use anchor_spl::token::Mint;  

// 定义InitializeStaking结构体,用于初始化质押
#[derive(Accounts)] 
#[instruction( 
   token_per_sec: u64,  // 每秒奖励的代币数量
   _staking_instance_bump: u8,  // 质押实例的 bump
)]

pub struct InitializeStaking<'info> {
   #[account(mut)] 
   pub authority: Signer<'info>,  
   #[account( 
       mut,  
       constraint = reward_token_mint  // 添加约束条件
           .mint_authority  // 约束mint_authority字段
           .unwrap() == staking_instance.key(),  // 确保 mint_authority与staking_instance的公钥相等
   )]
   pub reward_token_mint: Box<Account<'info, Mint>>,  // 声明reward_token_mint账户类型为Box<Account<'info, Mint>>
   #[account(  // 声明staking_instance账户的初始化及约束条件
       init,  // 声明staking_instance账户需要初始化
       seeds = [crate::STAKING_SEED.as_ref(), authority.key().as_ref()],  // 指定seeds参数,用于创建PDA(Program Derived Address)
       bump = _staking_instance_bump,  // 指定bump参数,用于创建PDA
       //space = 8 + core::mem::size_of::<StakingInstance>(),  // 为staking_instance账户分配空间(此行被注释掉)
       payer = authority,  // 声明authority账户为支付者
   )]
   pub staking_instance: Account<'info, StakingInstance>,  // 声明staking_instance账户类型为Account<'info, StakingInstance>
   pub allowed_collection_address: AccountInfo<'info>,  // 声明allowed_collection_address账户类型为AccountInfo<'info>
   pub system_program: Program<'info, System>,  // 声明system_program账户类型为Program<'info, System>
   pub rent: AccountInfo<'info>,  // 声明rent账户类型为AccountInfo<'info>
   pub time: Sysvar<'info, Clock>,  // 声明time账户类型为Sysvar<'info, Clock>,用于获取当前时间
}

  • InitializeUser

src/strctures/initialize_user.rs

use anchor_lang::prelude::*;  // 导入Anchor框架的预导入模块
use super::{  // 引入当前模块中定义的其他结构体
   StakingInstance,
   User,
};

#[derive(Accounts)]
#[instruction(
   _staking_instance_bump: u8,  // 质押实例的种子bump值,用于生成唯一的地址
   _staking_user_bump: u8,  // 用户实例的种子bump值,用于生成唯一的地址
)]
pub struct InitializeUser<'info> {
   #[account(mut)]
   pub authority: Signer<'info>,  // 签名者,通常是用户的身份
   #[account(
       init,  // 表示该账户是初始化的账户
       seeds = [  // 用于生成用户实例账户地址的种子
           crate::USER_SEED.as_ref(),  // 用户种子
           staking_instance.key().as_ref(),  // 质押实例的公钥
           authority.key().as_ref()  // 签名者的公钥
       ],
       bump = _staking_user_bump,  // 生成用户实例账户地址的bump值
       payer = authority,  // 为创建该账户支付费用的账户
   )]
   pub user_instance: Box<Account<'info, User>>,  // 用户实例账户,存储用户的质押信息
   #[account(
       mut,  // 表示该账户可能被修改
       seeds = [crate::STAKING_SEED.as_ref(), staking_instance.authority.as_ref()],  // 用于生成质押实例账户地址的种子
       bump = _staking_instance_bump,  // 生成质押实例账户地址的bump值
   )]
   pub staking_instance: Account<'info, StakingInstance>,  // 质押实例账户,存储质押相关的全局信息
   pub system_program: Program<'info, System>,  // Solana系统程序,用于创建账户等系统级操作
   pub rent: AccountInfo<'info>,  // 租金账户信息,用于账户的租金计算
   pub time: Sysvar<'info, Clock>,  // 时钟系统变量,用于获取当前时间
}

  • EnterStaking

src/strctures/enter_staking.rs

use anchor_lang::prelude::*; // 导入 Anchor 框架的预导入模块
use anchor_spl::token::TokenAccount; // 导入 TokenAccount 类型,用于表示 SPL 代币账户
use super::StakingInstance; // 引入 StakingInstance 结构体
use super::User; // 引入 User 结构体
use anchor_spl::token::Mint; // 导入 Mint 类型,用于表示 SPL 代币的铸造账户
use std::ops::Deref; // 导入 Deref trait,用于解引用

#[derive(Accounts)]
#[instruction(
   _staking_instance_bump: u8, // 质押实例的种子bump值
   _staking_user_bump: u8, // 用户实例的种子bump值
)]
pub struct EnterStaking<'info> {
   #[account(mut)]
   pub authority: Signer<'info>, // 签名者的账户信息,通常是用户,用于验证操作的合法性
   #[account(
       mut, // 表示该账户可能被修改
       constraint = reward_token_mint.mint_authority.unwrap().eq(&staking_instance.key())
       // 确保奖励代币的铸造权限属于质押实例
   )]
   pub reward_token_mint: Box<Account<'info, Mint>>, // 奖励代币的铸造账户,用于指定奖励代币的类型
   #[account(mut)]
   pub nft_token_mint: Box<Account<'info, Mint>>, // NFT代币的铸造账户,用于指定NFT代币的类型
   #[account(
       constraint = nft_token_metadata.owner == &nft_program_id.key()
       // 确保NFT元数据账户的所有者是NFT程序
   )]
   pub nft_token_metadata: AccountInfo<'info>, // NFT元数据账户,存储与NFT相关的元数据
   #[account(
       mut, // 表示该账户可能被修改
       constraint = nft_token_authority_wallet
        .clone().into_inner().deref().owner == authority.key(),
       // 确保NFT代币的持有者是操作的签名者
       constraint = nft_token_authority_wallet
       .clone().into_inner().deref().mint == nft_token_mint.key()
       // 确保钱包中的代币是指定的NFT代币
   )]
   pub nft_token_authority_wallet: Box<Account<'info, TokenAccount>>, // NFT代币持有者的钱包账户
   #[account(
       mut, // 表示该账户可能被修改
       constraint = nft_token_program_wallet
       .clone().into_inner().deref().owner == staking_instance.key(),
       // 确保NFT代币的程序钱包所有者是质押实例
       constraint = nft_token_program_wallet
       .clone().into_inner().deref().mint == nft_token_mint.key()
       // 确保程序钱包中的代币是指定的NFT代币
   )]
   pub nft_token_program_wallet: Box<Account<'info, TokenAccount>>, // 存储NFT代币的程序钱包
   #[account(
       mut, 
       seeds = [crate::STAKING_SEED.as_ref(),staking_instance.authority.as_ref()],
       bump = _staking_instance_bump, // 生成质押实例账户地址的bump值
   )]
   pub staking_instance: Account<'info, StakingInstance>, // 质押实例账户,包含质押相关的全局信息
   #[account(
       mut, 
       seeds = [
           crate::USER_SEED.as_ref(), // 用户种子
           staking_instance.key().as_ref(), // 质押实例的公钥
           authority.key().as_ref() // 签名者的公钥
       ],
       bump = _staking_user_bump, // 生成用户实例账户地址的bump值
   )]
   pub user_instance: Account<'info, User>, // 用户实例账户,存储用户的质押信息
   #[account(
       constraint = allowed_collection_address.key() 
           == staking_instance.allowed_collection_address,
       // 确保允许的NFT集合地址与质押实例中存储的地址一致
   )]
   pub allowed_collection_address: AccountInfo<'info>, // 允许的NFT集合的账户地址
   #[account(
       constraint = 
           token_program.key() == crate::TOKEN_PROGRAM_BYTES.parse::<Pubkey>().unwrap(),
       // 确保指定的程序是Token程序
   )]
   pub token_program: AccountInfo<'info>, // Token程序的账户信息
   #[account(
       constraint = 
           nft_program_id.key() == 
           crate::NFT_TOKEN_PROGRAM_BYTES.parse::<Pubkey>().unwrap(),
       // 确保指定的程序是NFT程序
   )]
   pub nft_program_id: AccountInfo<'info>, // NFT程序的账户信息
   pub system_program: Program<'info, System>, // Solana系统程序,用于系统级操作如账户创建
   pub rent: AccountInfo<'info>, // 租金账户信息,用于账户的租金计算
   pub time: Sysvar<'info,Clock>, // 时钟系统变量,用于获取当前时间
}

这里的 TokenAccount 就是我们所说的 ata 账户了,TokenAccount 存储了特定数量的某种 SPL 代币,这些代币属于该账户的所有者。Mint 描述了代币的属性,如小数位数(decimals),这决定了代币的最小单位。

  • CancelStaking

src/strctures/cancel_staking.rs

use anchor_lang::prelude::*; // 导入 Anchor 框架的预导入模块
use anchor_spl::token::TokenAccount; // 导入 TokenAccount 类型,用于表示 SPL 代币账户
use super::StakingInstance; // 引入 StakingInstance 结构体
use super::User; // 引入 User 结构体
use anchor_spl::token::Mint; // 导入 Mint 类型,用于表示 SPL 代币的铸造账户
use std::ops::Deref; // 导入 Deref trait,用于解引用

#[derive(Accounts)]
#[instruction(
   staking_instance_bump: u8, // 质押实例的种子bump值
   _staking_user_bump: u8, // 用户实例的种子bump值
)]
pub struct CancelStaking<'info> {
   #[account(mut)]
   pub authority: Signer<'info>, // 签名者的账户信息,通常是用户,用于验证操作的合法性
   #[account(
       mut, // 表示该账户可能被修改
       constraint = reward_token_mint.mint_authority.unwrap().eq(&staking_instance.key())
       // 确保奖励代币的铸造权限属于质押实例
   )]
   pub reward_token_mint: Box<Account<'info, Mint>>, // 奖励代币的铸造账户,用于指定奖励代币的类型
   #[account(mut)]
   pub nft_token_mint: Box<Account<'info, Mint>>, // NFT代币的铸造账户,用于指定NFT代币的类型
   #[account(
       constraint = nft_token_metadata.owner == &nft_program_id.key()
       // 确保NFT元数据账户的所有者是NFT程序
   )]
   pub nft_token_metadata: AccountInfo<'info>, // NFT元数据账户,存储与NFT相关的元数据
   #[account(
       mut, // 表示该账户可能被修改
       constraint = nft_token_authority_wallet
        .clone().into_inner().deref().owner == authority.key(),
       // 确保NFT代币的持有者是操作的签名者
       constraint = nft_token_authority_wallet
       .clone().into_inner().deref().mint == nft_token_mint.key()
       // 确保钱包中的代币是指定的NFT代币
   )]
   pub nft_token_authority_wallet: Box<Account<'info, TokenAccount>>, // NFT代币持有者的钱包账户
   #[account(
       mut, // 表示该账户可能被修改
       constraint = nft_token_program_wallet
       .clone().into_inner().deref().owner == staking_instance.key(),
       // 确保NFT代币的程序钱包所有者是质押实例
       constraint = nft_token_program_wallet
       .clone().into_inner().deref().mint == nft_token_mint.key()
       // 确保程序钱包中的代币是指定的NFT代币
   )]
   pub nft_token_program_wallet: Box<Account<'info, TokenAccount>>, // 存储NFT代币的程序钱包
   #[account(
       mut, 
       seeds = [crate::STAKING_SEED.as_ref(), staking_instance.authority.as_ref()],
       bump = staking_instance_bump, // 生成质押实例账户地址的bump值
   )]
   pub staking_instance: Account<'info, StakingInstance>, // 质押实例账户,包含质押相关的全局信息
   #[account(
       mut, 
       seeds = [
           crate::USER_SEED.as_ref(), // 用户种子
           staking_instance.key().as_ref(), // 质押实例的公钥
           authority.key().as_ref() // 签名者的公钥
       ],
       bump = _staking_user_bump, // 生成用户实例账户地址的bump值
   )]
   pub user_instance: Account<'info, User>, // 用户实例账户,存储用户的质押信息
   #[account(
       constraint = allowed_collection_address.key() 
           == staking_instance.allowed_collection_address,
       // 确保允许的NFT集合地址与质押实例中存储的地址一致
   )]
   pub allowed_collection_address: AccountInfo<'info>, // 允许的NFT集合的账户地址
   #[account(
       constraint = 
           token_program.key() == crate::TOKEN_PROGRAM_BYTES.parse::<Pubkey>().unwrap(),
       // 确保指定的程序是Token程序
   )]
   pub token_program: AccountInfo<'info>, // Token程序的账户信息
   #[account(
       constraint = 
           nft_program_id.key() == 
           crate::NFT_TOKEN_PROGRAM_BYTES.parse::<Pubkey>().unwrap(),
       // 确保指定的程序是NFT程序
   )]
   pub nft_program_id: AccountInfo<'info>, // NFT程序的账户信息
   pub system_program: Program<'info, System>, // Solana系统程序,用于系统级操作如账户创建
   pub rent: AccountInfo<'info>, // 租金账户信息,用于账户的租金计算
   pub time: Sysvar<'info, Clock>, // 时钟系统变量,用于获取当前时间
}

  • ClaimRewards

src/strctures/claim_rewards.rs

use anchor_lang::prelude::*; // 导入Anchor框架的预导入模块
use anchor_spl::token::TokenAccount; // 导入TokenAccount类型
use super::{ // 引入当前模块中定义的其他结构体
   StakingInstance,
   User,
};
use anchor_spl::token::Mint; // 导入Mint类型

#[derive(Accounts)]
#[instruction(
   amount: u64, // 领取的奖励数量
   staking_instance_bump: u8, // 质押实例的种子bump值,用于生成唯一的地址
   _staking_user_bump: u8, // 用户实例的种子bump值,用于生成唯一的地址
)]
pub struct ClaimRewards<'info> {
   #[account(signer)]
   pub authority: AccountInfo<'info>, // 签名者的账户信息,通常是用户的身份,用于确认操作的合法性
   #[account(
       mut, // 表示该账户可能被修改
       constraint = reward_token_mint.mint_authority.unwrap().eq(&staking_instance.key()) // 确保奖励代币的mint权限属于质押实例
   )]
   pub reward_token_mint: Box<Account<'info, Mint>>, // 奖励代币的Mint账户,用于指定奖励代币的类型
   #[account(
       mut, // 表示该账户可能被修改
       associated_token::mint = reward_token_mint, // 关联的Token Mint
       associated_token::authority = authority, // 关联的Token账户所有者
   )]
   pub reward_token_authority_wallet: Box<Account<'info, TokenAccount>>, // 奖励代币接收者的钱包账户
   #[account(
       mut, // 表示该账户可能被修改
       seeds = [crate::STAKING_SEED.as_ref(), staking_instance.authority.as_ref()], // 用于生成质押实例账户地址的种子
       bump = staking_instance_bump, // 生成质押实例账户地址的bump值
   )]
   pub staking_instance: Box<Account<'info, StakingInstance>>, // 质押实例账户,存储质押相关的全局信息
   #[account(
       mut, // 表示该账户可能被修改
       seeds = [
           crate::USER_SEED.as_ref(), // 用户种子
           staking_instance.key().as_ref(), // 质押实例的公钥
           authority.key().as_ref() // 签名者的公钥
       ],
       bump = _staking_user_bump, // 生成用户实例账户地址的bump值
   )]
   pub user_instance: Box<Account<'info, User>>, // 用户实例账户,存储用户的质押信息
   #[account(
       constraint = token_program.key() == crate::TOKEN_PROGRAM_BYTES.parse::<Pubkey>().unwrap(), // 确保指定的程序是Token程序
   )]
   pub token_program: AccountInfo<'info>, // Token程序的账户信息
   pub system_program: Program<'info, System>, // Solana系统程序,用于系统级操作如账户创建
   pub rent: AccountInfo<'info>, // 租金账户信息,用于账户的租金计算
   pub time: Sysvar<'info, Clock>, // 时钟系统变量,用于获取当前时间
}

上面完成了指令所需的 Context 结构体,接着我们来实现指令具体的逻辑:

4. 实现指令逻辑

  • initialize_staking 初始化质押
pub fn initialize_staking(
       ctx: Context<InitializeStaking>,  // 初始化质押上下文
       token_per_sec: u64,  // 每秒奖励的代币数量
   ) -> ProgramResult {
       let staking_instance = &mut ctx.accounts.staking_instance;  // 获取质押实例
       staking_instance.authority= ctx.accounts.authority.key().clone();  // 设置权限
       staking_instance.reward_token_per_sec = token_per_sec;  // 设置每秒奖励的代币数量
       staking_instance.last_reward_timestamp = ctx.accounts.time.unix_timestamp as u64;  // 设置最后奖励时间戳
       staking_instance.accumulated_reward_per_share = 0;  // 初始化每股累计奖励
       staking_instance.reward_token_mint = ctx
           .accounts
           .reward_token_mint
           .to_account_info()
           .key()
           .clone();  // 设置奖励代币的 mint
       staking_instance.allowed_collection_address = ctx
           .accounts
           .allowed_collection_address
           .key()
           .clone();  // 设置允许的NFT集合地址
       Ok(())
}

这里并没有什么好说的。有点类似构造函数,指令初始化了一个质押实例。作为初始化质押的用户,其公钥被存储到质押实例中,如果后续有需要可以以此来校验 PDA 账户权限。

  • initialize_user 初始化用户
   pub fn initialize_user(
       ctx: Context<InitializeUser>,  // 初始化用户上下文
   ) -> ProgramResult {
       let user_instance = &mut ctx.accounts.user_instance;  // 获取用户实例
       user_instance.deposited_amount = 0;  // 初始化存入数量
       user_instance.reward_debt = 0;  // 初始化奖励债务
       user_instance.accumulated_reward = 0;  // 初始化累计奖励
       Ok(())
   }

这里也是关联质押用户的 PDA 账户,并初始化了对应的数据。

  • enter_staking 用户参与质押

在此之前先实现质押的奖励逻辑。(逻辑函数,而非指令

每次用户参与质押时都需要计算当前节点已经产生的奖励,并更新用户累计奖励和占有份额。这里的总奖励我们简单根据时间累积来计算。

// 更新奖励池
fn update_reward_pool(
   current_timestamp: u64,  // 当前时间戳
   staking_instance: &mut StakingInstance,  // 质押实例
   #[allow(unused_variables)]  // 允许未使用变量
   user_instance: &mut User,  // 用户实例
) {
   // 计算从上次更新奖励到现在的时间内产生的奖励收入
   let income = staking_instance.reward_token_per_sec
       .checked_mul(current_timestamp
       .checked_sub(staking_instance.last_reward_timestamp)
       .unwrap())
       .unwrap();
   // 更新每股的累计奖励
   staking_instance.accumulated_reward_per_share = 
       staking_instance.accumulated_reward_per_share
       .checked_add(income.checked_mul(COMPUTATION_DECIMALS).unwrap()
       .checked_div(staking_instance.total_shares)
       .unwrap_or(0))
       .unwrap();
   staking_instance.last_reward_timestamp = current_timestamp;  // 更新最后奖励时间戳
}

// 存储待领取的奖励
fn store_pending_reward(
   staking_instance: &mut StakingInstance,  // 质押实例
   user_instance: &mut User,  // 用户实例
) {
   // 计算并更新用户累计奖励
   user_instance.accumulated_reward = user_instance.accumulated_reward
       .checked_add(user_instance.deposited_amount
       .checked_mul(staking_instance.accumulated_reward_per_share)
       .unwrap()
       .checked_div(COMPUTATION_DECIMALS)
       .unwrap()
       .checked_sub(user_instance.reward_debt)
       .unwrap())
       .unwrap();
}

// 更新用户奖励债务
fn update_reward_debt(
   staking_instance: &mut StakingInstance,  // 质押实例
   user_instance: &mut User,  // 用户实例
) {
   // 计算并更新用户奖励债务
   user_instance.reward_debt = user_instance.deposited_amount
       .checked_mul(staking_instance.accumulated_reward_per_share)
       .unwrap()
       .checked_div(COMPUTATION_DECIMALS)
       .unwrap();
}

完成参与质押指令:

// 进入质押
   pub fn enter_staking(
       ctx: Context<EnterStaking>,  // 进入质押上下文
   ) -> ProgramResult {
       let data = &mut ctx.accounts.nft_token_metadata.try_borrow_data()?;  // 获取NFT元数据
       let val = mpl_token_metadata::state::Metadata::deserialize(&mut &data[..])?;  // 反序列化元数据
       let collection_not_proper = val
           .data
           .creators
           .as_ref()
           .unwrap()
           .iter()
           .filter(|item|{
               ctx.accounts.allowed_collection_address.key() == 
                   item.address && item.verified
           })
           .count() == 0;  // 验证NFT集合
       if collection_not_proper || val.mint != ctx.accounts.nft_token_mint.key() {
           msg!("error");
           return Ok(());
       }
       let staking_instance = &mut ctx.accounts.staking_instance;  // 获取质押实例
       let user_instance = &mut ctx.accounts.user_instance;  // 获取用户实例
       let current_timestamp = ctx.accounts.time.unix_timestamp as u64;  // 获取当前时间戳
       
       // 更新奖励池
       update_reward_pool(
           current_timestamp,
           staking_instance,
           user_instance,
       );

       // 执行NFT转移
       let cpi_accounts = Transfer {
           to: ctx.accounts.nft_token_program_wallet.to_account_info(),
           from: ctx.accounts.nft_token_authority_wallet.to_account_info(),
           authority: ctx.accounts.authority.to_account_info(),
       };
       let cpi_program = ctx.accounts.token_program.clone();
       let context = CpiContext::new(cpi_program, cpi_accounts);
       token::transfer(context, 1)?;

       user_instance.deposited_amount = user_instance
           .deposited_amount
           .checked_add(1)
           .unwrap();  // 更新用户存入数量
       staking_instance.total_shares = staking_instance
           .total_shares
           .checked_add(1)
           .unwrap();  // 更新总份额
       update_reward_debt(
           staking_instance,
           user_instance,
       );
       Ok(())
   }

在参与质押指令中我们执行了 token::transfer,这里其实是调用了外部程序。在 Solana 中这种行为被称为CPI

CPI(Cross-Program Invocation,跨程序调用)

Solana 允许一个程序调用另一个程序的功能

模块化和复用: CPI 允许开发者将功能模块化,例如将通用功能(如代币转账)封装在单独的程序中。其他程序可以通过 CPI 调用这些功能,减少重复代码。

安全性: 在 Solana 中,CPI 调用是受控的,调用者必须显式声明被调用程序的地址和调用方式。这种设计增加了安全性,防止未经授权的访问和操作。

性能考虑: CPI 的设计考虑到了性能,Solana 的高吞吐量和低延迟使得 CPI 操作可以在区块链上高效执行。

  • cancel_staking 用户取消质押
pub fn cancel_staking(
       ctx: Context<CancelStaking>,  // 取消质押上下文
       staking_instance_bump: u8,  // 质押实例的 bump
       _staking_user_bump: u8,  // 用户实例的 bump
   ) -> ProgramResult {
       let data = &mut ctx.accounts.nft_token_metadata.try_borrow_data()?;  // 获取NFT元数据
       msg!("borrow");
       let val = mpl_token_metadata::state::Metadata::deserialize(&mut &data[..])?;  // 反序列化元数据
       msg!("deser");
       let collection_not_proper = val
           .data
           .creators
           .as_ref()
           .unwrap()
           .iter()
           .filter(|item|{
               ctx.accounts.allowed_collection_address.key() == 
                   item.address && item.verified
           })
           .count() == 0;  // 验证NFT集合
       msg!("count");
       if collection_not_proper || val.mint != ctx.accounts.nft_token_mint.key() {
           msg!("error");
           return Ok(());
       }

       let staking_instance = &mut ctx.accounts.staking_instance;  // 获取质押实例
       let user_instance = &mut ctx.accounts.user_instance;  // 获取用户实例
       let current_timestamp = ctx.accounts.time.unix_timestamp as u64;  // 获取当前时间戳
       msg!("get accounts");
       update_reward_pool(
           current_timestamp,
           staking_instance,
           user_instance,
       );
       msg!("upd pool");
       store_pending_reward(
           staking_instance,
           user_instance,
       );

       // 执行NFT转移
       let cpi_accounts = Transfer {
           to: ctx.accounts.nft_token_authority_wallet.to_account_info(),
           from: ctx.accounts.nft_token_program_wallet.to_account_info(),
           authority: staking_instance.clone().to_account_info(),
       };
       let cpi_program = ctx.accounts.token_program.clone();
       let context = CpiContext::new(cpi_program, cpi_accounts);
       let authority_seeds = &[
           &STAKING_SEED[..], 
           staking_instance.authority.as_ref(), 
           &[staking_instance_bump]
       ];
       token::transfer(context.with_signer(&[&authority_seeds[..]]), 1)?;

       user_instance.deposited_amount = user_instance
           .deposited_amount
           .checked_sub(1)
           .unwrap();  // 更新用户存入数量
       staking_instance.total_shares = staking_instance
           .total_shares
           .checked_sub(1)
           .unwrap();  // 更新总份额
       update_reward_debt(
           staking_instance,
           user_instance,
       );
       Ok(())
   }
  • claim_rewards 用户领取奖励

这里我们调用了 Mint 铸造的 CPI 程序,用来完成对用户的奖励。

pub fn claim_rewards(
       ctx: Context<ClaimRewards>,  // 领取奖励上下文
       amount: u64,  // 领取的奖励数量
       staking_instance_bump: u8,  // 质押实例的 bump
       _staking_user_bump: u8,  // 用户实例的 bump
   ) -> ProgramResult {
       let staking_instance = &mut ctx.accounts.staking_instance;  // 获取质押实例
       let user_instance = &mut ctx.accounts.user_instance;  // 获取用户实例
       let current_timestamp = ctx.accounts.time.unix_timestamp as u64;  // 获取当前时间戳
       update_reward_pool(
           current_timestamp,
           staking_instance,
           user_instance,
       );
       store_pending_reward(
           staking_instance,
           user_instance,
       );

       // 执行代币铸造
       let cpi_accounts = MintTo {
           mint: ctx.accounts.reward_token_mint.to_account_info(),
           to: ctx.accounts.reward_token_authority_wallet.to_account_info(),
           authority: staking_instance.to_account_info(),
       };
       let cpi_program = ctx.accounts.token_program.clone();
       let context = CpiContext::new(cpi_program, cpi_accounts);
       let authority_seeds = &[
           &STAKING_SEED[..], 
           staking_instance.authority.as_ref(), 
           &[staking_instance_bump]
       ];

       let amount = if amount == 0 {
           user_instance.accumulated_reward
       } else {
           amount
       };
       user_instance.accumulated_reward = user_instance
           .accumulated_reward
           .checked_sub(amount)
           .unwrap();

       token::mint_to(context.with_signer(&[&authority_seeds[..]]), amount)?;
       update_reward_debt(
           staking_instance,
           user_instance,
       );
       Ok(())
   }

部署/测试

Solana的网络环境分成开发网、测试网、主网三类,开发网为Solana节点开发使用,更新频繁,测试网主要 给到DApp开发者使用,相对稳定。主网则是正式环境,里面的是真金白银,部署需要消耗 gas 费,在测试环境中我们可以通过水龙头领取所需的 sol

// 设置开发环境
solana config set --url https://api.devnet.solana.com

// 创建密钥
solana-keygen new --force

// 领取空投
solana airdrop 5

通过 Anchor-cli 执行打包

// 执行打包
anchor build
// 部署
anchor deploy

anchor 打包后的 taget 产物中会包含 IDL 文件,IDL 是 json 格式的,可以用来和前端进行交互。目前 Solana 的工程化还是挺不错的。提供了一套 solana/web3Js 的 sdk 给我们进行联调开发。不过这里就不做过多描述。有时间再补充。

总结

Solana 基于 rust,通过 POS + POH 的方式实现了高性能和低 gas 调用。目前来说我个人比较看好 Solana 和 TON,不过基于之前的 rust 基础选择了 Solana。类似的平台还有 Move 之类的... 总体来看现在 web3 可以学习的东西还是挺多的,defi/gamefi... 感兴趣的同学可以一起交流 =)