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 运行时在每条指令后检查以下规则:

  1. 只有所有者程序可以改变账户的 data。
  2. 只有所有者程序可以扣除 lamports。任何程序都可以增加 lamports。
  3. 只有当前所有者才能改变 owner,而且只有在数据已被清零的情况下。
  4. 账户的 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)。这是两种机制,让程序可以拥有账户并安全地相互调用。

solanasvmblockchainjavaaccount-model