Solana Account Model: Understanding SVM State Storage via Java
1. Overview
On Solana, everything is an account: wallets, tokens, programs, even system configuration. If you’re familiar with Ethereum, where contracts hold their own storage, then Solana’s separation of code and data will feel quite strange at first.
This article explains the fields of an account and the ownership rules that the runtime enforces. You’ll also see how to calculate rent, how to read and decode account data using Java 21. After reading, you’ll be able to call the getAccountInfo RPC yourself and understand every byte returned.
2. What makes up a Solana account
You can think of Solana like a massive key-value store. The key is a 32-byte address, usually displayed as Base58. The value is an account with five fields:
| Field | Type | Meaning |
|---|---|---|
lamports |
u64 | Balance, 1 SOL = 1,000,000,000 lamports |
data |
byte[] | Arbitrary data, maximum 10 MiB |
owner |
Pubkey | The program that owns this account |
executable |
bool | true if the account contains program code |
rent_epoch |
u64 | Legacy field, rarely used anymore |
We model an account using a record in 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;
}
}
Pay attention to one point: the owner of an account is not the person holding the private key. The owner is the program that has write permission to the account. Your personal wallet is owned by the System Program (11111111111111111111111111111111). You are merely the signer of the transaction.
2.1. Code and data are separated
Programs on Solana are stateless. Code lives in an account with executable = true. State lives in other accounts, and those accounts have their owner pointing to that program.
For example, a USDC token in your wallet involves four accounts:
Your wallet owner = System Program
Token account owner = Token Program (data: mint, owner, balance)
Mint account owner = Token Program (data: total supply, decimals)
Token Program executable = true (contains code)
Thanks to this separation, a single Token Program can serve all token types. Each token doesn’t need to deploy its own contract like ERC-20.
3. Reading accounts via JSON-RPC
We use java.net.http.HttpClient built into the JDK and Jackson for JSON parsing. If your project uses Spring Boot 3.x, Jackson is already included. Otherwise, add this dependency:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.2</version>
</dependency>
Client to read accounts:
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 doesn't exist yet
}
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()));
}
}
Quick explanation:
- We call
getAccountInfowithencoding: base64. In the response,datais an array[string, "base64"], so we take the first element and decode it. - If
valueisnull, the account doesn’t exist. This is normal, not an error. Every address is “valid” until it has lamports. - Note that
rentEpochcan exceed the limit of signedlong(valueu64::MAX).asLong()then returns a negative number. For precision, useLong.parseUnsignedLongorBigInteger.
Try calling it with Token Program on 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
The Token Program is also just an account. Its owner is the BPF Loader, the program responsible for loading and running bytecode. The space number can vary depending on the cluster.
4. Ownership rules the runtime enforces
The SVM runtime checks these rules after each instruction:
- Only the owner program can modify the
dataof an account. - Only the owner program can debit lamports. Any program can credit lamports to an account.
- Only the current owner can change the
owner, and only when data has been zeroed out. - An
executableaccount is immutable by any regular instruction.
We simulate the first two rules to see the logic clearly:
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);
}
}
Write tests to confirm behavior:
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();
}
}
From here you see why you can’t “withdraw SOL” directly from a token account using the System Program. Its owner is the Token Program, so only an instruction from Token Program (for example CloseAccount) can debit lamports.
Note: The “owner” in a token account’s data (the person who owns the token) is different from the
ownerfield of the account (Token Program). This is the most common source of confusion when learning Solana.
5. Rent: paying for storage space
Storing data on-chain costs validator resources. Therefore, each account must hold a minimum number of lamports to be rent-exempt. Currently, all new accounts must be rent-exempt. Accounts with insufficient balance will be rejected by the runtime when created.
The formula with mainnet’s default parameters:
minBalance = (dataLen + 128) * lamportsPerByteYear * exemptionYears
= (dataLen + 128) * 3480 * 2
The number 128 is a fixed metadata overhead that the runtime adds for each 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 must be in range 0..10 MiB");
}
return (dataLen + ACCOUNT_STORAGE_OVERHEAD)
* LAMPORTS_PER_BYTE_YEAR * EXEMPTION_YEARS;
}
}
Test with familiar values:
class RentCalculatorTest {
@Test
void walletAccountWithNoData() {
assertThat(RentCalculator.minimumBalance(0)).isEqualTo(890_880L);
}
@Test
void splTokenAccountOf165Bytes() {
assertThat(RentCalculator.minimumBalance(165)).isEqualTo(2_039_280L);
}
}
An empty wallet needs about 0.00089 SOL. A token account needs about 0.00204 SOL. When you close an account, all these lamports are refunded. That’s why wallets often have a button to “close empty token accounts” to get SOL back.
Note: In production, you should call the RPC
getMinimumBalanceForRentExemptioninstead of hard-coding constants. The rent parameter can change through feature gates, and test clusters may use different configurations.
6. Decoding account data
data is just a byte array. The program decides the layout. For an SPL token account (165 bytes), the first three fields are:
offset 0 : mint (32 bytes)
offset 32 : owner (32 bytes) - the token owner
offset 64 : amount (u64, little-endian)
To display pubkeys, we need a simple Base58 encoder:
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) { // each leading zero byte -> '1' character
if (b != 0) break;
sb.append('1');
}
return sb.reverse().toString();
}
}
Token account parser:
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("Not an 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));
}
}
The crucial point is ByteOrder.LITTLE_ENDIAN. Solana serializes integers in little-endian format (Borsh or C layout). Java’s ByteBuffer defaults to big-endian, so forgetting this line will give you nonsensical balances.
Test parsing with hand-crafted data:
@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 zero bytes encode to 32 1 characters, exactly the System Program address. For USDC with 6 decimals, amount = 1_500_000 corresponds to 1.5 USDC.
Note:
amountis a u64. Values larger thanLong.MAX_VALUEbecome negative in Java. UseLong.toUnsignedString(amount)when displaying. Token-2022 also has extensions making data longer than 165 bytes, so don’t hard-code length checks if you need to support both standards.
7. Account model and parallel processing
Every transaction on Solana must declare beforehand all accounts it will touch. For each account, the transaction specifies the isSigner and isWritable flags. The runtime (Sealevel) uses this list to schedule:
- Two transactions that only read the same account can still run in parallel.
- Two transactions that both write to an account must run sequentially.
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())));
}
}
The conflictsWith method returns true when two transactions use the same account and at least one side writes to it. This is exactly the read/write lock principle.
Practical consequence: if you design a program with a single “global state” account that everyone writes to, you’ve made the system sequential. A better approach is to partition state by user. You typically do this using PDAs (Program Derived Address) derived from seeds like ["user", userPubkey].
8. Common mistakes
- Confusing two kinds of “owner”: the
ownerfield of an account is the program, not the user. - Forgetting to mark an account writable: the transaction will fail with an error like
writable privilege escalated. - Not enough lamports for rent: creating an account errors with
insufficient funds for rent. You must calculatespacecorrectly before callingcreateAccount. - Wrong endianness when parsing: always use little-endian.
- Treating a non-existent account as an error:
value: nullis a valid state, handle it withOptional. - Don’t use a single giant data account for everything. The 10 MiB limit, rent cost, and write locks all work against you.
9. Conclusion
Solana’s account model revolves around a few simple ideas. Everything is an account. Programs don’t hold state. Only the owner program can modify data and debit lamports. Every byte of storage must be paid for with a minimum rent.
You’ve now read accounts via JSON-RPC using Java 21 and verified ownership rules with tests. You’ve also calculated rent-exempt balances and decoded an SPL token account from raw bytes. Declaring accounts beforehand is also the key that lets Solana process transactions in parallel.
A logical next step is to explore PDAs and Cross-Program Invocation. These are two mechanisms that let programs own accounts and safely call each other.