账户模型:一切皆账户
在 Solana 中,"账户"是最基本的数据单元。这个概念与 Web2 中的"账户"有很大不同,更接近于"文件"的概念。
账户的结构
每个 Solana 账户都有以下字段:
rustpub 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)。
这种设计使得程序的权限边界非常清晰。
账户类型
- 系统账户(System Account):最常见的账户类型,owner 是 System Program。普通用户的"钱包"就是系统账户,它只存储 SOL 余额。
- 数据账户(Data Account):由自定义程序创建和拥有,用于存储应用数据。例如,一个 DEX 可能为每个用户创建一个数据账户来记录他们的订单。
- 程序账户(Program Account):存储可执行代码,executable 标志为 true。程序一旦部署,其代码默认不可修改。
与以太坊的对比
| 特性 | 以太坊 | Solana |
|---|---|---|
| 状态存储 | 合约内部存储 | 独立账户 |
| 程序与数据 | 耦合 | 分离 |
| 并行能力 | 有限 | 原生支持 |
| 升级模式 | 代理模式 | 程序升级授权 |
租金机制:存储也有成本
在 Web2 中,数据存储的成本由服务提供商承担,然后通过订阅费或广告回收。但在去中心化网络中,每个节点都必须存储所有数据,这个成本必须由用户承担。
Solana 的租金机制是这样工作的:
- 每个账户根据其数据大小,需要维持一定的最低余额
- 如果余额低于最低要求,账户可能被清除以回收存储空间
- 为了简化操作,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(()) }
这种设计的优势:
- 并行性:不同账户的操作可以并行执行
- 灵活性:同一个程序可以操作无限多个账户
- 可组合性:账户可以被多个程序读取(虽然只能被 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 的程序可以代表它签名。
这意味着:
- 托管程序创建一个 PDA 账户来持有用户 A 的 SOL
- 当条件满足时,托管程序可以代表 PDA 签名,将 SOL 转给用户 B
- 整个过程不需要任何人类干预,也不需要信任任何中心化实体
确定性的优势
因为 PDA 是确定性计算的,所以前端应用不需要查询后端就能计算出账户地址:
javascript// 前端可以直接计算 PDA const [escrowPda] = PublicKey.findProgramAddressSync( [ Buffer.from("escrow"), userA.toBuffer(), userB.toBuffer(), ], programId ); // 直接使用这个地址,无需查询 const accountInfo = await connection.getAccountInfo(escrowPda);
交易与指令
交易结构
Solana 的交易是用户与网络交互的基本单位。一个交易包含:
- 签名列表:授权这笔交易的所有签名者
- 账户列表:交易涉及的所有账户(必须预先声明)
- 最近区块哈希:防止重放攻击
- 指令列表:要执行的操作
javascriptconst 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:从用户账户扣除 100 USDC
- 指令 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。这个程序需要:
- 调用 Token Program 转移用户的 USDC
- 调用 DEX 程序(如 Raydium)执行兑换
- 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 层
- 所有涉及的账户必须在最外层交易中声明
- 被调用程序不能获得比调用程序更多的权限