Solana代币托管程序实现

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

Solana代币托管程序实现
一键发币: 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兼容,我们需要派生某些特征(如 DefaultCopy 等)。

3、函数

这个程序中有三个提供完整功能的函数:initializereleaseclaim。我们将逐一介绍这些函数及其对应的上下文(即它们将接收的账户)和实际函数实现。

初始化 💻

这个函数负责设置托管的所有准备工作。要使用此函数,你应该使用下面所示的种子生成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_accountescrow_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选择使用 newnew_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翻译整理,转载请标明出处

免责声明:本站资源仅用于学习目的,也不应被视为投资建议,读者在采取任何行动之前应自行研究并对自己的决定承担全部责任。
通过 NowPayments 打赏