在本教程中,我们将在 Solana 上构建一个具有常规银行基础功能的简单银行程序。用户可以创建账户、查询余额、存入资金,并在需要时提取资金。存入的 SOL 将存储在一个由我们的程序所拥有的银行 PDA 中。
下面是用 Solidity 表示的我们将使用 Anchor 构建的内容。
在 Solidity 代码中,我们定义了 User 和 Bank struct 来保存状态。这些对应于我们在 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;
}
}
创建基础银行程序
在深入代码之前,我们先了解一下基础银行程序的工作原理。我们需要以下功能:
- 设置和初始化银行的方法
- 用户创建自己账户的方法
- 用户存入资金的方法
- 用户提取资金的方法
- 查询余额的方法
我们需要以下存储:
- 一个中央账户,用于跟踪所有用户的总存款
- 独立账户,可由用户创建,用于跟踪其余额和所有权
所有这些元素结合在一起,构成了我们的基础银行程序。
现在,让我们深入探讨。
银行 PDA 与用户账户
创建一个名为 basic_bank 的新 Anchor 项目,并添加以下程序代码。该程序定义了两个指令:initialize(用于创建一个银行 PDA 来保存存款)和 create_user_account(用于设置特定于用户的 PDA 以跟踪每个用户的总存款)。用户实际存入的 SOL 将存储在银行 PDA 中。
我们为用户设计了一个专用账户,因为在 Solana 中,所有程序数据都必须作为独立的账户存在。与 Ethereum 可以使用映射来存储存款(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"); // RUN ANCHOR SYNC TO UPDATE YOUR PROGRAM ID
#[program]
pub mod basic_bank {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
// Initialize the bank account
let bank = &mut ctx.accounts.bank;
bank.total_deposits = 0;
msg!("Bank initialized");
Ok(())
}
pub fn create_user_account(ctx: Context<CreateUserAccount>) -> Result<()> {
// Initialize the user account
let user_account = &mut ctx.accounts.user_account;
user_account.owner = ctx.accounts.user.key();
user_account.balance = 0;
msg!("User account created for: {:?}", user_account.owner);
Ok(())
}
}
让我们为基础银行程序定义账户 structs。这些 structs 被 initialize 和 create_user_account 函数同时使用,它们分别是:
Initialize****struct,包含:bank:正在创建的新银行账户,用于跟踪总存款payer:支付交易费和租金的账户system_program:创建新账户所必需的
CreateUserAccountstruct 包含:bank:对主银行账户的引用user_account:从用户公钥派生的 PDA,用于存储其余额user:拥有该账户并为其创建支付费用的签名者system_program:创建账户所必需的
- 最后,我们定义了
Bank和UserAccountstruct,它们定义了各自账户的数据结构。Bankstruct 包含一个total_deposits字段,用于跟踪银行账户中的所有存款;而UserAccountstruct 在用户账户 PDA 中存储用户的公钥和个人余额。
// ACCOUNT STRUCT TO CREATE THE BANK PDA TO STORE TO
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(
init,
payer = payer,
space = 8 + Bank::INIT_SPACE)] // discriminator + u64
pub bank: Account<'info, Bank>,
#[account(mut)]
pub payer: Signer<'info>,
pub system_program: Program<'info, System>,
}
// ACCOUNT STRUCT FOR CREATING INDIVIDUAL USER ACCOUNT
#[derive(Accounts)]
pub struct CreateUserAccount<'info> {
#[account(mut)]
pub bank: Account<'info, Bank>,
#[account(
init,
payer = user,
space = 8 + UserAccount::INIT_SPACE, // discriminator + pubkey + 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>,
}
// BANK ACCOUNT TO TRACK TOTAL DEPOSITS ACROSS ALL USERS
#[account]
#[derive(InitSpace)]
pub struct Bank {
pub total_deposits: u64,
}
// USER-SPECIFIC ACCOUNT TO TRACK INDIVIDUAL USER BALANCES
#[account]
#[derive(InitSpace)]
pub struct UserAccount {
pub owner: Pubkey,
pub balance: u64,
}
接下来,添加下面的测试来初始化并创建一个用户账户。
该测试执行以下操作:
- 为银行账户生成一个密钥对
- 使用 provider 的钱包(我们默认的 Anchor 钱包)作为所有操作的签名者
- 设置测试金额值(存款为 1 SOL,取款为 0.5 SOL)
- 为签名者的公钥派生用户账户的 PDA
- 调用
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", () => {
// Configure the client to use the local cluster
anchor.setProvider(anchor.AnchorProvider.env());
const program = anchor.workspace.BasicBank as Program<BasicBank>;
const provider = anchor.AnchorProvider.env();
// Generate a new keypair for the bank account
const bankAccount = Keypair.generate();
// Use provider's wallet as the signer
const signer = provider.wallet;
// Test deposit amount
const depositAmount = new anchor.BN(1_000_000_000); // 1 SOL in lamports
const withdrawAmount = new anchor.BN(500_000_000); // 0.5 SOL in lamports
// Find PDA for user accounts
const [userAccountPDA] = PublicKey.findProgramAddressSync(
[Buffer.from("user-account"), signer.publicKey.toBuffer()],
program.programId,
);
it("Initializes the bank account", async () => {
// Initialize the bank account
const tx = await program.methods
.initialize()
.accounts({
bank: bankAccount.publicKey,
payer: signer.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([bankAccount])
.rpc();
console.log("Initialize transaction signature", tx);
// Fetch the bank account data
const bankData = await program.account.bank.fetch(bankAccount.publicKey);
// Verify the bank is initialized correctly
assert.equal(bankData.totalDeposits.toString(), "0");
});
it("Creates a user account", async () => {
// Create user account for the signer
const tx = await program.methods
.createUserAccount()
.accounts({
bank: bankAccount.publicKey,
userAccount: userAccountPDA,
user: signer.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
console.log("Create user account transaction signature", tx);
// Fetch the user account data
const userAccountData = await program.account.userAccount.fetch(userAccountPDA);
// Verify the user account is set correctly
assert.equal(userAccountData.owner.toString(), signer.publicKey.toString());
assert.equal(userAccountData.balance.toString(), "0");
});
});
运行测试,应该会通过。

现在我们将实现向银行存入资金的代码,并为其编写测试。
向银行存入 SOL
将下面的 deposit 函数添加到我们的程序中。
它执行以下操作:
- 验证存款金额大于零
- 创建并执行 System
transfer指令(通过 CPI),将 SOL 从用户的钱包提取到银行账户 - 安全地将存款金额加到用户的
user_accountPDA 中,从而增加用户账户余额 - 用相同金额更新银行的总存款
- 记录存款金额和用户地址
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
// Ensure deposit amount is greater than zero
require!(amount > 0, BankError::ZeroAmount);
let user = &ctx.accounts.user.key();
let bank = &ctx.accounts.bank.key();
// Transfer SOL from user to bank account using System Program
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(),
],
)?;
// Update user balance
let user_account = &mut ctx.accounts.user_account;
user_account.balance = user_account
.balance
.checked_add(amount)
.ok_or(BankError::Overflow)?;
// Update bank total deposits
let bank = &mut ctx.accounts.bank;
bank.total_deposits = bank
.total_deposits
.checked_add(amount)
.ok_or(BankError::Overflow)?;
msg!("Deposited {} lamports for {}", amount, user);
Ok(())
}
请注意我们是如何使用 System transfer 指令将 SOL 从用户钱包提取到银行的,稍后我们将回顾这种模式。
现在,添加 Deposit 账户 struct
#[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 // Ensure the signer owns the account
)]
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("Amount must be greater than zero")]
ZeroAmount,
#[msg("Insufficient balance for withdrawal")]
InsufficientBalance,
#[msg("Arithmetic overflow")]
Overflow,
#[msg("Arithmetic underflow")]
Underflow,
#[msg("Insufficient funds in the bank account")]
InsufficientFunds,
#[msg("Unauthorized access to user account")]
UnauthorizedAccess,
}
现在使用下面的测试块更新程序测试。
存款测试执行以下操作:
- 记录用户和银行账户的初始 SOL 余额
- 提交一笔从用户向银行存入 1 SOL 的存款交易
- 验证用户账户的余额记录和银行的总存款记录都已使用新的存款金额正确更新
- 检查银行余额是否增加以及用户余额是否相应减少(考虑交易费用),以确认实际的 SOL 转账已发生
- 最后,记录所有余额变化
it("Deposits funds into the bank", async () => {
// Get initial SOL balances
const initialUserBalance = await provider.connection.getBalance(signer.publicKey);
const initialBankBalance = await provider.connection.getBalance(bankAccount.publicKey);
console.log(`Initial user SOL balance: ${initialUserBalance / 1e9} SOL`);
console.log(`Initial bank SOL balance: ${initialBankBalance / 1e9} SOL`);
// Deposit funds into the bank
const tx = await program.methods
.deposit(depositAmount)
.accounts({
bank: bankAccount.publicKey,
userAccount: userAccountPDA,
user: signer.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
console.log("Deposit transaction signature", tx);
// Get the user's account balance
const userAccountData = await program.account.userAccount.fetch(userAccountPDA);
// Verify the tracked balance is correct
assert.equal(userAccountData.balance.toString(), depositAmount.toString());
// Verify bank total tracked deposits
const bankData = await program.account.bank.fetch(bankAccount.publicKey);
assert.equal(bankData.totalDeposits.toString(), depositAmount.toString());
// Get final SOL balances
const finalUserBalance = await provider.connection.getBalance(signer.publicKey);
const finalBankBalance = await provider.connection.getBalance(bankAccount.publicKey);
console.log(`Final user SOL balance: ${finalUserBalance / 1e9} SOL`);
console.log(`Final bank SOL balance: ${finalBankBalance / 1e9} SOL`);
// Check actual SOL transfers (accounting for tx fees)
assert.isTrue(finalBankBalance > initialBankBalance);
// User balance should be reduced by deposit amount + some tx fees
assert.isTrue(finalUserBalance < initialUserBalance - Number(depositAmount));
assert.isTrue(finalUserBalance > initialUserBalance - Number(depositAmount) - 10000); // Account for a reasonable tx fees
});
运行测试,测试通过。

接下来,我们将添加一个函数来获取用户在我们银行中的存款金额。
获取用户余额
下面的 get_balance 函数检索并返回用户的 lamport 余额。
pub fn get_balance(ctx: Context<GetBalance>) -> Result<u64> {
// Get user account
let user_account = &ctx.accounts.user_account;
let balance = user_account.balance;
msg!("Balance for {}: {} lamports", user_account.owner, balance);
Ok(balance)
}
添加 GetBalance 账户 struct。
#[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 // Ensure the signer owns the account
)]
pub user_account: Account<'info, UserAccount>,
pub user: Signer<'info>,
}
为该函数添加测试。
该测试调用我们 Anchor 程序的 getBalance 函数,并断言返回的余额等于我们之前存入的金额(1 SOL)。
it("Retrieves user balance", async () => {
// Get the user's balance
const balance = await program.methods
.getBalance()
.accounts({
bank: bankAccount.publicKey,
userAccount: userAccountPDA,
user: signer.publicKey,
})
.view(); // The .view() method in Anchor is used to call instructions that only read data (view functions) without submitting an actual transaction
// Verify the balance is correct
assert.equal(balance.toString(), depositAmount.toString());
console.log(`User balance: ${Number(balance) / 1e9} SOL`);
});
运行测试,测试通过。

现在,我们将添加一个取款实现并为其编写测试。
从银行提取余额:成功案例
添加下面的代码以从我们的银行提取用户存款。
取款函数执行以下操作:
- 验证取款金额大于零
- 检查用户是否有足够的余额进行取款
- 使用安全检查算术(checked arithmetic)更新用户账户余额和银行的总存款
- 计算保持银行账户免租金(rent-exempt)所需的最低余额
- 确定在保留免租金最低余额的情况下的安全转账金额
- 使用直接的 lamport 操作将 SOL 从银行转移到用户钱包(因为我们的程序拥有银行账户)
- 记录取款详情
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
// Ensure withdraw amount is greater than zero
require!(amount > 0, BankError::ZeroAmount);
// Get accounts
let bank = &mut ctx.accounts.bank;
let user_account = &mut ctx.accounts.user_account;
let user = ctx.accounts.user.key();
// Check if the user has enough balance
require!(
user_account.balance >= amount,
BankError::InsufficientBalance
);
// Update user balance
user_account.balance = user_account
.balance
.checked_sub(amount)
.ok_or(BankError::Underflow)?;
// Update bank total deposits
bank.total_deposits = bank
.total_deposits
.checked_sub(amount)
.ok_or(BankError::Underflow)?;
// The bank account must keep enough lamports to stay rent-exempt,
// otherwise the runtime will garbage-collect it.
let rent = Rent::get()?;
let bank_account_info = ctx.accounts.bank.to_account_info();
let minimum_balance = rent.minimum_balance(bank_account_info.data_len());
// Only transfer what the bank can afford after reserving rent.
// Cap at the requested amount so we never send more than asked.
let available_lamports = bank_account_info.lamports();
let transfer_amount = amount.min(available_lamports.saturating_sub(minimum_balance));
// Transfer SOL: subtract from bank account and add to user wallet
**bank_account_info.try_borrow_mut_lamports()? -= transfer_amount;
**ctx.accounts.user.try_borrow_mut_lamports()? += transfer_amount;
msg!("Withdrawn {} lamports for {}", amount, user);
Ok(())
}
回想一下在 deposit 函数中,我们使用 System Program 的 transfer 函数将 SOL 从用户账户提取到银行。我们这样做是因为 System Program 拥有所有常规钱包(就像 Ethereum 中的 EOA),并且有权修改它们的余额。

但在 withdraw 函数中,我们可以直接修改银行和用户账户 PDA 的 lamport 余额。这是因为我们的程序拥有这两个 PDA(是我们部署的它们)。

现在添加 Withdraw 账户 struct
#[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 函数添加测试。
它执行以下操作:
- 记录用户账户的初始余额,并获取用户和银行的 SOL 余额
- 提交一笔从银行向用户提取 0.5 SOL 的取款交易
- 验证取款后用户账户余额和银行的总存款都已正确更新
- 检查用户余额是否增加(考虑交易费用),以确认实际的 SOL 转账已发生
- 最后,我们记录该交易的所有余额变化
it("Withdraws funds from the bank", async () => {
// Get the initial balance
const userAccountData = await program.account.userAccount.fetch(userAccountPDA);
const initialBalance = userAccountData.balance;
// Get initial SOL balances
const initialUserBalance = await provider.connection.getBalance(signer.publicKey);
const initialBankBalance = await provider.connection.getBalance(bankAccount.publicKey);
console.log(`Initial user SOL balance: ${initialUserBalance / 1e9} SOL`);
console.log(`Initial bank SOL balance: ${initialBankBalance / 1e9} SOL`);
// Withdraw funds from the bank
const tx = await program.methods
.withdraw(withdrawAmount)
.accounts({
bank: bankAccount.publicKey,
userAccount: userAccountPDA,
user: signer.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
console.log("Withdraw transaction signature", tx);
// Get the new balance
const updatedUserAccountData = await program.account.userAccount.fetch(userAccountPDA);
const newBalance = updatedUserAccountData.balance;
// Verify the balance is correct
const expectedBalance = initialBalance.sub(withdrawAmount);
assert.equal(newBalance.toString(), expectedBalance.toString());
// Verify bank total deposits
const bankData = await program.account.bank.fetch(bankAccount.publicKey);
assert.equal(bankData.totalDeposits.toString(), expectedBalance.toString());
// Get final SOL balances
const finalUserBalance = await provider.connection.getBalance(signer.publicKey);
const finalBankBalance = await provider.connection.getBalance(bankAccount.publicKey);
console.log(`Final user SOL balance: ${finalUserBalance / 1e9} SOL`);
console.log(`Final bank SOL balance: ${finalBankBalance / 1e9} SOL`);
// Check actual SOL transfers
// User balance should increase by withdraw amount (minus tx fees)
// Since the user pays tx fees, the final balance might be slightly less than expected
assert.isTrue(finalUserBalance < initialUserBalance + Number(withdrawAmount));
assert.isTrue(finalUserBalance > initialUserBalance - 10000); // Allow for reasonable tx fees
// Bank balance should decrease by withdraw amount
assert.isTrue(finalBankBalance <= initialBankBalance);
});
现在运行测试。测试通过。

从银行提取余额:失败案例
让我们再添加一个测试块,以确认用户无法提取超过其存款的金额。
添加以下代码,它执行以下操作:
- 尝试从银行提取超额金额(10 SOL)
- 预期交易失败并抛出余额不足错误
- 使用 try/catch 块捕获预期的失败,并记录收到的实际错误消息
- 验证错误消息中包含“insufficient balance”文本
- 如果没有抛出错误,则测试失败(这表明缺少验证——这是我们不希望发生的)
it("Prevents users from withdrawing more than their balance", async () => {
// Try to withdraw more than the balance
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();
// If we reach here, the test failed
assert.fail("Should have thrown an error for insufficient balance");
} catch (error) {
// Log the actual error
console.log("Error received:", error.toString());
// Check for multiple possible error messages that could indicate insufficient balance
const errorMsg = error.toString().toLowerCase();
assert.isTrue(
errorMsg.includes("insufficient balance") ||
errorMsg.includes("0x7d3")
);
}
});
最后,运行测试。测试通过。

我们的基础银行程序到此结束。
本文是 Solana 教程系列 的一部分。