Solana Account Model: Wie die SVM den Status mit Java-Code speichert

1. Übersicht

Auf Solana ist alles ein Account: Wallets, Token, Programme und sogar Systemkonfigurationen. Wenn du mit Ethereum vertraut bist, wo der Contract seinen Storage selbst hält, wird dir zunächst fremd vorkommen, wie Solana Code und Daten trennt.

Dieser Artikel erklärt die Felder eines Accounts und die Ownership-Regeln, die die Runtime durchsetzt. Du wirst auch sehen, wie man Rent berechnet, wie man Account-Daten mit Java 21 liest und dekodiert. Danach kannst du selbst den RPC getAccountInfo aufrufen und jedes Byte der Antwort verstehen.

2. Woraus ein Account auf Solana besteht

Man kann sich Solana wie einen riesigen Key-Value Store vorstellen. Der Schlüssel ist eine 32-Byte-Adresse, normalerweise in Base58-Form angezeigt. Der Wert ist ein Account mit fünf Feldern:

Feld Typ Bedeutung
lamports u64 Guthaben, 1 SOL = 1.000.000.000 Lamports
data byte[] Beliebige Daten, maximal 10 MiB
owner Pubkey Programm, das diesen Account besitzt
executable bool true, wenn der Account Programmcode enthält
rent_epoch u64 Altes Feld, wird kaum noch verwendet

Wir modellieren einen Account mit einem 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;
    }
}

Beachte einen wichtigen Punkt: Der Owner eines Accounts ist nicht die Person, die den Private Key hält. owner ist das Programm, das Schreibberechtigung auf den Account hat. Dein persönliches Wallet hat als Owner das System Program (11111111111111111111111111111111). Du bist nur der Unterzeichner der Transaktion.

2.1. Code und Daten sind getrennt

Programme auf Solana sind stateless. Der Code liegt in einem Account mit executable = true. Der Zustand liegt in anderen Accounts, deren owner auf das Programm verweist.

Beispiel: Ein USDC-Token in deinem Wallet bezieht sich auf vier Accounts:

Dein Wallet         owner = System Program
Token-Account       owner = Token-Programm   (Daten: Mint, Besitzer, Saldo)
Mint-Account        owner = Token-Programm   (Daten: Gesamtangebot, Dezimalstellen)
Token-Programm      executable = true        (enthält Code)

Durch diese Trennung wird nur ein einziges Token-Programm benötigt, das alle Token-Arten bedient. Kein Token muss einen eigenen Contract wie ERC-20 deployen.

3. Accounts über JSON-RPC auslesen

Wir verwenden java.net.http.HttpClient, das in der JDK enthalten ist, und Jackson zum Parsen von JSON. Wenn dein Projekt Spring Boot 3.x verwendet, ist Jackson bereits vorhanden. Wenn nicht, füge die folgende Abhängigkeit hinzu:

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.17.2</version>
</dependency>

Client zum Auslesen von 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 existiert nicht
        }
        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()));
    }
}

Schnelle Erklärung:

  • Wir rufen getAccountInfo mit encoding: base64 auf. In der Antwort ist data ein Array [string, "base64"], also nehmen wir das erste Element und dekodieren es.
  • Wenn value null ist, existiert der Account nicht. Dies ist ein normaler Zustand, kein Fehler. Jede Adresse ist gültig, bis sie Lamports hat.
  • Beachte, dass rentEpoch die Grenze eines vorzeichenbehafteten Long überschreiten kann (der Wert u64::MAX). asLong() gibt dann eine negative Zahl zurück. Wenn du genau brauchst, verwende Long.parseUnsignedLong oder BigInteger.

Probieren wir es mit dem Token-Programm auf 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

Das Token-Programm ist auch nur ein Account. Sein Owner ist der BPF Loader, das Programm, das für das Laden und Ausführen von Bytecode verantwortlich ist. Die genaue space-Zahl kann je nach Cluster unterschiedlich sein.

4. Ownership-Regeln, die die Runtime durchsetzt

Die Runtime der SVM prüft nach jedem Instruction folgende Regeln:

  1. Nur das Owner-Programm darf die Daten eines Accounts ändern.
  2. Nur das Owner-Programm darf lamports abziehen. Jedes Programm kann lamports zu einem Account hinzufügen.
  3. Nur der aktuelle Owner darf den Owner ändern, und nur wenn die Daten auf 0 gelöscht wurden.
  4. Ein ausführbarer Account ist mit normalen Instructions unveränderbar.

Simulieren wir die ersten zwei Regeln, um die Logik zu verstehen:

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);
    }
}

Schreiben wir Tests, um das Verhalten zu fixieren:

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();
    }
}

Jetzt siehst du, warum man SOL nicht direkt von einem Token-Account mit dem System-Programm abheben kann. Sein Owner ist das Token-Programm, daher können nur Instructions des Token-Programms (z. B. CloseAccount) lamports abziehen.

Hinweis: Der Owner in den Daten eines Token-Accounts (Besitzer des Tokens) unterscheidet sich vom Owner-Feld des Accounts (Token-Programm). Dies ist der häufigste Verwechslungspunkt beim Erlernen von Solana.

5. Rent: Bezahlung für Speicherplatz

Das Speichern von On-Chain-Daten verbraucht Validator-Ressourcen. Deshalb muss jeder Account eine Mindestanzahl von Lamports halten, um rent-exempt zu sein. Heutzutage müssen alle neuen Accounts rent-exempt sein. Accounts mit unzureichendem Guthaben werden von der Runtime beim Erstellen abgelehnt.

Formel mit Standardparametern von Mainnet:

minBalance = (dataLen + 128) * lamportsPerByteYear * exemptionYears
           = (dataLen + 128) * 3480 * 2

Die Zahl 128 ist der feste Metadaten-Overhead, den die Runtime zu jedem Account hinzufügt.

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 muss zwischen 0 und 10 MiB liegen");
        }
        return (dataLen + ACCOUNT_STORAGE_OVERHEAD)
                * LAMPORTS_PER_BYTE_YEAR * EXEMPTION_YEARS;
    }
}

Test mit vertrauten Werten:

class RentCalculatorTest {

    @Test
    void walletAccountWithNoData() {
        assertThat(RentCalculator.minimumBalance(0)).isEqualTo(890_880L);
    }

    @Test
    void splTokenAccountOf165Bytes() {
        assertThat(RentCalculator.minimumBalance(165)).isEqualTo(2_039_280L);
    }
}

Ein leeres Wallet benötigt etwa 0,00089 SOL. Ein Token-Account benötigt etwa 0,00204 SOL. Beim Schließen eines Accounts werden alle diese Lamports zurückgegeben. Deshalb haben Wallets normalerweise einen Button „close empty token accounts”, um SOL zurückzubekommen.

Hinweis: In der Produktion solltest du den RPC getMinimumBalanceForRentExemption aufrufen, statt Konstanten hart zu codieren. Der Rent-Parameter kann sich durch Feature Gates ändern, und Test-Cluster können andere Konfigurationen verwenden.

6. Account-Daten dekodieren

data ist nur ein Byte-Array. Das Programm entscheidet selbst über das Layout. Bei einem SPL-Token-Account (165 Bytes) sind die ersten drei Felder:

offset 0   : mint   (32 Bytes)
offset 32  : owner  (32 Bytes) - Besitzer des Tokens
offset 64  : amount (u64, Little-Endian)

Um eine Pubkey anzuzeigen, benötigen wir einen einfachen 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) {           // jedes führende 0-Byte -> Zeichen '1'
            if (b != 0) break;
            sb.append('1');
        }
        return sb.reverse().toString();
    }
}

Parser für Token-Accounts:

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("Kein 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));
    }
}

Der wichtige Punkt liegt bei ByteOrder.LITTLE_ENDIAN. Solana serialisiert Ganzzahlen nach Little-Endian (Borsh oder C-Layout). ByteBuffer von Java ist standardmäßig Big-Endian, wenn du diese Zeile vergisst, erhältst du einen unsinnigen Saldo.

Test zum Parsen mit selbst erstellten Daten:

@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 Null-Bytes werden in 32 ‘1’-Zeichen kodiert, genau wie die System-Programm-Adresse. Bei USDC mit 6 Dezimalstellen entspricht amount = 1_500_000 1,5 USDC.

Hinweis: amount ist u64. Werte größer als Long.MAX_VALUE werden negativ. Verwende Long.toUnsignedString(amount) beim Anzeigen. Token-2022 hat auch Erweiterungen, die Daten länger als 165 Bytes machen, also überprüfe die Länge nicht hart-codiert, wenn du beide Standards unterstützen musst.

7. Account-Modell und parallele Verarbeitung

Jede Transaktion auf Solana muss vorher alle Accounts deklarieren, die sie berührt. Für jeden Account gibt die Transaktion die Flags isSigner und isWritable an. Die Runtime (Sealevel) nutzt diese Liste zum Planen:

  • Zwei Transaktionen, die denselben Account nur lesen, können parallel laufen.
  • Zwei Transaktionen, die denselben Account schreiben, müssen sequenziell laufen.
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())));
    }
}

Die Methode conflictsWith gibt true zurück, wenn zwei Transaktionen denselben Account verwenden und mindestens eine Seite darin schreibt. Dies ist genau das Read/Write-Lock-Prinzip.

Praktische Konsequenz: Wenn du ein Programm mit einem globalen State-Account entwirfst, in den alle Benutzer schreiben, hast du das System zu einem sequenziellen gemacht. Ein besserer Weg ist, den Status pro Benutzer zu teilen. Das machst du normalerweise mit einer PDA (Program Derived Address), die von einem Seed abgeleitet wird, wie ["user", userPubkey].

8. Häufige Fehler

  • Verwechselung der beiden Owner-Typen: Das Owner-Feld eines Accounts ist ein Programm, nicht der Benutzer.
  • Account als writable vergessen zu deklarieren: Die Transaktion schlägt fehl mit einem Fehler wie writable privilege escalated.
  • Nicht genug Lamports für Rent: Account erstellen meldet insufficient funds for rent. Berechne richtig die Größe, bevor du createAccount aufrufen.
  • Falsches Endianness beim Parsen: Verwende immer Little-Endian.
  • Einen nicht existierenden Account als Fehler ansehen: value: null ist ein gültiger Zustand, muss mit Optional behandelt werden.
  • Verwende nicht einen riesigen Data-Account für alles. Das Limit ist 10 MiB, Rent-Kosten und Write-Lock arbeiten gegen dich.

9. Fazit

Das Account-Modell von Solana dreht sich um einige einfache Konzepte. Alles ist ein Account. Programme halten keinen Zustand. Nur das Owner-Programm darf Daten ändern und Lamports abziehen. Jedes Speicher-Byte muss mit einer minimalen Rent bezahlt werden.

Du hast Accounts über JSON-RPC mit Java 21 gelesen und die Ownership-Regeln mit Tests überprüft. Du hast auch rent-exempt berechnet und SPL-Token-Accounts aus rohen Byte-Arrays dekodiert. Das vorherige Deklarieren von Accounts ist auch der Schlüssel, der Solana hilft, Transaktionen parallel zu verarbeiten.

Der nächste logische Schritt ist, PDAs zu verstehen und Cross-Program Invocation zu lernen. Dies sind zwei Mechanismen, die Programmen helfen, Accounts zu besitzen und sich gegenseitig sicher aufzurufen.

solanasvmblockchainjavaaccount-model