Solana 账户模型:用 Java 理解 SVM 如何存储状态
1. 概览
在 Solana 上,一切都是账户:钱包、代币、程序(program),甚至系统配置。如果你用过 Ethereum,其中 contract 自己保有存储,那么 Solana 这种分离代码和数据的方式一开始会觉得陌生。
这篇文章解释了账户的各个字段,以及运行时(runtime)应用的所有权规则。你还会看到如何计算租金、如何用 Java 21 读取和解析账户数据。读完这篇,你就能自己调用 RPC 的 getAccountInfo 并理解返回的每一个字节。
2. Solana 账户包含什么
可以把 Solana 想象成一个巨大的键值存储。键是 32 字节的地址,通常以 Base58 格式显示。值是一个有五个字段的账户:
| 字段 | 类型 | 含义 |
|---|---|---|
lamports |
u64 | 余额,1 SOL = 1,000,000,000 lamports |
data |
byte[] | 任意数据,最大 10 MiB |
owner |
Pubkey | 拥有这个账户的程序 |
executable |
bool | 如果为 true,账户包含程序代码 |
rent_epoch |
u64 | 遗留字段,基本不再使用 |
我们用 Java 的 record 来建模账户:
public record SolanaAccount(
String address,
long lamports,
byte[] data,
String owner,
boolean executable,
long rentEpoch) {
public double sol() {
return lamports / 1_000_000_000.0;
}
public int space() {
return data.length;
}
}
注意一个要点:账户的 owner 不是持有私钥的人。owner 是有权写入账户的程序。你自己的钱包由系统程序(System Program)拥有(11111111111111111111111111111111)。你只是签署交易的人。
2.1. 代码和数据分离
Solana 上的程序是无状态的。代码存在一个有 executable = true 的账户中。状态存在其他账户中,这些账户的 owner 指向那个程序。
例如,你钱包里的一个 USDC 代币涉及四个账户:
你的钱包 owner = 系统程序
代币账户 owner = Token 程序 (数据:mint、所有者、余额)
Mint 账户 owner = Token 程序 (数据:总供应量、decimals)
Token 程序 executable = true (包含代码)
正因为有这种分离,一个 Token 程序就能服务所有代币。每个代币不需要像 ERC-20 那样部署一个单独的 contract。
3. 通过 JSON-RPC 读取账户
我们使用 JDK 内置的 java.net.http.HttpClient 以及 Jackson 来解析 JSON。如果项目用 Spring Boot 3.x,Jackson 已经内置。否则,添加以下依赖:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.2</version>
</dependency>
读取账户的客户端代码:
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.*;
import java.util.Base64;
import java.util.Optional;
public class SolanaRpcClient {
private final HttpClient http = HttpClient.newHttpClient();
private final ObjectMapper mapper = new ObjectMapper();
private final URI endpoint;
public SolanaRpcClient(String endpoint) {
this.endpoint = URI.create(endpoint);
}
public Optional<SolanaAccount> getAccount(String address) throws Exception {
String body = """
{"jsonrpc":"2.0","id":1,"method":"getAccountInfo",
"params":["%s",{"encoding":"base64"}]}
""".formatted(address);
HttpRequest request = HttpRequest.newBuilder(endpoint)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
String json = http.send(request, HttpResponse.BodyHandlers.ofString()).body();
JsonNode value = mapper.readTree(json).path("result").path("value");
if (value.isNull() || value.isMissingNode()) {
return Optional.empty(); // 账户不存在
}
byte[] data = Base64.getDecoder().decode(value.path("data").get(0).asText());
return Optional.of(new SolanaAccount(
address,
value.path("lamports").asLong(),
data,
value.path("owner").asText(),
value.path("executable").asBoolean(),
value.path("rentEpoch").asLong()));
}
}
快速解释:
- 我们调用
getAccountInfo时指定encoding: base64。在响应中,data是一个数组[字符串, "base64"],所以我们取第一个元素然后解码。 - 如果
value是null,账户不存在。这是正常状态,不是错误。任何地址都是“有效的”,直到它被充值为止。 - 注意
rentEpoch可能超过有符号long的范围(值为u64::MAX)。此时asLong()会返回负数。如果需要精确值,可以用Long.parseUnsignedLong或BigInteger。
试试用 devnet 上的 Token 程序调用:
public static void main(String[] args) throws Exception {
var client = new SolanaRpcClient("https://api.devnet.solana.com");
client.getAccount("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
.ifPresent(acc -> System.out.printf(
"owner=%s executable=%s space=%d%n",
acc.owner(), acc.executable(), acc.space()));
}
owner=BPFLoader2111111111111111111111111111111111 executable=true space=134080
Token 程序也只是一个账户。它的所有者是 BPF Loader,也就是负责加载和执行字节码的程序。space 的确切值可能因集群而异。
4. 运行时应用的所有权规则
SVM 运行时在每条指令后检查以下规则:
- 只有所有者程序可以改变账户的
data。 - 只有所有者程序可以扣除 lamports。任何程序都可以增加 lamports。
- 只有当前所有者才能改变
owner,而且只有在数据已被清零的情况下。 - 账户的
executable对于普通指令是不可变的。
我们模拟前两个规则来理解逻辑:
public final class OwnershipRules {
private OwnershipRules() {}
public static boolean canModifyData(String programId, SolanaAccount account) {
return !account.executable() && account.owner().equals(programId);
}
public static boolean canChangeLamports(String programId,
SolanaAccount account,
long newLamports) {
boolean isCredit = newLamports >= account.lamports();
return isCredit || account.owner().equals(programId);
}
}
用测试来确认行为:
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class OwnershipRulesTest {
static final String SYSTEM = "11111111111111111111111111111111";
static final String TOKEN = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
SolanaAccount tokenAccount = new SolanaAccount(
"Acc1", 2_039_280, new byte[165], TOKEN, false, 0);
@Test
void onlyOwnerCanModifyData() {
assertThat(OwnershipRules.canModifyData(TOKEN, tokenAccount)).isTrue();
assertThat(OwnershipRules.canModifyData(SYSTEM, tokenAccount)).isFalse();
}
@Test
void anyoneCanCreditButOnlyOwnerCanDebit() {
assertThat(OwnershipRules.canChangeLamports(SYSTEM, tokenAccount, 3_000_000)).isTrue();
assertThat(OwnershipRules.canChangeLamports(SYSTEM, tokenAccount, 1_000_000)).isFalse();
assertThat(OwnershipRules.canChangeLamports(TOKEN, tokenAccount, 1_000_000)).isTrue();
}
}
从这里你就明白了为什么不能通过系统程序直接从代币账户“提取”SOL。它的所有者是 Token 程序,所以只有 Token 程序的指令(例如 CloseAccount)才能扣除 lamports。
注意: 代币账户数据中的“所有者”不同于账户的
owner字段(Token 程序)。这是初学者最常见的混淆点。
5. 租金:为存储空间付费
在链上存储数据需要验证器的资源。因此,每个账户必须保有最少数量的 lamports 以实现免租(rent-exempt)。目前,所有新账户都必须是免租的。余额不足的账户会在创建时被运行时拒绝。
使用 mainnet 默认参数的公式:
minBalance = (dataLen + 128) * lamportsPerByteYear * exemptionYears
= (dataLen + 128) * 3480 * 2
128 这个数字是运行时为每个账户添加的固定元数据开销。
public final class RentCalculator {
private static final long ACCOUNT_STORAGE_OVERHEAD = 128;
private static final long LAMPORTS_PER_BYTE_YEAR = 3_480;
private static final long EXEMPTION_YEARS = 2;
private RentCalculator() {}
public static long minimumBalance(int dataLen) {
if (dataLen < 0 || dataLen > 10 * 1024 * 1024) {
throw new IllegalArgumentException("dataLen 必须在 0..10 MiB 范围内");
}
return (dataLen + ACCOUNT_STORAGE_OVERHEAD)
* LAMPORTS_PER_BYTE_YEAR * EXEMPTION_YEARS;
}
}
用常见的值测试:
class RentCalculatorTest {
@Test
void walletAccountWithNoData() {
assertThat(RentCalculator.minimumBalance(0)).isEqualTo(890_880L);
}
@Test
void splTokenAccountOf165Bytes() {
assertThat(RentCalculator.minimumBalance(165)).isEqualTo(2_039_280L);
}
}
一个空钱包大约需要 0.00089 SOL。一个代币账户大约需要 0.00204 SOL。关闭账户时,所有这些 lamports 都被返还。这就是为什么钱包通常有“关闭空代币账户”按钮来取回 SOL。
注意: 在生产环境中,你应该调用 RPC 的
getMinimumBalanceForRentExemption,而不是硬编码这些常数。租金参数可能因功能门(feature gate)而变化,测试集群可能使用不同的配置。
6. 解析账户数据
data 只是一个字节数组。程序自己决定布局。对于 SPL 代币账户(165 字节),前三个字段是:
偏移 0 : mint (32 字节)
偏移 32 : owner (32 字节) - 代币所有者
偏移 64 : amount (u64,小端序)
要显示 pubkey,我们需要一个简单的 Base58 编码器:
import java.math.BigInteger;
public final class Base58 {
private static final String ALPHABET =
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
private Base58() {}
public static String encode(byte[] input) {
BigInteger value = new BigInteger(1, input);
StringBuilder sb = new StringBuilder();
BigInteger base = BigInteger.valueOf(58);
while (value.signum() > 0) {
BigInteger[] qr = value.divideAndRemainder(base);
sb.append(ALPHABET.charAt(qr[1].intValue()));
value = qr[0];
}
for (byte b : input) { // 前导 0 字节 -> '1' 字符
if (b != 0) break;
sb.append('1');
}
return sb.reverse().toString();
}
}
代币账户解析器:
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Arrays;
public record TokenAccountData(String mint, String tokenOwner, long amount) {
public static TokenAccountData parse(byte[] data) {
if (data.length != 165) {
throw new IllegalArgumentException("不是 SPL 代币账户:" + data.length);
}
ByteBuffer buf = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN);
return new TokenAccountData(
Base58.encode(Arrays.copyOfRange(data, 0, 32)),
Base58.encode(Arrays.copyOfRange(data, 32, 64)),
buf.getLong(64));
}
}
关键点是 ByteOrder.LITTLE_ENDIAN。Solana 以小端序序列化整数(Borsh 或 C 布局)。Java 的 ByteBuffer 默认是大端序,如果你忘了这一行,会得到荒谬的数字。
用自己构造的数据测试解析:
@Test
void parsesAmountInLittleEndian() {
byte[] data = new byte[165];
ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN).putLong(64, 1_500_000L);
TokenAccountData parsed = TokenAccountData.parse(data);
assertThat(parsed.amount()).isEqualTo(1_500_000L);
assertThat(parsed.mint()).isEqualTo("11111111111111111111111111111111");
}
32 个零字节被编码为 32 个 1 字符,正好是系统程序的地址。对于有 6 decimals 的 USDC,amount = 1_500_000 对应 1.5 USDC。
注意:
amount是 u64。超过Long.MAX_VALUE的值在 Java 中会变成负数。显示时用Long.toUnsignedString(amount)。Token-2022 还有扩展,导致数据长度超过 165 字节,所以如果要同时支持两个标准,不要做硬性长度检查。
7. 账户模型和并行处理
Solana 上的每笔交易都必须提前声明它将涉及的所有账户。对于每个账户,交易明确标记 isSigner 和 isWritable 标志。运行时(Sealevel)基于这个列表来调度:
- 两笔交易如果只是读取同一个账户,仍然可以并行运行。
- 两笔交易如果都写入同一个账户,必须顺序执行。
import java.util.List;
public record AccountMeta(String address, boolean isSigner, boolean isWritable) {}
public record Transaction(List<AccountMeta> accounts) {
public boolean conflictsWith(Transaction other) {
return accounts.stream().anyMatch(a -> other.accounts.stream().anyMatch(b ->
a.address().equals(b.address()) && (a.isWritable() || b.isWritable())));
}
}
conflictsWith 函数在两笔交易共享一个账户且至少有一方写入时返回 true。这正是读写锁的原理。
实际后果:如果你设计的程序有一个“全局状态”账户,所有用户都往里写,你就把系统变成了顺序执行的。更好的做法是按用户划分状态。通常你用 PDA(程序衍生地址)来做这件事,从诸如 ["user", userPubkey] 的种子衍生出来。
8. 常见错误
- 混淆两种“所有者”:账户的
owner字段是程序,不是用户。 - 忘记声明账户为可写:交易会失败,错误类似
writable privilege escalated。 - 租金不足:创建账户时报
insufficient funds for rent。创建前请正确计算space。 - 解析时字节序错误:总是用小端序。
- 把不存在的账户当作错误:
value: null是合法状态,用Optional处理。 - 不应该用一个巨大的数据账户存放所有东西。10 MiB 的限制、租金成本和写锁都会反对这样做。
9. 结论
Solana 的账户模型围绕几个简单的想法展开。一切都是账户。程序不持有状态。只有所有者程序可以修改数据和扣除 lamports。每个字节的存储都必须用最低租金来支付。
你已经用 Java 21 通过 JSON-RPC 读取了账户,并用测试验证了所有权规则。你还计算了免租余额并从原始字节数组解析了 SPL 代币账户。提前声明账户也是 Solana 实现交易并行处理的关键。
合理的下一步是了解 PDA 和跨程序调用(CPI)。这是两种机制,让程序可以拥有账户并安全地相互调用。