(七)Solana 入门指南——众筹智能合约篇(下)

717 阅读11分钟

(七)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

image-20241120111640432.png 通过这些步骤,您可以验证通过 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)

  • 作用UncheckedAccountAccountInfo 的别名,不检查所有权,因此必须小心使用,因为它会接受任意账户。这种类型通常用于需要传递任意地址的情况,但要非常小心地处理数据,因为黑客可能在账户中放置恶意数据。
  • 示例
    #[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()
    );
  });
});

image-20241121142834138.png 通过上述测试,我们可以观察到,当账户(如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 上找到。如果您有任何疑问或建议,也欢迎随时联系我。我会尽力提供帮助和支持。