Solana Account Model: hiểu cách SVM lưu trạng thái qua code Java
1. Tổng quan
Trên Solana, mọi thứ đều là account: ví, token, chương trình (program), cả cấu hình hệ thống. Nếu bạn quen với Ethereum, nơi contract tự giữ storage của nó, thì cách Solana tách code và dữ liệu sẽ khá lạ lúc đầu.
Bài này giải thích các trường của một account và quy tắc ownership mà runtime áp dụng. Bạn cũng sẽ thấy cách tính rent, cách đọc và giải mã dữ liệu account bằng Java 21. Đọc xong, bạn sẽ tự gọi được RPC getAccountInfo và hiểu từng byte trả về.
2. Account trên Solana gồm những gì
Có thể hình dung Solana như một key-value store khổng lồ. Key là địa chỉ 32 byte, thường hiển thị dạng Base58. Value là một account với năm trường:
| Trường | Kiểu | Ý nghĩa |
|---|---|---|
lamports |
u64 | Số dư, 1 SOL = 1.000.000.000 lamports |
data |
byte[] | Dữ liệu tùy ý, tối đa 10 MiB |
owner |
Pubkey | Program sở hữu account này |
executable |
bool | true nếu account chứa code program |
rent_epoch |
u64 | Trường cũ, gần như không còn dùng |
Ta mô hình hóa account bằng một record trong Java:
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;
}
}
Bạn chú ý một điểm: owner của account không phải người giữ private key. owner là program có quyền ghi vào account. Ví cá nhân của bạn có owner là System Program (11111111111111111111111111111111). Bạn chỉ là người ký giao dịch.
2.1. Code và dữ liệu tách rời
Program trên Solana là stateless. Code nằm trong một account có executable = true. Trạng thái nằm trong các account khác, và các account đó có owner trỏ về program kia.
Ví dụ một token USDC trong ví bạn liên quan tới bốn account:
Ví của bạn owner = System Program
Token account owner = Token Program (data: mint, chủ, số dư)
Mint account owner = Token Program (data: tổng cung, decimals)
Token Program executable = true (chứa code)
Nhờ cách tách này, chỉ cần một Token Program duy nhất phục vụ mọi loại token. Mỗi token không phải deploy một contract riêng như ERC-20.
3. Đọc account qua JSON-RPC
Ta dùng java.net.http.HttpClient có sẵn trong JDK và Jackson để parse JSON. Nếu dự án dùng Spring Boot 3.x thì Jackson đã có sẵn. Nếu không, bạn thêm dependency sau:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.2</version>
</dependency>
Client đọc account:
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(); // account chưa tồn tại
}
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()));
}
}
Giải thích nhanh:
- Ta gọi
getAccountInfovớiencoding: base64. Trong response,datalà mảng[chuỗi, "base64"], nên ta lấy phần tử đầu rồi decode. - Nếu
valuelànull, account không tồn tại. Đây là trạng thái bình thường, không phải lỗi. Mọi địa chỉ đều “hợp lệ” cho tới khi có lamports. - Lưu ý
rentEpochcó thể vượt quá giới hạnlongcó dấu (giá trịu64::MAX).asLong()khi đó trả về số âm. Nếu cần chính xác, bạn dùngLong.parseUnsignedLonghoặcBigInteger.
Gọi thử với Token Program trên devnet:
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 Program cũng chỉ là một account. Owner của nó là BPF Loader, tức program chịu trách nhiệm nạp và chạy bytecode. Con số space chính xác có thể khác theo cluster.
4. Quy tắc ownership mà runtime áp dụng
Runtime của SVM kiểm tra các quy tắc sau sau mỗi instruction:
- Chỉ owner program được thay đổi
datacủa account. - Chỉ owner program được trừ lamports. Program nào cũng có thể cộng lamports vào account.
- Chỉ owner hiện tại được đổi
owner, và chỉ khi data đã được xóa về 0. - Account
executablelà bất biến với mọi instruction thông thường.
Ta mô phỏng hai quy tắc đầu để thấy rõ logic:
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);
}
}
Viết test để chốt hành vi:
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();
}
}
Từ đây bạn thấy vì sao không thể “rút SOL” trực tiếp từ một token account bằng System Program. Owner của nó là Token Program, nên chỉ instruction của Token Program (ví dụ CloseAccount) mới trừ được lamports.
Lưu ý: “Owner” trong dữ liệu của token account (người sở hữu token) khác với trường
ownercủa account (Token Program). Đây là điểm gây nhầm lẫn phổ biến nhất khi mới học Solana.
5. Rent: trả tiền cho không gian lưu trữ
Lưu dữ liệu on-chain tốn tài nguyên của validator. Vì vậy mỗi account phải giữ một số lamports tối thiểu để được rent-exempt. Hiện nay mọi account mới đều bắt buộc rent-exempt. Account không đủ số dư sẽ bị runtime từ chối khi tạo.
Công thức với tham số mặc định của mainnet:
minBalance = (dataLen + 128) * lamportsPerByteYear * exemptionYears
= (dataLen + 128) * 3480 * 2
Con số 128 là overhead metadata cố định mà runtime cộng thêm cho mỗi account.
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 phải trong khoảng 0..10 MiB");
}
return (dataLen + ACCOUNT_STORAGE_OVERHEAD)
* LAMPORTS_PER_BYTE_YEAR * EXEMPTION_YEARS;
}
}
Test với các giá trị quen thuộc:
class RentCalculatorTest {
@Test
void walletAccountWithNoData() {
assertThat(RentCalculator.minimumBalance(0)).isEqualTo(890_880L);
}
@Test
void splTokenAccountOf165Bytes() {
assertThat(RentCalculator.minimumBalance(165)).isEqualTo(2_039_280L);
}
}
Một ví rỗng cần khoảng 0,00089 SOL. Một token account cần khoảng 0,00204 SOL. Khi đóng account, toàn bộ lamports này được hoàn lại. Đó là lý do các ví thường có nút “close empty token accounts” để lấy lại SOL.
Lưu ý: Trong production, bạn nên gọi RPC
getMinimumBalanceForRentExemptionthay vì hard-code hằng số. Tham số rent có thể thay đổi qua feature gate, và cluster test có thể dùng cấu hình khác.
6. Giải mã dữ liệu account
data chỉ là mảng byte. Program tự quyết định layout. Với SPL token account (165 byte), ba trường đầu là:
offset 0 : mint (32 byte)
offset 32 : owner (32 byte) - người sở hữu token
offset 64 : amount (u64, little-endian)
Để hiển thị pubkey, ta cần một bộ mã hóa Base58 đơn giản:
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) { // mỗi byte 0 đứng đầu -> ký tự '1'
if (b != 0) break;
sb.append('1');
}
return sb.reverse().toString();
}
}
Bộ parse token account:
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("Không phải SPL token account: " + 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));
}
}
Điểm quan trọng nằm ở ByteOrder.LITTLE_ENDIAN. Solana serialize số nguyên theo little-endian (Borsh hoặc layout C). ByteBuffer của Java mặc định là big-endian, nên quên dòng này bạn sẽ nhận số dư vô lý.
Test parse với dữ liệu tự dựng:
@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 byte 0 được mã hóa thành 32 ký tự 1, đúng bằng địa chỉ System Program. Với USDC có 6 decimals, amount = 1_500_000 tương ứng 1,5 USDC.
Lưu ý:
amountlà u64. Giá trị lớn hơnLong.MAX_VALUEsẽ thành số âm trong Java. Hãy dùngLong.toUnsignedString(amount)khi hiển thị. Token-2022 còn có extension làm data dài hơn 165 byte, nên đừng kiểm tra độ dài cứng nếu cần hỗ trợ cả hai chuẩn.
7. Account model và xử lý song song
Mỗi transaction trên Solana phải khai báo trước toàn bộ account nó sẽ đụng tới. Với mỗi account, transaction ghi rõ cờ isSigner và isWritable. Runtime (Sealevel) dựa vào danh sách này để lập lịch:
- Hai transaction chỉ đọc cùng một account vẫn chạy song song được.
- Hai transaction cùng ghi một account phải chạy tuần tự.
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())));
}
}
Hàm conflictsWith trả về true khi hai transaction dùng chung một account và ít nhất một bên ghi vào nó. Đây chính là nguyên tắc read/write lock.
Hệ quả thực tế: nếu bạn thiết kế program với một account “global state” mà mọi người dùng đều ghi vào, bạn đã biến hệ thống thành tuần tự. Cách tốt hơn là chia state theo người dùng. Bạn thường làm việc này bằng PDA (Program Derived Address) được derive từ seed như ["user", userPubkey].
8. Lỗi hay gặp
- Nhầm hai loại “owner”: trường
ownercủa account là program, không phải người dùng. - Quên khai báo account writable: transaction sẽ fail với lỗi kiểu
writable privilege escalated. - Không đủ lamports cho rent: tạo account báo
insufficient funds for rent. Bạn hãy tính đúngspacetrước khi gọicreateAccount. - Sai endianness khi parse: luôn dùng little-endian.
- Xem account không tồn tại là lỗi:
value: nulllà trạng thái hợp lệ, cần xử lý bằngOptional. - Không nên dùng một account dữ liệu khổng lồ cho mọi thứ. Giới hạn 10 MiB, chi phí rent và write lock đều chống lại bạn.
9. Kết luận
Account model của Solana xoay quanh vài ý đơn giản. Mọi thứ đều là account. Program không giữ state. Chỉ owner program mới được sửa data và trừ lamports. Mỗi byte lưu trữ phải được trả bằng rent tối thiểu.
Bạn đã đọc account qua JSON-RPC bằng Java 21 và kiểm tra quy tắc ownership bằng test. Bạn cũng đã tính rent-exempt và giải mã SPL token account từ mảng byte thô. Việc khai báo account trước cũng là chìa khóa giúp Solana xử lý transaction song song.
Bước tiếp theo hợp lý là tìm hiểu PDA và Cross-Program Invocation. Đó là hai cơ chế giúp program sở hữu account và gọi lẫn nhau một cách an toàn.