本文介绍了如何在Solana上使用Anchor框架构建一个简单的银行程序,包括账户创建、余额查询、存款和取款等基本功能。文章详细讲解了程序中用到的关键概念,例如PDA(Program Derived Address),并通过Solidity代码和Rust代码进行了对比,展示了如何在Solana上实现类似以太坊的功能。
在本教程中,我们将在 Solana 上构建一个简单的银行程序,它具有你期望从普通银行获得的基本功能。用户可以创建账户、查看余额、存入资金,并在需要时提取资金。存入的 SOL 将存储在我们的程序拥有的银行 PDA 中。
以下是我们尝试用 Anchor 构建的 Solidity 表示。
在 Solidity 代码中,我们定义了一个 User
和 Bank
结构体来保存状态。这些反映了我们将在 Solana 程序中创建的单独账户。代码中的 initialize
函数将银行的总存款设置为零,但在 Solana 程序中,这将涉及部署和初始化银行账户本身。类似地,Solidity 代码中的 createUserAccount
函数只是更新存储,而在 Solana 中,它将在链上创建并初始化一个新的用户账户。另请注意存款和取款如何更新用户和银行的状态,自定义错误会在余额不足或无效时强制执行规则。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract BasicBank {
// Custom errors
error ZeroAmount();
error InsufficientBalance();
error Overflow();
error Underflow();
error InsufficientFunds();
error UnauthorizedAccess();
// Bank struct to track total deposits across all users
struct Bank {
uint256 totalDeposits;
}
// User account struct to store individual balances
struct UserAccount {
address owner;
uint256 balance;
}
// The bank state
Bank public bank;
// Mapping from user address to their user account
mapping(address => UserAccount) public userAccounts;
// Initialize the bank
function initialize() external {
bank.totalDeposits = 0;
}
// Create a user account
function createUserAccount() external {
// Ensure account doesn't already exist
require(userAccounts[msg.sender].owner == address(0), "Account already created");
// Initialize the user account
userAccounts[msg.sender].owner = msg.sender;
userAccounts[msg.sender].balance = 0;
}
// Deposit ETH into the bank
function deposit(uint256 amount) external payable {
// Ensure amount is greater than zero
if (amount == 0) {
revert ZeroAmount();
}
// Ensure account exists and is owned by caller
if (userAccounts[msg.sender].owner != msg.sender) {
revert UnauthorizedAccess();
}
// Ensure the correct amount was sent
require(msg.value == amount, "Amount mismatch");
// Update user balance with checks for overflow
uint256 newUserBalance = userAccounts[msg.sender].balance + amount;
if (newUserBalance < userAccounts[msg.sender].balance) {
revert Overflow();
}
userAccounts[msg.sender].balance = newUserBalance;
// Update bank total deposits with checks for overflow
uint256 newTotalDeposits = bank.totalDeposits + amount;
if (newTotalDeposits < bank.totalDeposits) {
revert Overflow();
}
bank.totalDeposits = newTotalDeposits;
}
// Withdraw ETH from the bank
function withdraw(uint256 amount) external {
// Ensure amount is greater than zero
if (amount == 0) {
revert ZeroAmount();
}
// Ensure account exists and is owned by caller
if (userAccounts[msg.sender].owner != msg.sender) {
revert UnauthorizedAccess();
}
// Check if user has enough balance
if (userAccounts[msg.sender].balance < amount) {
revert InsufficientBalance();
}
// Update user balance with checks for underflow
uint256 newBalance = userAccounts[msg.sender].balance - amount;
userAccounts[msg.sender].balance = newBalance;
// Update bank total deposits with checks for underflow
uint256 newTotalDeposits = bank.totalDeposits - amount;
if (newTotalDeposits > bank.totalDeposits) {
revert Underflow();
}
bank.totalDeposits = newTotalDeposits;
// Transfer ETH to the user
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
// Get the balance of the caller's bank account
function getBalance() external view returns (uint256) {
// Ensure account exists and is owned by caller
if (userAccounts[msg.sender].owner != msg.sender) {
revert UnauthorizedAccess();
}
return userAccounts[msg.sender].balance;
}
}
在深入研究代码之前,以下是我们的基础银行程序的工作方式。我们需要以下功能:
我们需要以下存储:
所有这些元素结合在一起构成了我们的基础银行程序。
现在,让我们开始吧。
创建一个名为 basic_bank
的新 Anchor 项目,并添加以下程序代码。该程序定义了两个指令:initialize
,它创建一个银行 PDA 来保存存款,以及 create_user_account
,它设置一个特定于用户的 PDA 来跟踪每个用户的总存款。用户实际存入的 SOL 将存储在银行 PDA 中。
我们为用户提供了一个专用帐户,因为在 Solana 中,所有程序数据都必须作为其自身的帐户存在。与以太坊不同,我们可以使用映射来存储存款(mapping(address => deposited_amount)
),Solana 需要显式帐户分配来存储状态。因此,我们创建一个 user_account PDA 来存储用户的地址和他们的存款金额。
use anchor_lang::prelude::*;
use anchor_lang::solana_program::rent::Rent;
use anchor_lang::solana_program::system_instruction;
use anchor_lang::solana_program::program as solana_program;
declare_id!("u9tNA22L1oRZyF3RKoPVUYTAc1zCYSC5BySKFddZnfN"); // 运行 ANCHOR SYNC 以更新你的程序 ID
#[program]
pub mod basic_bank {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
// 初始化银行账户
let bank = &mut ctx.accounts.bank;
bank.total_deposits = 0;
msg!("银行已初始化");
Ok(())
}
pub fn create_user_account(ctx: Context<CreateUserAccount>) -> Result<()> {
// 初始化用户账户
let user_account = &mut ctx.accounts.user_account;
user_account.owner = ctx.accounts.user.key();
user_account.balance = 0;
msg!("已为以下用户创建账户: {:?}", user_account.owner);
Ok(())
}
}
让我们为我们的基础银行程序定义账户结构体。这些结构体被 initialize
和 create_user_account
函数使用,它们是:
Initialize
****结构体,包含:
bank
:正在创建的用于跟踪总存款的新银行账户payer
:为交易费用和租金支付的账户system_program
:创建新账户所必需的CreateUserAccount
结构体包含:
bank
:对主银行账户的引用user_account
:从用户的公钥派生的 PDA,用于存储他们的余额user
:拥有此账户并为其创建付费的签名者system_program
:创建账户所必需的Bank
和一个 UserAccount
结构体,它们定义了各自账户的数据结构。Bank
结构体包含一个 total_deposits
字段,用于跟踪银行账户中的所有存款,而 UserAccount
结构体存储用户的公钥和他们在用户账户 PDA 中的个人余额。
// 账户结构体,用于创建银行 PDA 以进行存储
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(\
init,\
payer = payer,\
space = 8 + Bank::INIT_SPACE)] // 鉴别器 + u64
pub bank: Account<'info, Bank>,
#[account(mut)]
pub payer: Signer<'info>,
pub system_program: Program<'info, System>,
}
// 用于创建个人用户账户的账户结构体
#[derive(Accounts)]
pub struct CreateUserAccount<'info> {
#[account(mut)]
pub bank: Account<'info, Bank>,
#[account(\
init,\
payer = user,\
space = 8 + UserAccount::INIT_SPACE, // 鉴别器 + 公钥 + u64\
seeds = [b"user-account", user.key().as_ref()],\
bump\
)]
pub user_account: Account<'info, UserAccount>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}
// 用于跟踪所有用户的总存款的银行账户
#[account]
#[derive(InitSpace)]
pub struct Bank {
pub total_deposits: u64,
}
// 用于跟踪个人用户余额的特定于用户的账户
#[account]
#[derive(InitSpace)]
pub struct UserAccount {
pub owner: Pubkey,
pub balance: u64,
}
接下来,添加以下测试以初始化并创建一个用户账户。
该测试执行以下操作:
initialize
以设置银行账户并验证它是否以零余额开始。createUserAccount
以初始化一个特定于用户的 PDA,并断言用户的地址和余额是否已正确记录。import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Keypair, PublicKey } from "@solana/web3.js";
import { assert } from "chai";
import { BasicBank } from "../target/types/basic_bank";
describe("basic_bank", () => {
// 配置客户端以使用本地集群
anchor.setProvider(anchor.AnchorProvider.env());
const program = anchor.workspace.BasicBank as Program<BasicBank>;
const provider = anchor.AnchorProvider.env();
// 为银行账户生成一个新的密钥对
const bankAccount = Keypair.generate();
// 使用提供者的钱包作为签名者
const signer = provider.wallet;
// 测试存款金额
const depositAmount = new anchor.BN(1_000_000_000); // 1 SOL 以 lamports 为单位
const withdrawAmount = new anchor.BN(500_000_000); // 0.5 SOL 以 lamports 为单位
// 查找用户账户的 PDA
const [userAccountPDA] = PublicKey.findProgramAddressSync(
[Buffer.from("user-account"), signer.publicKey.toBuffer()],
program.programId
);
it("初始化银行帐户", async () => {
// 初始化银行帐户
const tx = await program.methods
.initialize()
.accounts({
bank: bankAccount.publicKey,
payer: signer.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([bankAccount])
.rpc();
console.log("初始化交易签名", tx);
// 获取银行账户数据
const bankData = await program.account.bank.fetch(bankAccount.publicKey);
// 验证银行是否已正确初始化
assert.equal(bankData.totalDeposits.toString(), "0");
});
it("创建一个用户账户", async () => {
// 为签名者创建用户账户
const tx = await program.methods
.createUserAccount()
.accounts({
bank: bankAccount.publicKey,
userAccount: userAccountPDA,
user: signer.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
console.log("创建用户账户交易签名", tx);
// 获取用户账户数据
const userAccountData = await program.account.userAccount.fetch(userAccountPDA);
// 验证用户账户是否已正确设置
assert.equal(userAccountData.owner.toString(), signer.publicKey.toString());
assert.equal(userAccountData.balance.toString(), "0");
});
});
运行测试,它应该通过。
现在,我们将实现代码以将资金存入银行,并为其编写测试。
将以下存款函数添加到我们的程序中。
它执行以下操作:
transfer
指令(通过 CPI)以将 SOL 从用户的钱包提取到银行账户user_account
PDA,安全地增加用户的账户余额
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
// 确保存款金额大于零
require!(amount > 0, BankError::ZeroAmount);
let user = &ctx.accounts.user.key();
let bank = &ctx.accounts.bank.key();
// 使用系统程序将 SOL 从用户转移到银行账户
let transfer_ix = system_instruction::transfer(user, bank, amount);
solana_program::invoke(
&[\
transfer_ix,\
ctx.accounts.user.to_account_info(),\
ctx.accounts.bank.to_account_info(),\
],
)?;
// 更新用户余额
let user_account = &mut ctx.accounts.user_account;
user_account.balance = user_account
.balance
.checked_add(amount)
.ok_or(BankError::Overflow)?;
// 更新银行存款总额
let bank = &mut ctx.accounts.bank;
bank.total_deposits = bank
.total_deposits
.checked_add(amount)
.ok_or(BankError::Overflow)?;
msg!("为 {} 存入了 {} lamports", amount, user);
Ok(())
}
请注意我们如何使用系统 transfer
指令将 SOL 从用户钱包提取到银行,我们稍后将重新讨论此模式。
现在,添加 Deposit
账户结构体
#[derive(Accounts)]
pub struct Deposit<'info> {
#[account(mut)]
pub bank: Account<'info, Bank>,
#[account(\
mut,\
seeds = [b"user-account", user.key().as_ref()],\
bump,\
constraint = user_account.owner == user.key() @ BankError::UnauthorizedAccess // 确保签名者拥有该账户\
)]
pub user_account: Account<'info, UserAccount>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}
添加自定义 BankError
#[error_code]
pub enum BankError {
#[msg("金额必须大于零")]
ZeroAmount,
#[msg("提款余额不足")]
InsufficientBalance,
#[msg("算术溢出")]
Overflow,
#[msg("算术下溢")]
Underflow,
#[msg("银行帐户资金不足")]
InsufficientFunds,
#[msg("未经授权访问用户账户")]
UnauthorizedAccess,
}
现在使用以下测试块更新程序测试。
存款测试执行以下操作:
it("将资金存入银行", async () => {
// 获取初始 SOL 余额
const initialUserBalance = await provider.connection.getBalance(signer.publicKey);
const initialBankBalance = await provider.connection.getBalance(bankAccount.publicKey);
console.log(`初始用户 SOL 余额:${initialUserBalance / 1e9} SOL`);
console.log(`初始银行 SOL 余额:${initialBankBalance / 1e9} SOL`);
// 将资金存入银行
const tx = await program.methods
.deposit(depositAmount)
.accounts({
bank: bankAccount.publicKey,
userAccount: userAccountPDA,
user: signer.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
console.log("存款交易签名", tx);
// 获取用户的帐户余额
const userAccountData = await program.account.userAccount.fetch(userAccountPDA);
// 验证跟踪的余额是否正确
assert.equal(userAccountData.balance.toString(), depositAmount.toString());
// 验证银行跟踪的总存款
const bankData = await program.account.bank.fetch(bankAccount.publicKey);
assert.equal(bankData.totalDeposits.toString(), depositAmount.toString());
// 获取最终 SOL 余额
const finalUserBalance = await provider.connection.getBalance(signer.publicKey);
const finalBankBalance = await provider.connection.getBalance(bankAccount.publicKey);
console.log(`最终用户 SOL 余额:${finalUserBalance / 1e9} SOL`);
console.log(`最终银行 SOL 余额:${finalBankBalance / 1e9} SOL`);
// 检查实际 SOL 转移 (考虑交易费用)
assert.isTrue(finalBankBalance > initialBankBalance);
// 用户余额应减少存款金额 + 一些交易费用
assert.isTrue(finalUserBalance < initialUserBalance - Number(depositAmount));
assert.isTrue(finalUserBalance > initialUserBalance - Number(depositAmount) - 10000); // 考虑到合理的 tx 费用
});
运行测试,它会通过。
接下来,我们将添加一个函数来检索用户在我们银行中的存款金额。
以下 get_balance
函数检索并返回用户的 lamport 余额。
pub fn get_balance(ctx: Context<GetBalance>) -> Result<u64> {
// 获取用户账户
let user_account = &ctx.accounts.user_account;
let balance = user_account.balance;
msg!("{} 的余额:{} lamports", user_account.owner, balance);
Ok(balance)
}
添加 GetBalance
账户结构体。
#[derive(Accounts)]
pub struct GetBalance<'info> {
pub bank: Account<'info, Bank>,
#[account(\
seeds = [b"user-account", user.key().as_ref()],\
bump,\
constraint = user_account.owner == user.key() @ BankError::UnauthorizedAccess // 确保签名者拥有该账户\
)]
pub user_account: Account<'info, UserAccount>,
pub user: Signer<'info>,
}
为该函数添加测试。
该测试调用我们的 Anchor 程序的 getBalance 函数,并断言返回的余额等于我们之前存入的金额(1 SOL)。
it("检索用户余额", async () => {
// 获取用户的余额
const balance = await program.methods
.getBalance()
.accounts({
bank: bankAccount.publicKey,
userAccount: userAccountPDA,
user: signer.publicKey,
})
.view(); // Anchor 中的 .view() 方法用于调用仅读取数据(视图函数)而不提交实际交易的指令
// 验证余额是否正确
assert.equal(balance.toString(), depositAmount.toString());
console.log(`用户余额:${Number(balance) / 1e9} SOL`);
});
运行测试,它通过。
现在,我们将添加一个提款实现并为其编写测试。
添加以下代码以从我们的银行中提取用户存款。
withdraw 函数执行以下操作:
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
// 确保提款金额大于零
require!(amount > 0, BankError::ZeroAmount);
// 获取账户
let bank = &mut ctx.accounts.bank;
let user_account = &mut ctx.accounts.user_account;
let user = ctx.accounts.user.key();
// 检查用户是否有足够的余额
require!(
user_account.balance >= amount,
BankError::InsufficientBalance
);
// 更新用户余额
user_account.balance = user_account
.balance
.checked_sub(amount)
.ok_or(BankError::Underflow)?;
// 更新银行存款总额
bank.total_deposits = bank
.total_deposits
.checked_sub(amount)
.ok_or(BankError::Underflow)?;
// 计算保持账户免租金所需的最低余额
let rent = Rent::get()?;
let user_account_info = ctx.accounts.user_account.to_account_info();
let minimum_balance = rent.minimum_balance(user_account_info.data_len());
// 计算安全转移金额 (保留免租金最低限额)
let available_lamports = user_account_info.lamports();
let transfer_amount = amount.min(available_lamports.saturating_sub(minimum_balance));
// 转移 SOL:从用户账户 PDA 中减去,并添加到用户钱包中
**user_account_info.try_borrow_mut_lamports()? -= transfer_amount;
**ctx.accounts.user.try_borrow_mut_lamports()? += transfer_amount;
msg!("为 {} 提取了 {} lamports", amount, user);
Ok(())
}
从deposit函数中回想一下,我们使用系统程序的 transfer
函数将 SOL 从用户的账户提取到银行中。我们这样做是因为系统程序拥有所有常规钱包(如以太坊中的 EOA),并且有权修改其余额。
但是在 withdraw 函数中,我们可以直接修改银行和用户账户 PDA 的 lamport 余额。这是因为我们的程序拥有这两个 PDA(我们部署了它们)。
现在添加 Withdraw
账户结构体
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(mut)]
pub bank: Account<'info, Bank>,
#[account(\
mut,\
seeds = [b"user-account", user.key().as_ref()],\
bump,\
constraint = user_account.owner == user.key() @ BankError::UnauthorizedAccess\
)]
pub user_account: Account<'info, UserAccount>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}
为 withdraw 函数添加测试。
它执行以下操作:
it("从银行提取资金", async () => {
// 获取初始余额
const userAccountData = await program.account.userAccount.fetch(userAccountPDA);
const initialBalance = userAccountData.balance;
// 获取初始 SOL 余额
const initialUserBalance = await provider.connection.getBalance(signer.publicKey);
const initialBankBalance = await provider.connection.getBalance(bankAccount.publicKey);
console.log(`初始用户 SOL 余额:${initialUserBalance / 1e9} SOL`);
console.log(`初始银行 SOL 余额:${initialBankBalance / 1e9} SOL`);
// 从银行提取资金
const tx = await program.methods
.withdraw(withdrawAmount)
.accounts({
bank: bankAccount.publicKey,
userAccount: userAccountPDA,
user: signer.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
console.log("提款交易签名", tx);
// 获取新余额
const updatedUserAccountData = await program.account.userAccount.fetch(userAccountPDA);
const newBalance = updatedUserAccountData.balance;
// 验证余额是否正确
const expectedBalance = initialBalance.sub(withdrawAmount);
assert.equal(newBalance.toString(), expectedBalance.toString());
// 验证银行总存款
const bankData = await program.account.bank.fetch(bankAccount.publicKey);
assert.equal(bankData.totalDeposits.toString(), expectedBalance.toString());
// 获取最终 SOL 余额
const finalUserBalance = await provider.connection.getBalance(signer.publicKey);
const finalBankBalance = await provider.connection.getBalance(bankAccount.publicKey);
console.log(`最终用户 SOL 余额:${finalUserBalance / 1e9} SOL`);
console.log(`最终银行 SOL 余额:${finalBankBalance / 1e9} SOL`);
// 检查实际 SOL 转移
// 用户余额应增加提款金额 (减去 tx 费用)
// 由于用户支付 tx 费用,因此最终余额可能略低于预期
assert.isTrue(finalUserBalance < initialUserBalance + Number(withdrawAmount));
assert.isTrue(finalUserBalance > initialUserBalance - 10000); // 允许合理的 tx 费用
// 银行余额应减少提款金额
assert.isTrue(finalBankBalance <= initialBankBalance);
});
现在运行测试。它通过。
让我们添加另一个测试块,以确认用户无法提取超过他们存入的金额。
添加以下代码,它执行以下操作:
it("阻止用户提取超过其余额的金额", async () => {
// 尝试提取超过余额的金额
const excessiveWithdrawAmount = new anchor.BN(10_000_000); // 10 SOL
try {
await program.methods
.withdraw(excessiveWithdrawAmount)
.accounts({
bank: bankAccount.publicKey,
userAccount: userAccountPDA,
user: signer.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
// 如果我们到达这里,则测试失败
assert.fail("应该为余额不足抛出一个错误");
} catch (error) {
// 记录实际错误
console.log("收到的错误:", error.toString());
// 检查可能表明余额不足的多个可能错误消息
const errorMsg = error.toString().toLowerCase();
assert.isTrue(
errorMsg.includes("insufficient balance") ||
errorMsg.includes("0x7d3")
);
}
});
最后,运行测试。它通过。
我们的基础银行程序到此结束。
本文是 Solana 上的教程系列 的一部分。
- 原文链接: rareskills.io/post/spl-t...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!