(六)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
方法从捐赠者的账户向众筹项目的账户转账指定金额。 - 更新余额:成功转账后,更新众筹项目的余额。
- CPI 上下文:创建一个跨程序调用(CPI)上下文,用于调用系统程序的
约束条件 #[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)]
派生宏,来指定执行该指令所需的账户及其约束条件。
这里留给大家几个问题,希望大家先思考一下,下一节我会为大家解答:
- 什么是跨程序调用(CPI)?
#[derive(Accounts)]
中的账户类型有哪些,各自的作用是什么?- Solana 账户中的密钥对账户和 PDA(程序派生地址)有什么区别?
- 所有者和授权者有何不同?
这些问题将帮助你更深入地理解 Solana 和 Anchor 框架的核心概念。希望你在思考这些问题的过程中能有所收获。
最后
如果您觉得这个教程系列对您有帮助,欢迎点赞、收藏并关注。感谢您的阅读!
另外,本节项目的源码可以在 GitHub 上找到。如果您有任何疑问或建议,也欢迎随时联系我。我会尽力提供帮助和支持。