Solana Account Model : Comprendre le stockage d'état
1. Aperçu
Sur Solana, tout est un compte : portefeuilles, tokens, programmes, et même la configuration système. Si vous connaissez Ethereum, où le contrat maintient son propre stockage, la façon dont Solana sépare le code et les données semblera plutôt étrange au début.
Cet article explique les champs d’un compte et les règles de propriété que le runtime applique. Vous verrez aussi comment calculer la rente, lire et décoder les données d’un compte en Java 21. Après la lecture, vous serez capable d’appeler l’API RPC getAccountInfo et de comprendre chaque octet retourné.
2. Qu’est-ce qu’un compte sur Solana
On peut imaginer Solana comme une énorme base de données clé-valeur. La clé est une adresse de 32 octets, généralement affichée en Base58. La valeur est un compte avec cinq champs :
| Champ | Type | Signification |
|---|---|---|
lamports |
u64 | Solde, 1 SOL = 1 000 000 000 lamports |
data |
byte[] | Données arbitraires, maximum 10 MiB |
owner |
Pubkey | Le programme propriétaire de ce compte |
executable |
bool | true si le compte contient du code de programme |
rent_epoch |
u64 | Champ ancien, rarement utilisé |
Nous modélisons un compte avec un enregistrement en 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;
}
}
Notez un point important : le propriétaire d’un compte n’est pas la personne qui détient la clé privée. Le owner est un programme qui a le droit d’écrire dans le compte. Votre portefeuille personnel a pour propriétaire le System Program (11111111111111111111111111111111). Vous êtes simplement le signataire de la transaction.
2.1. Code et données séparés
Les programmes sur Solana sont sans état. Le code se trouve dans un compte avec executable = true. L’état se trouve dans d’autres comptes, et ces comptes ont leur propriétaire pointant vers ce programme.
Par exemple, un token USDC dans votre portefeuille implique quatre comptes :
Portefeuille owner = System Program
Compte de token owner = Token Program (data: mint, propriétaire, solde)
Compte mint owner = Token Program (data: approvisionnement total, décimales)
Token Program executable = true (contient le code)
Grâce à cette séparation, seul un Token Program unique est nécessaire pour servir tous les types de tokens. Chaque token n’a pas besoin de déployer son propre contrat, comme avec ERC-20.
3. Lire les comptes via JSON-RPC
Nous utilisons java.net.http.HttpClient disponible dans le JDK et Jackson pour analyser JSON. Si votre projet utilise Spring Boot 3.x, Jackson est déjà disponible. Sinon, ajoutez la dépendance suivante :
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.2</version>
</dependency>
Client pour lire les comptes :
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(); // le compte n'existe pas
}
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()));
}
}
Explication rapide :
- Nous appelons
getAccountInfoavecencoding: base64. Dans la réponse,dataest un tableau[chaîne, "base64"], donc nous prenons le premier élément puis le décodons. - Si
valueestnull, le compte n’existe pas. C’est un état normal, pas une erreur. Toute adresse est « valide » jusqu’à ce qu’elle ait des lamports. - Notez que
rentEpochpeut dépasser la limite d’un long signé (valeuru64::MAX).asLong()retourne alors un nombre négatif. Si vous avez besoin de précision, utilisezLong.parseUnsignedLongouBigInteger.
Essayez d’appeler avec le Token Program sur 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 est aussi simplement un compte. Son propriétaire est BPF Loader, c’est-à-dire le programme responsable du chargement et de l’exécution du bytecode. Le nombre exact de space peut différer selon le cluster.
4. Règles de propriété appliquées par le runtime
Le runtime de la SVM applique les règles suivantes après chaque instruction :
- Seul le programme propriétaire peut modifier les données du compte.
- Seul le programme propriétaire peut débiter les lamports. Tout programme peut créditer les lamports d’un compte.
- Seul le propriétaire actuel peut changer le propriétaire, seulement quand les données ont été effacées.
- Un compte
executableest immuable pour toute instruction normale.
Nous implémentons les deux premières règles pour montrer clairement la logique :
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);
}
}
Écrivons un test pour confirmer le comportement :
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();
}
}
A partir de là, vous voyez pourquoi on ne peut pas « retirer des SOL » directement d’un compte de token avec le System Program. Son propriétaire est le Token Program, donc seules les instructions du Token Program (par exemple CloseAccount) peuvent débiter les lamports.
Remarque : « Propriétaire » dans les données du compte de token (le détenteur du token) est différent du champ
ownerdu compte (Token Program). C’est la source de confusion la plus courante quand on débute avec Solana.
5. Rent : payer pour l’espace de stockage
Stocker des données on-chain consomme les ressources des validateurs. Ainsi, chaque compte doit maintenir un nombre minimum de lamports pour être exempt de rente. Aujourd’hui, tous les nouveaux comptes doivent être exempts de rente. Un compte sans assez de solde sera rejeté par le runtime lors de sa création.
Formule avec les paramètres par défaut du mainnet :
minBalance = (dataLen + 128) * lamportsPerByteYear * exemptionYears
= (dataLen + 128) * 3480 * 2
Le chiffre 128 est la surcharge de métadonnées fixe que le runtime ajoute à chaque compte.
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 doit être entre 0..10 MiB");
}
return (dataLen + ACCOUNT_STORAGE_OVERHEAD)
* LAMPORTS_PER_BYTE_YEAR * EXEMPTION_YEARS;
}
}
Test avec des valeurs familières :
class RentCalculatorTest {
@Test
void walletAccountWithNoData() {
assertThat(RentCalculator.minimumBalance(0)).isEqualTo(890_880L);
}
@Test
void splTokenAccountOf165Bytes() {
assertThat(RentCalculator.minimumBalance(165)).isEqualTo(2_039_280L);
}
}
Un portefeuille vide nécessite environ 0,00089 SOL. Un compte de token nécessite environ 0,00204 SOL. Quand vous fermez un compte, tous ces lamports sont remboursés. C’est pourquoi les portefeuilles ont généralement un bouton « fermer les comptes de tokens vides » pour récupérer le SOL.
Remarque : En production, vous devez appeler l’API RPC
getMinimumBalanceForRentExemptionau lieu de coder les constantes en dur. Le paramètre rent peut changer via un feature gate, et le cluster de test peut utiliser une configuration différente.
6. Décodage des données du compte
data n’est qu’un tableau d’octets. Le programme décide lui-même de la disposition. Pour un compte SPL token (165 octets), les trois premiers champs sont :
offset 0 : mint (32 octets)
offset 32 : owner (32 octets) - le détenteur du token
offset 64 : amount (u64, little-endian)
Pour afficher la clé publique, nous avons besoin d’un simple encodeur 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) { // chaque octet nul en tête -> caractère '1'
if (b != 0) break;
sb.append('1');
}
return sb.reverse().toString();
}
}
Analyseur de compte de token :
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("Pas un compte SPL token : " + 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));
}
}
Le point important est ByteOrder.LITTLE_ENDIAN. Solana sérialise les entiers en little-endian (Borsh ou layout C). ByteBuffer en Java est big-endian par défaut, donc si vous oubliez cette ligne, vous obtiendrez des soldes absurdes.
Testons l’analyse avec des données construites manuellement :
@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 octets nuls s’encodent en 32 caractères ‘1’, exactement l’adresse du System Program. Pour l’USDC avec 6 décimales, amount = 1_500_000 correspond à 1,5 USDC.
Remarque :
amountest un u64. Les valeurs supérieures àLong.MAX_VALUEdeviennent négatives en Java. UtilisezLong.toUnsignedString(amount)lors de l’affichage. Token-2022 a aussi des extensions qui rendent les données plus longues que 165 octets, donc ne vérifiez pas la longueur en dur si vous devez supporter les deux standards.
7. Modèle de compte et traitement parallèle
Chaque transaction sur Solana doit déclarer à l’avance tous les comptes qu’elle va toucher. Pour chaque compte, la transaction spécifie les drapeaux isSigner et isWritable. Le runtime (Sealevel) utilise cette liste pour planifier :
- Deux transactions qui lisent seulement le même compte peuvent toujours s’exécuter en parallèle.
- Deux transactions qui écrivent dans le même compte doivent s’exécuter séquentiellement.
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())));
}
}
La fonction conflictsWith retourne true quand deux transactions partagent un compte et qu’au moins l’une écrit dedans. C’est exactement le principe du verrou lecture/écriture.
Conséquences pratiques : si vous concevez un programme avec un compte « état global » auquel tous les utilisateurs écrivent, vous avez rendu le système séquentiel. La meilleure approche est de diviser l’état par utilisateur. Vous le faites généralement avec PDA (Program Derived Address) dérivée à partir de graines comme ["user", userPubkey].
8. Erreurs courantes
- Confondre les deux types de « propriétaire » : le champ
ownerd’un compte est un programme, et non l’utilisateur. - Oublier de déclarer un compte comme modifiable : la transaction échouera avec une erreur du type « escalade de privilège modifiable ».
- Pas assez de lamports pour la rente : la création du compte signale « fonds insuffisants pour la rente ». Vous devez calculer correctement l’espace avant d’appeler
createAccount. - Mauvais ordre d’octets lors de l’analyse : utilisez toujours little-endian.
- Considérer qu’un compte inexistant est une erreur :
value: nullest un état valide, à traiter avecOptional. - Ne pas utiliser un compte de données géantes pour tout. La limite de 10 MiB, le coût de la rente et du verrou d’écriture travaillent tous contre vous.
9. Conclusion
Le modèle de compte de Solana tourne autour de quelques idées simples. Tout est un compte. Les programmes n’ont pas d’état. Seul le programme propriétaire peut modifier les données et débiter les lamports. Chaque octet stocké doit être payé avec une rente minimum.
Vous avez lu les comptes via JSON-RPC avec Java 21 et vérifié les règles de propriété avec des tests. Vous avez aussi calculé l’exemption de rente et décodé des comptes SPL token à partir de données binaires brutes. La déclaration préalable des comptes est aussi la clé qui permet à Solana de traiter les transactions en parallèle.
L’étape logique suivante est d’étudier PDA et Cross-Program Invocation. Ce sont deux mécanismes qui permettent aux programmes de posséder des comptes et de s’invoquer les uns les autres de manière sécurisée.