Solana代币托管程序实现
代币托管是一种机制,允许代币/DAO的所有者在满足某些预定义条件时向任何受益人分发或“托管”一定数量的代币。

一键发币: SUI | SOL | BNB | ETH | BASE | ARB | OP | POLYGON | AVAX | FTM | OK
在过去的一年里,我一直在学习Solidity/Ethereum开发,并且在这方面取得了一定的熟练程度(为了学习目的,我构建了一个DEX、在该DEX上构建了一个套利机器人等)。虽然我很喜欢Solidity,但我想拓宽我的视野已经有一段时间了,而寒假正是一个很好的机会。
过去几周,我一直在学习Solana的工作原理,并使用Anchor框架构建合约。尽管学习曲线很陡峭,但我最终还是构建了一个简单的代币托管合约,将在本文中解释这个合约。在这个项目开始时,我对如何编写一行Rust代码一无所知,但在项目结束时,我已经对Solana和Anchor有了很好的了解,并且掌握了用不同于Solidity的语言编写智能合约的经验。
为什么我要写这篇文章?
我写这篇文章有几个原因,但主要原因是提供更多关于Anchor程序的解释。在我尝试学习Solana的过程中遇到了一些问题,其中一个主要问题是关于Solana开发的资源并不多,除了简单的入门教程之外。我理解基础知识,但一旦超出这个范围,就变得非常模糊。这篇文章旨在提供一个更复杂的Solana程序的解释,同时保持一定的初学者友好性。
为什么我会构建这个项目?
Solana开发是非常专业的。当有人试图进入任何形式的链上开发时,通常首先想到的是学习Solidity——这绝对不是一个坏的选择,因为它可以应用于所有EVM兼容的链,有很多学习资源,而且相对简单易懂,这也是我开始的地方。我认为,成为一个有能力的Solana/Anchor开发者比成为一个有能力的Solidity开发者更有吸引力,因为Solidity开发者太多了。简而言之,当考虑Solana开发者时,供给少导致需求高,如果我能正确地定位自己在这个曲线的位置,我的技能将更加需要。
最后,我认为Solana非常酷,我真的很相信这项技术。能够以闪电般的速度处理交易,与其他链相比交易费用非常低,以及证明历史的创新性都是让我相信Solana是一个真正独特且我希望熟悉的项目的理由。
在接下来的文章中,我将深入代码并帮助你理解其概念上的工作原理。
1、理解基础
代币托管是一种机制,允许代币/DAO的所有者在满足某些预定义条件时向任何受益人分发或“托管”一定数量的代币。代币托管中常用的模式有很多种(基于时间的托管非常常见),但这个合约模拟了一种模式,即在链下达到某些里程碑后,所有者可以释放一定比例的代币给受益人。
在阅读本文之前,我假设你已经具备了Anchor和Solana开发的经验。你可以查看完整的源代码 这里,但我们将会逐个函数来理解合约的工作原理。
2、账户
在这个合约中,我们有两个账户:
#[derive(Default, Copy, Clone, AnchorSerialize, AnchorDeserialize)]
pub struct Beneficiary {
pub key: Pubkey,
pub allocated_tokens: u64,
pub claimed_tokens: u64,
}
#[account]
#[derive(Default)]
pub struct DataAccount {
pub percent_available: u8,
pub token_amount: u64,
pub initializer: Pubkey,
pub escrow_wallet: Pubkey,
pub token_mint: Pubkey,
pub beneficiaries: Vec<Beneficiary>,
pub decimals: u8
}
首先,我们有一个 DataAccount
,它存储了合约状态所需的值。在你的程序实例中只会有一个 DataAccount
,这意味着这些变量只有一个副本。
在 DataAccount
内部,我们有一个 Beneficiary
结构体的向量。基本上,合约创建者会为每个他们希望能够向其分配代币的账户创建一个 Beneficiary
结构体,并设置 allocated_tokens
字段(初始化时 claimed_tokens
将始终为0)。然后,他们会使用所有这些结构体以及所需的所有其他值初始化一个 DataAccount
,从而允许我们实现任意数量的受益人(直到达到PDA的最大大小10KB)。
需要注意的是,Beneficiary
实际上不是一个账户(因此没有 #[account]
宏在其结构体上方),而是作为一个数据结构对待,其中包含每个受益人所需的所有数据。为了让这一点与Anchor兼容,我们需要派生某些特征(如 Default
、Copy
等)。
3、函数
这个程序中有三个提供完整功能的函数:initialize
、release
和 claim
。我们将逐一介绍这些函数及其对应的上下文(即它们将接收的账户)和实际函数实现。
初始化 💻
这个函数负责设置托管的所有准备工作。要使用此函数,你应该使用下面所示的种子生成PDA。initialize
函数本质上是设置未来使用所需的所有变量,然后将发送者的ATA中的代币转移到合约的 escrow_wallet
PDA,在那里只有程序本身才能控制这些代币。
初始化账户
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(
init,
payer = sender,
space = 8 + 1 + 8 + 32 + 32 + 32 + 1 + (4 + 50 * (32 + 8 + 8) + 1), // Can take 50 accounts to vest to
seeds = [b"data_account", token_mint.key().as_ref()],
bump
)]
pub data_account: Account<'info, DataAccount>,
#[account(
init,
payer = sender,
seeds=[b"escrow_wallet".as_ref(), token_mint.key().as_ref()],
bump,
token::mint=token_mint,
token::authority=data_account,
)]
pub escrow_wallet: Account<'info, TokenAccount>,
#[account(
mut,
constraint=wallet_to_withdraw_from.owner == sender.key(),
constraint=wallet_to_withdraw_from.mint == token_mint.key()
)]
pub wallet_to_withdraw_from: Account<'info, TokenAccount>,
pub token_mint: Account<'info, Mint>,
#[account(mut)]
pub sender: Signer<'info>,
pub system_program: Program<'info, System>,
pub token_program: Program<'info, Token>,
}
initialize
函数接收了很多账户,但有几点需要注意。首先,data_account
和 escrow_wallet
不是普通账户,而是PDA——这是因为它们都包含只能由程序本身更改的值,而不是由程序部署者更改的值。
此外,我们可以看看 wallet_to_withdraw_from
账户,这是发送者的ATA。通过检查给它的约束条件,我们可以看到交易的发起者必须是ATA的所有者,以便让发起者将代币转移到托管钱包中,而 token_mint
账户的铸币地址应与我们发送的铸币地址相同。通过这一点,我们也可以讨论Solana程序的安全性并得出一些结论:
Solana程序比以太坊程序更安全有两个原因。一是知道Solana开发的人不多,因此不知道常见的攻击向量和如何破坏合约。第二个原因是Solana程序的编写方式提供了很少的输入空间。当调用一个函数时,每个账户都会被检查以确保其符合要求,如果有任何不正确的部分,指令会被撤销。这意味着Anchor和Solana做了大量的繁重工作,让你专注于编写重要的内容,相比之下,Solidity中到处都是大量的 require
语句。
初始化函数
pub fn initialize(ctx: Context<Initialize>, beneficiaries: Vec<Beneficiary>, amount: u64, decimals: u8) -> Result<()> {
let data_account = &mut ctx.accounts.data_account;
data_account.beneficiaries = beneficiaries;
data_account.percent_available = 0;
data_account.token_amount = amount;
data_account.decimals = decimals;
data_account.initializer = ctx.accounts.sender.to_account_info().key();
data_account.escrow_wallet = ctx.accounts.escrow_wallet.to_account_info().key();
data_account.token_mint = ctx.accounts.token_mint.to_account_info().key();
let transfer_instruction = Transfer {
from: ctx.accounts.wallet_to_withdraw_from.to_account_info(),
to: ctx.accounts.escrow_wallet.to_account_info(),
authority: ctx.accounts.sender.to_account_info(),
};
let cpi_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
transfer_instruction,
);
token::transfer(cpi_ctx, data_account.token_amount * u64::pow(10, decimals as u32))?;
Ok(())
}
这个函数的实际逻辑非常简单:我们只是正确地初始化 data_account
的值,然后从发送者的ATA (wallet_to_withdraw_from
) 转移 amount
代币到 escrow_wallet
。我们乘以 token_amount
的原因是在转移代币时,你需要发送完整的数值加上小数位。例如,这意味着在具有6个小数位的代币上发送100个代币实际上需要发送100000000个代币(100后面再加6个0)。
为了执行转移,我们将执行一个CPI到代币程序。CPI是一个跨程序调用,基本上是一个程序与另一个程序交互。在创建CPI的上下文时,你可以根据是否使用PDA选择使用 new
或 new_with_signer
。在我们的例子中,由于权威不是PDA,我们可以使用 new
来创建CPI上下文,然后调用 token
上的 transfer
函数,并传递我们想要发送的代币数量。
释放 💻
这个函数将一定比例的代币释放给所有受益人,以便他们可以在任何时候领取。本质上,我们只是改变了 DataAccount
中的 percent_available
变量。
释放账户
#[derive(Accounts)]
#[instruction(data_bump: u8)]
pub struct Release<'info> {
#[account(
mut,
seeds = [b"data_account", token_mint.key().as_ref()],
bump = data_bump,
constraint=data_account.initializer == sender.key()
)]
pub data_account: Account<'info, DataAccount>,
pub token_mint: Account<'info, Mint>,
#[account(mut)]
pub sender: Signer<'info>,
pub system_program: Program<'info, System>,
}
释放账户非常简单。首先值得注意的是,我们在函数中并没有实际使用 token_mint
,而是因为我们生成 data_account
PDA时使用了它的键作为种子,每次使用PDA时都需要重新计算地址。
其次,我们想看看 #[instruction()]
在结构体上方。当你调用 instruction
宏时,它会查找实际被调用的函数并从中获取参数值,以便在结构体中使用。在这种情况下,当我们调用 release
时,我们传递了 data_bump
以便我们可以获取 data_account
的PDA。
释放函数
pub fn release(ctx: Context<Release>, _data_bump: u8, percent: u8 ) -> Result<()> {
let data_account = &mut ctx.accounts.data_account;
data_account.percent_available = percent;
Ok(())
}
释放函数只做一件事:改变 data_account
中的 percent_available
值为所有者想要的值。我们可以看到我们传递了 _data_bump
变量,但在函数内部从未使用它。这就是我之前提到的结构体中的 instruction
宏。它对于函数来说不是必需的,但我们需要那个 bump 才能获取数据账户的PDA。
领取 💻
这个函数旨在由已分配代币的受益人调用,以便领取这些代币到他们的钱包中。从代码角度来看,这与 initialize
非常相似,但有一些关键的区别,其中一个主要区别是受益人调用此函数而不是所有者。
领取账户
#[derive(Accounts)]
#[instruction(data_bump: u8, wallet_bump: u8)]
pub struct Claim<'info> {
#[account(
mut,
seeds = [b"data_account", token_mint.key().as_ref()],
bump = data_bump,
)]
pub data_account: Account<'info, DataAccount>,
#[account(
mut,
seeds=[b"escrow_wallet".as_ref(), token_mint.key().as_ref()],
bump = wallet_bump,
)]
pub escrow_wallet: Account<'info, TokenAccount>,
#[account(mut)]
pub sender: Signer<'info>,
pub token_mint: Account<'info, Mint>,
#[account(
init_if_needed,
payer = sender,
associated_token::mint = token_mint,
associated_token::authority = sender,
)]
pub wallet_to_deposit_to: Account<'info, TokenAccount>,
pub associated_token_program: Program<'info, AssociatedToken>, // Don't actually use it in the instruction, but used for the wallet_to_deposit_to account
pub token_program: Program<'info, Token>,
pub system_program: Program<'info, System>,
}
这个结构体中没有什么是我们没有通过前面两个函数展示过的。值得一提的是,我们并没有在指令中使用 associated_token_program
账户,但它是为了能够在 wallet_to_deposit_to
ATA 上使用 associated_token
约束条件,而这个ATA将是调用函数的受益人的ATA。
领取函数
pub fn claim(ctx: Context<Claim>, data_bump: u8, _escrow_bump: u8) -> Result<()> {
let sender = &mut ctx.accounts.sender;
let escrow_wallet = &mut ctx.accounts.escrow_wallet;
let data_account = &mut ctx.accounts.data_account;
let beneficiaries = &data_account.beneficiaries;
let token_program = &mut ctx.accounts.token_program;
let token_mint_key = &mut ctx.accounts.token_mint.key();
let beneficiary_ata = &mut ctx.accounts.wallet_to_deposit_to;
let decimals = data_account.decimals;
let (index, beneficiary) = beneficiaries.iter().enumerate().find(|(_, beneficiary)| beneficiary.key == *sender.to_account_info().key)
.ok_or(VestingError::BeneficiaryNotFound)?;
let amount_to_transfer = ((data_account.percent_available as f32 / 100.0) * beneficiary.allocated_tokens as f32) as u64;
require!(amount_to_transfer > beneficiary.claimed_tokens, VestingError::ClaimNotAllowed); // Allowed to claim new tokens
// Transfer Logic:
let seeds = &["data_account".as_bytes(), token_mint_key.as_ref(), &[data_bump]];
let signer_seeds = &[&seeds[..]];
let transfer_instruction = Transfer{
from: escrow_wallet.to_account_info(),
to: beneficiary_ata.to_account_info(),
authority: data_account.to_account_info(),
};
let cpi_ctx = CpiContext::new_with_signer(
token_program.to_account_info(),
transfer_instruction,
signer_seeds
);
token::transfer(cpi_ctx, amount_to_transfer * u64::pow(10, decimals as u32))?;
data_account.beneficiaries[index].claimed_tokens = amount_to_transfer;
Ok(())
}
这个函数中最独特的概念是找到受益人的地址的方式——因为受益人存储在一个向量中,唯一的方法是循环遍历向量并检查每个键是否与发送者的键匹配。如果匹配,则继续执行,如果不匹配,则抛出 BeneficiaryNotFound
错误。这听起来效率很低,合理的解决方案是使用哈希表或类似的O(1)查找数据结构。不幸的是,Anchor只支持少数几种数据结构,而哈希表不在其中,所以我们被迫遍历所有受益人。
另外值得注意的是,我们在转账时使用了 new_with_signer
而不是 new
。这是因为权威是一个PDA,在这种情况下是我们的 data_account
PDA。因此,我们需要传递我们的种子和 bump 以便能够签署交易。
4、结束语
这个项目在过去几周帮助我学到了很多东西,我期待着在未来能够创建什么样的项目。
原文链接:Solana Simple Token Vesting Program Explained
DefiPlot翻译整理,转载请标明出处
免责声明:本站资源仅用于学习目的,也不应被视为投资建议,读者在采取任何行动之前应自行研究并对自己的决定承担全部责任。