(七)Solana 入门指南——众筹智能合约篇(下)
在上一节中,我们留下了一些问题,本节将通过实际操作的方式帮助大家找到答案,进一步加深对 Solana 及其 Anchor 框架核心概念的理解。
1. 跨程序调用(CPI)简介
跨程序调用(Cross Program Invocation,简称 CPI)是 Solana 区块链平台特有的一个功能,它允许一个智能合约调用另一个智能合约中的公共方法。这一机制使得不同的智能合约能够相互协作,共同完成更加复杂的任务。
例如,在上一节介绍的众筹合约中,捐赠功能就是利用了 CPI 来调用系统程序中的转账功能,具体代码如下:
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.donator.to_account_info(),
to: self.crowdfunding_project.to_account_info(),
},
);
// 进行转账操作
system_program::transfer(cpi_context, amount)?;
// 更新众筹项目余额
self.crowdfunding_project.balance += amount;
Ok(())
}
在这段代码中,最核心的部分在于构建 CpiContext 对象以及调用 system_program::transfer 函数来执行转账。
接下来,我们将编写一个示例,调用众筹合约的方法。
首先,在与 crowd_found 项目同一级别创建新项目 test_cpi:
anchor init test_cpi
接着,在 test_cpi/Cargo.toml 文件中添加依赖项,注意指定 cpi 功能:
#...
[dependencies]
anchor-lang = "0.30.1"
# 添加依赖
crowd_found = { path = "../../../crowd_found/programs/crowd_found", features = [
"cpi",
] }
随后,在 test_cpi/src/lib.rs 文件中加入以下代码:
use anchor_lang::prelude::*;
use crowd_found::program::CrowdFound;
declare_id!("DkiYzwzUoLJ8bPjA525CntLsZCyv6Xz3hqHTVaLCJX81");
#[program]
pub mod test_cpi {
use super::*;
pub fn call_crowd_found_withdraw(ctx: Context<CrowdFoundWithdraw>, amount: u64) -> Result<()> {
// 创建cpi context
let cpi_context = CpiContext::new(
ctx.accounts.crowd_found_program.to_account_info(),
crowd_found::cpi::accounts::Withdraw {
authority: ctx.accounts.authority.to_account_info(),
crowd_found: ctx.accounts.crowd_found.to_account_info(),
},
);
// 调用cpi
crowd_found::cpi::withdraw(cpi_context, amount)
}
}
#[derive(Accounts)]
pub struct CrowdFoundWithdraw<'info> {
#[account(mut)]
pub authority: Signer<'info>,
#[account(mut)]
pub crowd_found: Account<'info, crowd_found::state::CrowdFound>,
pub crowd_found_program: Program<'info, CrowdFound>,
}
如果想更简洁一点可以把构造cpi_context的逻辑抽离出来,如下所示。
use anchor_lang::prelude::*;
use crowd_found::program::CrowdFound;
declare_id!("DkiYzwzUoLJ8bPjA525CntLsZCyv6Xz3hqHTVaLCJX81");
#[program]
pub mod test_cpi {
use super::*;
pub fn call_crowd_found_withdraw(ctx: Context<CrowdFoundWithdraw>, amount: u64) -> Result<()> {
crowd_found::cpi::withdraw(ctx.accounts.withdraw_ctx(), amount)
}
}
#[derive(Accounts)]
pub struct CrowdFoundWithdraw<'info> {
#[account(mut)]
pub authority: Signer<'info>,
#[account(mut)]
pub crowd_found: Account<'info, crowd_found::state::CrowdFound>,
pub crowd_found_program: Program<'info, CrowdFound>,
}
impl<'info> CrowdFoundWithdraw<'info> {
pub fn withdraw_ctx(
&self,
) -> CpiContext<'_, '_, '_, 'info, crowd_found::cpi::accounts::Withdraw<'info>> {
CpiContext::new(
self.crowd_found_program.to_account_info(),
crowd_found::cpi::accounts::Withdraw {
authority: self.authority.to_account_info(),
crowd_found: self.crowd_found.to_account_info(),
},
)
}
}
最后,在 test_cpi.ts 文件中添加测试代码。这部分测试基于上一节的内容,不同之处在于取款操作中使用了 test_cpi 中的 call_crowd_found_withdraw 指令。
import * as anchor from "@coral-xyz/anchor";
import { getProvider, Program } from "@coral-xyz/anchor";
// 导入CrowdFound的类型
import { CrowdFound } from "../../crowd_found/target/types/crowd_found";
// 导入CrowdFound的IDL
import crowd_found_idl from "../../crowd_found/target/idl/crowd_found.json";
import { Keypair, LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js";
import { airdropSOL, printBalance } from "./utils";
import { expect } from "chai";
import { TestCpi } from "../target/types/test_cpi";
describe("crowd_found", () => {
// Configure the client to use the local cluster.
anchor.setProvider(anchor.AnchorProvider.env());
const program = new anchor.Program(
crowd_found_idl as any
) as anchor.Program<CrowdFound>;
const testCpiProgram = anchor.workspace.TestCpi as Program<TestCpi>;
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 () => {
// Add your test here.
await program.methods
.create("crowdFound", "测试众筹")
.accounts({
singer: 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")
.accountsPartial({
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")
.accountsPartial({
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))
.accountsPartial({
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))
.accountsPartial({
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))
.accountsPartial({
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 testCpiProgram.methods
.callCrowdFoundWithdraw(new anchor.BN(5 * LAMPORTS_PER_SOL))
.accountsPartial({
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 withdraw two", async () => {
await testCpiProgram.methods
.callCrowdFoundWithdraw(new anchor.BN(5 * LAMPORTS_PER_SOL))
.accountsPartial({
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("account2 withdraw", async () => {
await testCpiProgram.methods
.callCrowdFoundWithdraw(new anchor.BN(5 * LAMPORTS_PER_SOL))
.accountsPartial({
crowdFound: account1CrowdFoundPda,
authority: account2.publicKey,
})
.signers([account2])
.rpc();
const res = await program.account.crowdFound.fetch(account1CrowdFoundPda);
console.log(`
${account2.publicKey} 从众筹项目${account1CrowdFoundPda} 提取了 5 SOL,
目前的余额是:${res.balance.toNumber() / LAMPORTS_PER_SOL} SOL
`);
await printBalance(account2.publicKey);
expect(res.balance.toNumber()).eq(1 * LAMPORTS_PER_SOL);
});
it("account1 delete", async () => {
await program.methods
.delete()
.accountsPartial({
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);
});
});
运行测试
启动本地验证器
solana-test-validator
部署crowd_found程序
# 进入到crowd_found目录
anchor deploy
运行test_cpi测试
# 进入到test_cpi目录
anchor test --skip-local-validator
通过这些步骤,您可以验证通过 CPI 调用
crowd_found 合约中的 withdraw 方法是否成功,并且可以看到通过 CPI 调用会自动进行账户验证,无需在 call_crowd_found_withdraw 中重复添加权限检查。当然,您也可以根据需要添加额外的限制条件。
上述内容展示了如何调用另一个项目的程序。如果您希望在同一个项目中多个程序之间进行调用,可以参考文档 Cross Program Invocations。
2. #[derive(Accounts)] 中的账户类型有哪些,各自的作用是什么?
在Solana的Anchor框架中,#[derive(Accounts)] 是一个宏,用于创建一个结构体,该结构体包含函数执行期间将要访问的所有账户的引用。不同的账户类型有不同的用途和特点,以下是几种常见的账户类型及其作用:
1. Account
- 作用:
Account类型会检查账户的所有者是否确实是程序本身。如果所有者不匹配,那么账户将无法加载。这是一个重要的安全措施,以防止意外读取程序未创建的数据。 - 示例:
#[derive(Accounts)] pub struct Foo<'info> { some_account: Account<'info, SomeAccount>, }
2. UncheckedAccount(或AccountInfo)
- 作用:
UncheckedAccount是AccountInfo的别名,不检查所有权,因此必须小心使用,因为它会接受任意账户。这种类型通常用于需要传递任意地址的情况,但要非常小心地处理数据,因为黑客可能在账户中放置恶意数据。 - 示例:
#[derive(Accounts)] pub struct Foo<'info> { /// CHECK: we are just printing the data some_account: AccountInfo<'info>, }
3. Signer
- 作用:
Signer类型验证账户是否签署了交易,即检查签名是否与账户的公钥匹配。虽然签名者也是一个账户,可以读取签名者的余额或存储在账户中的数据(如果有),但其主要目的是验证签名。根据文档,如果使用了Signer,则不应尝试访问底层账户数据。 - 示例:
#[derive(Accounts)] pub struct Hello<'info> { pub signer: Signer<'info>, }
4. Program
-
作用:
Program类型表示这是一个可执行的账户,即程序,并且可以向其发出跨程序调用(CPI)。我们通常使用的系统程序就是一个例子。 -
示例:
#[derive(Accounts)] pub struct SendSol<'info> { #[account(mut)] from: Signer<'info>, #[account(mut)] to: AccountInfo<'info>, system_program: Program<'info, System>, }
3. Solana 账户中的密钥对账户和 PDA(程序派生地址)有什么区别?
在Solana网络中,密钥对账户和PDA(程序派生地址)代表了创建账户的两种不同方法:
- 密钥对账户:这类账户首先在程序外部创建,随后在程序内部进行初始化。每个密钥对账户都有一个私钥,但值得注意的是,一旦账户被程序初始化,即便拥有私钥的一方也不能直接从该账户中转移资金,因为账户的所有权已变更为程序本身。这种特性确保了即使私钥泄露,账户内的资产仍然安全。
- PDA(程序派生地址):PDA是通过程序地址和特定的种子字符串计算得出的一个地址。创建PDA的过程中并不涉及私钥,这意味着PDA可以由程序自动管理,而无需担心私钥的安全问题。PDA通常用于智能合约中,以存储数据或执行特定的逻辑操作。
下面是一个简单的示例,展示了如何在Rust中定义和初始化这两种类型的账户:
use anchor_lang::prelude::*;
declare_id!("2FRrUXHWvWZFzHCrgTjT5wiG3gRQEC8kP5u56DPvfPQe");
#[program]
pub mod keypair_vs_pda {
use super::*;
pub fn initialize_pda(ctx: Context<InitializePda>, name: String) -> Result<()> {
msg!("Greetings from: {:?}", ctx.program_id);
ctx.accounts.pda.name = name;
Ok(())
}
pub fn initialize_key_pair(ctx: Context<InitializeKeyPair>, name: String) -> Result<()> {
msg!("Greetings from: {:?}", ctx.program_id);
ctx.accounts.key_pair.name = name;
Ok(())
}
}
#[derive(Accounts)]
pub struct InitializePda<'info> {
#[account(init,payer=singer,space=8+MyAccountData::INIT_SPACE,seeds=[],bump)]
pub pda: Account<'info, MyAccountData>,
#[account(mut)]
pub singer: Signer<'info>,
system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct InitializeKeyPair<'info> {
// 使用key_pair初始化账户,不需要指点定seeds,和bump
#[account(init,payer=singer,space=8+MyAccountData::INIT_SPACE)]
pub key_pair: Account<'info, MyAccountData>,
#[account(mut)]
pub singer: Signer<'info>,
system_program: Program<'info, System>,
}
#[account]
#[derive(InitSpace)]
pub struct MyAccountData {
#[max_len(50)]
pub name: String,
}
utils.ts
import { getProvider } from "@coral-xyz/anchor";
import {
Keypair,
LAMPORTS_PER_SOL,
PublicKey,
SystemProgram,
Transaction,
} 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,
});
}
export async function sendSol(from: Keypair, to: Keypair, amount: number) {
const tx = new Transaction().add(
SystemProgram.transfer({
fromPubkey: from.publicKey,
toPubkey: to.publicKey,
lamports: amount * LAMPORTS_PER_SOL,
})
);
await getProvider().sendAndConfirm(tx, [from]);
console.log(`Sent ${amount} SOL from ${from.publicKey} to ${to.publicKey}`);
}
测试内容
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Keypair, PublicKey } from "@solana/web3.js";
import { KeypairVsPda } from "../target/types/keypair_vs_pda";
import { airdropSOL, printBalance, sendSol } from "./utils";
import { expect } from "chai";
describe("keypair_vs_pda", () => {
// Configure the client to use the local cluster.
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.KeypairVsPda as Program<KeypairVsPda>;
const account1 = new Keypair();
const account2 = new Keypair();
const account3 = new Keypair();
const wallet = provider.wallet as anchor.Wallet;
const [padAccount] = PublicKey.findProgramAddressSync([], program.programId);
it("airdrop", async () => {
await airdropSOL(account1.publicKey, 10);
await airdropSOL(account2.publicKey, 10);
await printBalance(account1.publicKey);
await printBalance(account2.publicKey);
});
it("initialize pda", async () => {
console.log(
`
account1 的 Owner 为:`,
(
await anchor.getProvider().connection.getAccountInfo(account1.publicKey)
).owner.toString()
);
console.log(
`
padAccount 的 Owner 为:`,
await anchor.getProvider().connection.getAccountInfo(padAccount)
);
await program.methods
.initializePda("hello pad")
.accounts({
singer: account1.publicKey,
})
.signers([account1])
.rpc();
const res = await program.account.myAccountData.fetch(padAccount);
console.log(`
${account1.publicKey} 初始化了 PDA,
内容为: ${res.name}
`);
await sendSol(account1, account3, 1);
await printBalance(account1.publicKey);
await printBalance(account3.publicKey);
console.log(
`
account1 的 Owner 为:`,
(
await anchor.getProvider().connection.getAccountInfo(account1.publicKey)
).owner.toString()
);
console.log(
`
padAccount 的 Owner 为:`,
(
await anchor.getProvider().connection.getAccountInfo(padAccount)
).owner.toString()
);
});
it("initialize keypair", async () => {
console.log(
`
account2 的 Owner 为:`,
(
await anchor.getProvider().connection.getAccountInfo(account2.publicKey)
).owner.toString()
);
await program.methods
.initializeKeyPair("hello keypair")
.accounts({
keyPair: account2.publicKey,
singer: wallet.publicKey,
})
.signers([account2])
.rpc();
const res = await program.account.myAccountData.fetch(account2.publicKey);
console.log(`
${account2.publicKey} 初始化了 PDA,
内容为: ${res.name}
`);
const accountInfo = await anchor
.getProvider()
.connection.getAccountInfo(account2.publicKey);
expect(accountInfo.owner.toString()).to.eq(program.programId.toString());
try {
await sendSol(account2, account3, 1);
expect.fail("should fail");
} catch (error) {
console.error("sendSol 失败");
}
await printBalance(account2.publicKey);
await printBalance(account3.publicKey);
console.log(
`
account2 的 Owner 为:`,
(
await anchor.getProvider().connection.getAccountInfo(account2.publicKey)
).owner.toString()
);
});
});
通过上述测试,我们可以观察到,当账户(如account2)被程序初始化后,其所有者变更为程序的ID,因此尝试从该账户转账将会失败,这是因为程序成为了账户的新所有者。这表明,无论是PDA还是密钥对账户,在被程序初始化后,其所有权都会转移到程序本身,从而增强了安全性。
应该使用 PDA 还是 Keypair 帐户?
尽管PDA和Keypair账户在初始化后的行为相似,大多数应用程序倾向于使用PDA,因为它们可以通过种子参数以编程方式寻址,而Keypair账户需要提前知道地址。PDA在智能合约开发中更加灵活和安全,因此是存储数据的首选方式。Keypair账户主要用于教学示例,但在实际应用中,PDA更为常用。
4. 所有者和授权者有何不同?
在Solana区块链中,所有者(Owner)和授权者(Authority)是两个重要的概念,用于账户管理和权限控制。
什么是所有者(Owner)?
- 定义:所有者是控制账户的程序。每个账户都有一个所有者,通常是创建该账户的程序。
- 作用:只有所有者程序可以修改账户的数据。例如,如果一个账户的所有者是某个智能合约程序,那么只有这个程序可以对该账户进行读写操作。
- 示例:用户的钱包账户的所有者是系统程序(System Program),地址为
11111111111111111111111111111111。用户需要通过发送签名交易给系统程序来修改余额。
什么是授权者(Authority)?
- 定义:授权者是一个地址,可以向程序发送指令以修改特定账户的数据。授权者地址通常是一个用户的公钥。
- 作用:授权者地址不能直接修改账户数据,但可以通过发送签名交易给所有者程序,请求程序执行特定操作。
- 示例:假设一个账户的所有者是智能合约程序,而某个用户是该账户的授权者。该用户可以通过发送签名交易给该程序,请求执行转账等操作。
上一节中的crowd_found合约中,CrowdFound结构体中的authority字段就是授权者。
use anchor_lang::prelude::*;
#[account]
#[derive(InitSpace)]
pub struct CrowdFound {
#[max_len(50)]
pub title: String,
#[max_len(100)]
pub description: String,
pub authority: Pubkey,
pub balance: u64,
}
注意:
所有者是每个账户的基本属性,而授权者通常是账户data数据中的一个字段,如上面CrowdFound中的authority字段。
为什么账户所有者变化后无法再进行转账?
在Solana网络中,每个账户都有一个所有者,通常是某个程序。如果账户的所有者从系统程序变更为另一个程序,那么只有新的所有者程序可以修改该账户的数据。因此,用户不能再直接使用自己的私钥来控制这个账户,必须通过新的所有者程序来完成操作。这确保了账户数据的安全性和操作的有序性。
例如,账户account2最初由系统程序创建,用户可以使用自己的私钥请求转账。但如果account2的所有者变更为另一个程序,用户就无法再直接控制这个账户,必须通过新程序来执行操作。
最后
如果您觉得这个教程系列对您有帮助,欢迎点赞、收藏并关注。感谢您的阅读!
另外,本节项目的源码可以在 GitHub 上找到。如果您有任何疑问或建议,也欢迎随时联系我。我会尽力提供帮助和支持。