(六)Solana 入门指南——众筹智能合约篇(上)

20 阅读11分钟

(六)Solana 入门指南——众筹智能合约篇(上)

在这一部分,我们将学习如何使用 Anchor 框架创建一个基础的众筹应用。

1. 创建项目

首先,通过以下命令初始化一个新的项目:

anchor init crowd_found

接着,根据前一章节的结构指导,program/crowd_found 文件夹的结构应为:

.
├── Cargo.toml
├── Xargo.toml
└── src
    ├── error.rs
    ├── instructions
    │   ├── create.rs
    │   ├── delete.rs
    │   ├── donate.rs
    │   ├── mod.rs
    │   ├── update.rs
    │   └── withdraw.rs
    ├── lib.rs
    └── state
        ├── crowd_found.rs
        └── mod.rs

现在,让我们从定义 CrowdFound 账户开始,它将用于存储与众筹活动相关的数据。

crowd_found.rs 文件中添加以下代码:

use anchor_lang::prelude::*;

#[account]
#[derive(InitSpace)]
pub struct CrowdFound {
    /// 项目标题,最大长度为50个字符
    #[max_len(50)]
    pub title: String,
    /// 项目描述,最大长度为100个字符
    #[max_len(100)]
    pub description: String,
    /// 拥有者公钥,表示谁有权管理此众筹
    pub authority: Pubkey,
    /// 当前筹集的资金总额
    pub balance: u64,
}

这里,authority 字段记录了拥有对此次众筹进行操作权限的账户。

2. 创建众筹

create.rs 文件中添加以下代码,以实现创建众筹的功能:

use std::borrow::BorrowMut;

use anchor_lang::prelude::*;

use crate::state::CrowdFound;

#[derive(Accounts)]
#[instruction(title: String)]
pub struct CreateCrowdFound<'info> {
    // 发起交易的用户,必须是签名者
    #[account(mut)]
    pub signer: Signer<'info>,

    // Solana 系统程序,用于创建新账户
    pub system_program: Program<'info, System>,

    // 初始化众筹账户,由发起人支付创建费用
    // 使用种子 [signer.key().as_ref(), title.as_bytes()] 和 bump 参数生成 PDA (Program Derived Address)
    #[account(init, payer=signer, space=CrowdFound::INIT_SPACE + 8, seeds=[signer.key().as_ref(), title.as_bytes()], bump)]
    pub crowd_found: Account<'info, CrowdFound>,
}

impl<'info> CreateCrowdFound<'info> {
    /// 创建一个新的众筹实例
    pub fn create_crowd_found(&mut self, title: String, description: String) -> Result<()> {
        let crowd_found = self.crowd_found.borrow_mut();
        // 设置众筹的标题和描述
        crowd_found.title = title;
        crowd_found.description = description;
        // 设置发起人为众筹的管理者
        crowd_found.authority = self.signer.key();
        Ok(())
    }
}

注意

  • seeds 参数seeds 用于生成 PDA(Program Derived Address)。在这个例子中,我们使用了两个种子:
    • 第一个种子是 signer.key().as_ref(),即发起交易的用户的公钥。
    • 第二个种子是 title.as_bytes(),即众筹项目的标题。

通过这种组合方式,可以创建一个唯一的 PDA 地址,确保不同的用户可以拥有多个众筹项目。每个众筹项目的地址都是唯一的,即使不同的用户使用相同的标题,PDA 地址也会因为 signer 不同而不同。

  • #[instruction(title: String)]:这个属性宏用于访问指令的参数,这里访问title参数用于参与创建PDA

然后在lib.rs 中注册指令

use anchor_lang::prelude::*;

mod error;
mod instructions;
mod state;

use instructions::prelude::*;

declare_id!("FCFvpJohnR3Ha8xUq1Hy9MRN6bwhydtXazvAAFrPugGG");

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

    pub fn create(
        ctx: Context<CreateCrowdFound>,
        title: String,
        description: String,
    ) -> Result<()> {
        ctx.accounts.create_crowd_found(title, description)
    }
}

3. 更新众筹

添加错误类型

首先,在 error.rs 文件中定义一些可能遇到的错误类型,以便在处理逻辑中能够优雅地处理异常情况:

use anchor_lang::prelude::*;

#[error_code]
pub enum CrowdFoundError {
    #[msg("Invalid authority")]
    InvalidAuthority,
    #[msg("Invalid amount")]
    InvalidAmount,
    #[msg("Not enough balance")]
    NotEnoughBalance,
    #[msg("Can not donate")]
    CanNotDonate,
}

这些错误类型可以帮助我们在更新众筹功能时,更明确地指出哪些操作可能导致的问题。

更新众筹功能实现

接下来,在 update.rs 文件中添加以下代码,以实现更新众筹的功能:

use anchor_lang::prelude::*;

use crate::{error::CrowdFoundError, state::CrowdFound};

#[derive(Accounts)]
pub struct UpdateCrowdFound<'info> {
    /// 必须是众筹项目的管理者的签名者
    pub authority: Signer<'info>,

    /// 需要更新的众筹项目账户,且该账户的管理者必须是当前签名者
    #[account(mut, has_one = authority @ CrowdFoundError::InvalidAuthority)]
    pub crowd_found: Account<'info, CrowdFound>,
}

impl<'info> UpdateCrowdFound<'info> {
    /// 更新众筹项目的描述
    pub fn update_crowd_found(&mut self, description: String) -> Result<()> {
        self.crowd_found.description = description;
        Ok(())
    }
}

在这段代码中,#[account(mut, has_one = authority @ CrowdFoundError::InvalidAuthority)] 是一个非常重要的部分,它确保了只有众筹项目的创建者(即 authority)才能更新该项目的信息。具体来说:

  • mut:表明 crowd_found 账户是可变的,即可以在本次调用中修改其状态。
  • has_one = authority @ CrowdFoundError::InvalidAuthority:这是一个约束条件,要求 crowd_found 账户的 authority 字段必须与 UpdateCrowdFound 结构体中的 authority 字段相等。如果不相等,则会抛出 CrowdFoundError::InvalidAuthority 错误。这里的 @ 符号允许我们指定当验证失败时返回的具体错误类型。

这种方法的好处在于,它将权限检查的逻辑内嵌到了账户约束中,使得代码更加简洁、安全,并且易于维护。

替代方案

如果您希望更加显式地进行权限检查,也可以采用如下方式:

impl<'info> UpdateCrowdFound<'info> {
    pub fn update_crowd_found(&mut self, description: String) -> Result<()> {
        // 显式检查发起者是否为众筹项目的管理者
        require_eq!(self.crowd_found.authority, self.authority.key(), CrowdFoundError::InvalidAuthority);
        
        self.crow_found.description = description;
        Ok(())
    }
}

这种方式虽然稍微冗长一些,但它提供了更直接的权限检查逻辑,对于某些开发团队来说,可能更符合他们的编码习惯或项目需求。无论选择哪种方法,目的都是确保只有合法的用户才能对众筹项目进行更新操作。

4. 捐赠

为了实现捐赠功能,我们需要在 donate.rs 文件中添加以下代码:

use anchor_lang::{prelude::*, system_program};
use crate::{error::CrowdFoundError, state::CrowdFound};

#[derive(Accounts)]
#[instruction(amount: u64)]
pub struct Donate<'info> {
    /// 捐赠者,必须是签名者
    #[account(
        mut,
        constraint = singer.lamports() >= amount @ CrowdFoundError::NotEnoughBalance,
        constraint = crowd_found.authority != singer.key() @ CrowdFoundError::CanNotDonate
    )]
    pub singer: Signer<'info>,

    /// 接收捐赠的众筹项目账户
    #[account(mut)]
    pub crowd_found: Account<'info, CrowdFound>,

    /// Solana 系统程序,用于执行转账操作
    pub system_program: Program<'info, System>,
}

impl<'info> Donate<'info> {
    /// 执行捐赠操作
    pub fn donate(&mut self, amount: u64) -> Result<()> {
        // 创建 CPI 上下文
        let cpi_context = CpiContext::new(
            self.system_program.to_account_info(),
            system_program::Transfer {
                from: self.singer.to_account_info(),
                to: self.crowd_found.to_account_info(),
            },
        );

        // 执行转账操作
        system_program::transfer(cpi_context, amount)?;

        // 更新众筹项目的余额
        self.crowd_found.balance += amount;

        Ok(())
    }
}
  • singer: Signer<'info>:捐赠者,必须是签名者。这里使用了两个约束条件:

    • constraint = singer.lamports() >= amount @ CrowdFoundError::NotEnoughBalance:确保捐赠者有足够的余额进行转账,否则返回 NotEnoughBalance 错误。
    • constraint = crowd_found.authority != singer.key() @ CrowdFoundError::CanNotDonate:确保捐赠者不是众筹项目的管理者,否则返回 CanNotDonate 错误。
  • donate(&mut self, amount: u64) -> Result<()>:执行捐赠操作的方法。

    • CPI 上下文:创建一个跨程序调用(CPI)上下文,用于调用系统程序的 transfer 方法。
    • 转账操作:使用 system_program::transfer 方法从捐赠者的账户向众筹项目的账户转账指定金额。
    • 更新余额:成功转账后,更新众筹项目的余额。

约束条件 #[account(constraint = <expr> @ <custom_error>)]

singer 上面的属性中,使用了 constraint 字段来定义约束条件,这与更新功能中的 has_one 类似,都用于确保账户状态的正确性。当然,也可以用 require! 宏来替代这些约束条件。例如:

impl<'info> Donate<'info> {
    pub fn donate(&mut self, amount: u64) -> Result<()> {
        // 检查捐赠者是否有足够的余额
        require!(self.singer.lamports() >= amount, CrowdFoundError::NotEnoughBalance);

        // 检查捐赠者是否是众筹项目的管理者
        require!(self.crowd_found.authority != self.singer.key(), CrowdFoundError::CanNotDonate);

        // 创建 CPI 上下文
        let cpi_context = CpiContext::new(
            self.system_program.to_account_info(),
            system_program::Transfer {
                from: self.singer.to_account_info(),
                to: self.crowd_found.to_account_info(),
            },
        );

        // 执行转账操作
        system_program::transfer(cpi_context, amount)?;

        // 更新众筹项目的余额
        self.crowd_found.balance += amount;

        Ok(())
    }
}

通过上述代码,我们实现了捐赠功能,确保了捐赠者有足够的余额并且不是众筹项目的管理者。同时,通过跨程序调用(CPI)实现了从捐赠者账户到众筹项目账户的转账操作,并更新了众筹项目的余额。

思考

什么是跨程序调用?

5. 取款

为了实现取款功能,我们需要在 withdraw.rs 文件中添加以下代码:

use anchor_lang::prelude::*;

use crate::{error::CrowdFoundError, state::CrowdFound};

#[derive(Accounts)]
#[instruction(amount: u64)]
pub struct Withdraw<'info> {
    /// 众筹项目的管理者,必须是签名者
    #[account(mut)]
    pub authority: Signer<'info>,

    /// 众筹项目账户,必须是可变的
    #[account(
        mut,
        has_one = authority @ CrowdFoundError::InvalidAuthority,
        constraint = crowd_found.balance >= amount @ CrowdFoundError::NotEnoughBalance
    )]
    pub crowd_found: Account<'info, CrowdFound>,
}

impl<'info> Withdraw<'info> {
    /// 执行取款操作
    pub fn withdraw(&mut self, amount: u64) -> Result<()> {
        // 从众筹项目账户中扣除款项
        self.crowd_found.sub_lamports(amount)?;
        
        // 将款项转移到管理者的账户
        self.authority.add_lamports(amount)?;
        
        // 更新众筹项目的余额
        self.crowd_found.balance -= amount;

        Ok(())
    }
}
  • withdraw(&mut self, amount: u64) -> Result<()>:执行取款操作的方法。
    • 从众筹项目账户中扣除款项:使用 sub_lamports 方法从众筹项目账户中扣除指定金额。
    • 将款项转移到管理者的账户:使用 add_lamports 方法将扣除的款项转移到管理者的账户。
    • 更新众筹项目的余额:更新 crowd_found 账户的 balance 字段。

通过上述代码,我们实现了取款功能,确保了只有众筹项目的管理者才能执行取款操作,并且确保众筹项目的余额足够取款。这样,整个取款流程既安全又高效。

6.删除众筹

为了实现删除众筹功能,我们需要在 delete.rs 文件中添加以下代码:

use anchor_lang::prelude::*;

use crate::{error::CrowdFoundError, state::CrowdFound};

#[derive(Accounts)]
pub struct DeleteCrowdFound<'info> {
    /// 众筹项目的管理者,必须是签名者
    #[account(mut)]
    pub authority: Signer<'info>,

    /// 众筹项目账户,必须是可变的
    #[account(
        mut,
        close = authority,
        has_one = authority @ CrowdFoundError::InvalidAuthority,
    )]
    pub crowd_found: Account<'info, CrowdFound>,
}
  • authority: Signer<'info>:众筹项目的管理者,必须是签名者。这里使用了 has_one 约束条件:

    • has_one = authority @ CrowdFoundError::InvalidAuthority:确保 crowd_found 账户的 authority 字段与 authority 字段的键匹配,否则返回 InvalidAuthority 错误。
  • crowd_found: Account<'info, CrowdFound>:众筹项目账户,必须是可变的(mut),并且在删除时将其关闭并将剩余的 SOL 转移到管理者的账户。这里使用了 close 属性:

    • close = authority:关闭 crowd_found 账户,并将账户中的 SOL 转移到 authority 账户。

最后lib.rs的内容如下

lib.rs 文件中,我们需要注册所有指令,包括创建、更新、捐赠、取款和删除众筹的功能:

use anchor_lang::prelude::*;

mod error;
mod instructions;
mod state;

use instructions::prelude::*;

declare_id!("FCFvpJohnR3Ha8xUq1Hy9MRN6bwhydtXazvAAFrPugGG");

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

    /// 创建新的众筹项目
    pub fn create(
        ctx: Context<CreateCrowdFound>,
        title: String,
        description: String,
    ) -> Result<()> {
        ctx.accounts.create_crowd_found(title, description)
    }

    /// 更新众筹项目的描述
    pub fn update(ctx: Context<UpdateCrowdFound>, description: String) -> Result<()> {
        ctx.accounts.update_crowd_found(description)
    }

    /// 执行捐赠操作
    pub fn donate(ctx: Context<Donate>, amount: u64) -> Result<()> {
        ctx.accounts.donate(amount)
    }

    /// 执行取款操作
    pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
        ctx.accounts.withdraw(amount)
    }

    /// 删除众筹项目
    pub fn delete(ctx: Context<DeleteCrowdFound>) -> Result<()> {
        Ok(())
    }
}

7. 测试

为了测试我们的众筹项目合约,我们需要编写一些测试用例来验证各个功能的正确性。我们将使用 Anchor 和 Solana Web3.js 库来编写测试脚本。

1. 添加测试工具函数

首先,在 test/utils.ts 中添加以下两个方法,用于打印账户余额和请求空投 SOL:

import { getProvider } from "@coral-xyz/anchor";
import { LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js";

export async function printBalance(account: PublicKey) {
  const balance = await getProvider().connection.getBalance(account);
  console.log(`${account} has ${balance / LAMPORTS_PER_SOL} SOL`);
}

export async function airdropSOL(account: PublicKey, amount: number) {
  const connection = getProvider().connection;

  const tx = await connection.requestAirdrop(
    account,
    amount * LAMPORTS_PER_SOL
  );

  const lastBlockHash = await connection.getLatestBlockhash();

  await connection.confirmTransaction({
    blockhash: lastBlockHash.blockhash,
    lastValidBlockHeight: lastBlockHash.lastValidBlockHeight,
    signature: tx,
  });
}

2. 编写测试用例

接下来,在 test/crowd_found.ts 中添加以下内容,用于测试创建、更新、捐赠、取款和删除众筹项目的功能:

import * as anchor from "@coral-xyz/anchor";
import { getProvider, Program } from "@coral-xyz/anchor";
import { CrowdFound } from "../target/types/crowd_found";
import { Keypair, LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js";
import { airdropSOL, printBalance } from "./utils";
import { expect } from "chai";

describe("crowd_found", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.CrowdFound as Program<CrowdFound>;

  const account1 = new Keypair();
  const account2 = new Keypair();
  const account3 = new Keypair();

  const [account1CrowdFoundPda] = PublicKey.findProgramAddressSync(
    [account1.publicKey.toBuffer(), Buffer.from("crowdFound")],
    program.programId
  );

  it("airdrop", async () => {
    await airdropSOL(account1.publicKey, 10);
    await airdropSOL(account2.publicKey, 10);
    await airdropSOL(account3.publicKey, 10);

    await printBalance(account1.publicKey);
    await printBalance(account2.publicKey);
    await printBalance(account3.publicKey);
  });

  it("Create", async () => {
    await program.methods
      .create("crowdFound", "测试众筹")
      .accounts({
        authority: account1.publicKey,
      })
      .signers([account1])
      .rpc();

    const res = await program.account.crowdFound.fetch(account1CrowdFoundPda);
    console.log(`
        ${account1.publicKey} 创建了众筹项目,
        标题为: ${res.title}
        描述为:${res.description}
      `);

    expect(res.authority.toBase58()).eq(account1.publicKey.toBase58());
    expect(res.balance.toNumber()).eq(0);
  });

  it("update", async () => {
    await program.methods
      .update("new description")
      .accounts({
        crowdFound: account1CrowdFoundPda,
        authority: account1.publicKey,
      })
      .signers([account1])
      .rpc();

    const res = await program.account.crowdFound.fetch(account1CrowdFoundPda);
    console.log(`
        ${account1.publicKey} 更新了众筹项目,
        新的描述是:${res.description}
    `);

    expect(res.description).eq("new description");

    try {
      await program.methods
        .update("new description")
        .accounts({
          crowdFound: account1CrowdFoundPda,
          authority: account2.publicKey,
        })
        .signers([account2])
        .rpc();
      expect.fail("should fail");
    } catch (error) {
      expect(error.message).to.contain("Invalid authority");
    }
  });

  it("account2 donate", async () => {
    await program.methods
      .donate(new anchor.BN(1 * LAMPORTS_PER_SOL))
      .accounts({
        crowdFound: account1CrowdFoundPda,
        singer: account2.publicKey,
      })
      .signers([account2])
      .rpc();

    const res = await program.account.crowdFound.fetch(account1CrowdFoundPda);
    console.log(`
        ${account2.publicKey} 捐赠了 1 SOL 给 ${account1.publicKey} 的众筹项目,
        目前的余额是:${res.balance.toNumber() / LAMPORTS_PER_SOL} SOL
    `);

    expect(res.balance.toNumber()).eq(1 * LAMPORTS_PER_SOL);
  });

  it("account3 donate", async () => {
    await program.methods
      .donate(new anchor.BN(5 * LAMPORTS_PER_SOL))
      .accounts({
        crowdFound: account1CrowdFoundPda,
        singer: account3.publicKey,
      })
      .signers([account3])
      .rpc();

    const res = await program.account.crowdFound.fetch(account1CrowdFoundPda);
    console.log(`
        ${account3.publicKey} 捐赠了 5 SOL 给 ${account1.publicKey} 的众筹项目,
        目前的余额是:${res.balance.toNumber() / LAMPORTS_PER_SOL} SOL
    `);

    expect(res.balance.toNumber()).eq(6 * LAMPORTS_PER_SOL);
  });

  it("account1 donate", async () => {
    try {
      await program.methods
        .donate(new anchor.BN(5 * LAMPORTS_PER_SOL))
        .accounts({
          crowdFound: account1CrowdFoundPda,
          singer: account1.publicKey,
        })
        .signers([account1])
        .rpc();
      expect.fail("should fail");
    } catch (error) {
      expect(error.message).to.contain("Can not donate");
    }
  });

  it("account1 withdraw", async () => {
    await program.methods
      .withdraw(new anchor.BN(5 * LAMPORTS_PER_SOL))
      .accounts({
        crowdFound: account1CrowdFoundPda,
        authority: account1.publicKey,
      })
      .signers([account1])
      .rpc();

    const res = await program.account.crowdFound.fetch(account1CrowdFoundPda);
    console.log(`
        ${account1.publicKey} 从众筹项目${account1CrowdFoundPda} 提取了 5 SOL,
        目前的余额是:${res.balance.toNumber() / LAMPORTS_PER_SOL} SOL
    `);
    await printBalance(account1.publicKey);

    expect(res.balance.toNumber()).eq(1 * LAMPORTS_PER_SOL);
  });

  it("account1 delete", async () => {
    await program.methods
      .delete()
      .accounts({
        authority: account1.publicKey,
        crowdFound: account1CrowdFoundPda,
      })
      .signers([account1])
      .rpc();
    await printBalance(account1.publicKey);
    const balance = await getProvider().connection.getBalance(
      account1.publicKey
    );
    // 删除后,账户租金和众筹的资金都会转到账户中
    expect(balance).eq(16 * LAMPORTS_PER_SOL);
  });
});

运行测试

anchor test

这将编译并部署你的合约,然后运行所有的测试用例。如果一切正常,你应该看到所有测试用例都通过,并且输出相应的日志信息。

总结

通过实际操作,我们已经掌握了编写 Solana 程序的大部分内容。或许您已经感受到了 Solana 账户设计的精妙之处。每次调用指令时,我们都会使用 Anchor 框架的 #[derive(Accounts)] 派生宏,来指定执行该指令所需的账户及其约束条件。

这里留给大家几个问题,希望大家先思考一下,下一节我会为大家解答:

  1. 什么是跨程序调用(CPI)?
  2. #[derive(Accounts)] 中的账户类型有哪些,各自的作用是什么?
  3. Solana 账户中的密钥对账户和 PDA(程序派生地址)有什么区别?
  4. 所有者和授权者有何不同?

这些问题将帮助你更深入地理解 Solana 和 Anchor 框架的核心概念。希望你在思考这些问题的过程中能有所收获。

最后

如果您觉得这个教程系列对您有帮助,欢迎点赞、收藏并关注。感谢您的阅读!

另外,本节项目的源码可以在 GitHub 上找到。如果您有任何疑问或建议,也欢迎随时联系我。我会尽力提供帮助和支持。