Solana 核心概念详解

账户模型:一切皆账户

在 Solana 中,"账户"是最基本的数据单元。这个概念与 Web2 中的"账户"有很大不同,更接近于"文件"的概念。

账户的结构

每个 Solana 账户都有以下字段:

rust
pub struct Account { pub lamports: u64, // 余额(1 SOL = 10^9 lamports) pub data: Vec<u8>, // 存储的数据(最大 10MB) pub owner: Pubkey, // 拥有者程序的地址 pub executable: bool, // 是否是可执行程序 pub rent_epoch: u64, // 租金相关 }

用 Web2 类比:

  • lamports:类似于文件系统中的"配额"或"积分"
  • data:文件的内容,可以存储任何二进制数据
  • owner:类似于 Unix 文件系统中的"属主",决定谁有权修改这个文件
  • executable:类似于文件的执行权限位

所有权规则

Solana 有一条关键的安全规则:只有账户的 owner 程序才能修改账户的 data 和减少 lamports

但是,任何人都可以向账户转账(增加 lamports)。

这种设计使得程序的权限边界非常清晰。

账户类型

  1. 系统账户(System Account):最常见的账户类型,owner 是 System Program。普通用户的"钱包"就是系统账户,它只存储 SOL 余额。
  2. 数据账户(Data Account):由自定义程序创建和拥有,用于存储应用数据。例如,一个 DEX 可能为每个用户创建一个数据账户来记录他们的订单。
  3. 程序账户(Program Account):存储可执行代码,executable 标志为 true。程序一旦部署,其代码默认不可修改。

与以太坊的对比

特性以太坊Solana
状态存储合约内部存储独立账户
程序与数据耦合分离
并行能力有限原生支持
升级模式代理模式程序升级授权

租金机制:存储也有成本

在 Web2 中,数据存储的成本由服务提供商承担,然后通过订阅费或广告回收。但在去中心化网络中,每个节点都必须存储所有数据,这个成本必须由用户承担。

Solana 的租金机制是这样工作的:

  1. 每个账户根据其数据大小,需要维持一定的最低余额
  2. 如果余额低于最低要求,账户可能被清除以回收存储空间
  3. 为了简化操作,Solana 引入了"免租金"(Rent-Exempt)标准:只要账户余额大于等于 2 年租金,账户就永久存在
javascript
// 计算免租金所需的最低余额 const dataSize = 1000; // 1000 字节 const minimumBalance = await connection.getMinimumBalanceForRentExemption(dataSize); console.log(`需要 ${minimumBalance / 1e9} SOL 来保持账户免租金`);

当你关闭账户时,所有 lamports 都会返还。这鼓励开发者清理不再需要的数据。

程序(智能合约)

Solana 上的智能合约被称为"程序"(Program)。与以太坊的合约相比,Solana 程序有一个关键区别:程序是无状态的

在以太坊中,合约的代码和状态存储在一起:

solidity
// 以太坊合约 contract Counter { uint256 public count; // 状态存储在合约内部 function increment() public { count++; } }

在 Solana 中,程序只包含逻辑,状态存储在独立的账户中:

rust
// Solana 程序(简化示意) pub fn increment(accounts: &[AccountInfo]) -> ProgramResult { let counter_account = &accounts[0]; let mut data = counter_account.try_borrow_mut_data()?; // 从账户读取当前值 let mut count = u64::from_le_bytes(data[0..8].try_into().unwrap()); count += 1; // 写回账户 data[0..8].copy_from_slice(&count.to_le_bytes()); Ok(()) }

这种设计的优势:

  1. 并行性:不同账户的操作可以并行执行
  2. 灵活性:同一个程序可以操作无限多个账户
  3. 可组合性:账户可以被多个程序读取(虽然只能被 owner 修改)

程序派生地址(PDA)

这是 Solana 最独特也最重要的概念之一。

问题:程序如何"拥有"资产?

考虑一个简单的托管场景:用户 A 想要将 1 SOL 锁定,等条件满足后转给用户 B。在 Web2 中,我们用一个可信的中间人(比如支付宝)来持有这笔钱。但在去中心化世界里,谁来扮演这个角色?

程序本身可以拥有账户,但程序没有私钥,它怎么"签名"来授权转账?

答案是 PDA(Program Derived Address)。

PDA 的本质

PDA 是一种特殊的地址,它是从程序 ID 和一组"种子"(seeds)确定性计算出来的:

PDA = hash(program_id, seed_1, seed_2, ..., bump)

关键点:这个地址不在椭圆曲线上,因此不存在对应的私钥

rust
// 生成 PDA let (pda, bump) = Pubkey::find_program_address( &[ b"escrow", // 固定前缀 user_a.key.as_ref(), // 用户 A 的公钥 user_b.key.as_ref(), // 用户 B 的公钥 ], program_id, );

程序签名

虽然 PDA 没有私钥,但 Solana 运行时有一条特殊规则:拥有 PDA 的程序可以代表它签名

这意味着:

  1. 托管程序创建一个 PDA 账户来持有用户 A 的 SOL
  2. 当条件满足时,托管程序可以代表 PDA 签名,将 SOL 转给用户 B
  3. 整个过程不需要任何人类干预,也不需要信任任何中心化实体

确定性的优势

因为 PDA 是确定性计算的,所以前端应用不需要查询后端就能计算出账户地址:

javascript
// 前端可以直接计算 PDA const [escrowPda] = PublicKey.findProgramAddressSync( [ Buffer.from("escrow"), userA.toBuffer(), userB.toBuffer(), ], programId ); // 直接使用这个地址,无需查询 const accountInfo = await connection.getAccountInfo(escrowPda);

交易与指令

交易结构

Solana 的交易是用户与网络交互的基本单位。一个交易包含:

  1. 签名列表:授权这笔交易的所有签名者
  2. 账户列表:交易涉及的所有账户(必须预先声明)
  3. 最近区块哈希:防止重放攻击
  4. 指令列表:要执行的操作
javascript
const transaction = new Transaction(); // 添加指令 transaction.add( SystemProgram.transfer({ fromPubkey: sender.publicKey, toPubkey: recipient, lamports: 1_000_000_000, // 1 SOL }) ); // 可以在一个交易中包含多个指令 transaction.add( createAssociatedTokenAccountInstruction(...) ); transaction.add( createTransferInstruction(...) ); // 签名并发送 await sendAndConfirmTransaction(connection, transaction, [sender]);

原子性

这是区块链最强大的特性之一:一个交易中的所有指令是原子的。它们要么全部成功,要么全部失败,不存在中间状态。

考虑一个 DEX 交易:

  1. 指令 1:从用户账户扣除 100 USDC
  2. 指令 2:向用户账户添加 1 SOL

如果指令 1 成功但指令 2 失败(比如流动性不足),整个交易回滚,用户的 100 USDC 不会被扣除。

这与 Web2 形成鲜明对比。在传统系统中,实现跨服务的原子操作通常需要复杂的分布式事务协议(如 2PC、Saga 模式等),而且往往无法保证强一致性。

计算预算

每笔交易都有计算资源限制,以防止无限循环和拒绝服务攻击。默认限制是 200,000 计算单元(CU),可以提高到 1,400,000 CU。

javascript
// 如果操作复杂,可以请求更多计算单元 transaction.add( ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 }) ); // 在网络拥堵时,可以添加优先费以加快确认 transaction.add( ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1000 // 每 CU 的价格 }) );

跨程序调用(CPI)

程序可以调用其他程序,这是实现复杂 DeFi 协议的基础。

示例场景

假设你开发了一个自动定投程序:用户每天自动购买价值 10 USDC 的 SOL。这个程序需要:

  1. 调用 Token Program 转移用户的 USDC
  2. 调用 DEX 程序(如 Raydium)执行兑换
  3. DEX 程序内部调用 Token Program 转移 SOL
rust
// 跨程序调用示例 invoke_signed( &dex_swap_instruction, &[ user_usdc_account.clone(), user_sol_account.clone(), pool_account.clone(), token_program.clone(), ], &[&[b"authority", user_key.as_ref(), &[bump]]], // PDA 签名 )?;

CPI 限制

  • 调用深度最多 4 层
  • 所有涉及的账户必须在最外层交易中声明
  • 被调用程序不能获得比调用程序更多的权限
RUSTPlayground
EDITOR ACTIVE
Initializing RUST Environment...

Solana 核心运行时模拟

Actions
Ready...
User Wallet
Address:User111....
Owner:System111...
Lamports:5000
Data:0x
System Program
EXE
Address:System11...
Owner:NativeLoa...
Lamports:1
Data:Native Code
My Program
EXE
Address:Prog999....
Owner:BPFLoader...
Lamports:100
Data:BPF Code
Counter PDA
Address:PDA222.....
Owner:System111...
Lamports:0
Data:Empty