Modelo de Cuenta de Solana: entiende cómo SVM almacena estado

1. Descripción general

En Solana, todo es una cuenta: billeteras, tokens, programas, incluso configuraciones del sistema. Si estás acostumbrado a Ethereum, donde los contratos guardan su propio almacenamiento, la forma en que Solana separa código y datos te parecerá extraña al principio.

Este artículo explica los campos de una cuenta y las reglas de propiedad que aplica el runtime. También verás cómo calcular rent, leer y decodificar datos de cuentas con Java 21. Al terminar, podrás llamar directamente al RPC getAccountInfo y entender cada byte devuelto.

2. ¿Qué contiene una cuenta en Solana?

Puedes imaginar Solana como un almacén clave-valor gigantesco. La clave es una dirección de 32 bytes, usualmente mostrada en Base58. El valor es una cuenta con cinco campos:

Campo Tipo Significado
lamports u64 Saldo, 1 SOL = 1.000.000.000 lamports
data byte[] Datos arbitrarios, máximo 10 MiB
owner Pubkey Programa que posee esta cuenta
executable bool true si la cuenta contiene código del programa
rent_epoch u64 Campo antiguo, prácticamente no se usa

Modelamos la cuenta con un record 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;
    }
}

Fíjate en un punto: el propietario de la cuenta no es quien guarda la clave privada. owner es el programa que tiene permiso para escribir en la cuenta. Tu billetera personal tiene como propietario el System Program (11111111111111111111111111111111). Solo eres quien firma las transacciones.

2.1. Código y datos separados

Los programas en Solana son sin estado. El código reside en una cuenta con executable = true. El estado reside en otras cuentas, y esas cuentas tienen owner apuntando al programa.

Por ejemplo, un token USDC en tu billetera involucra cuatro cuentas:

Tu billetera       owner = System Program
Token account      owner = Token Program   (data: mint, propietario, saldo)
Mint account       owner = Token Program   (data: suministro total, decimales)
Token Program      executable = true       (contiene código)

Gracias a esta separación, un único Token Program sirve a todos los tipos de tokens. Cada token no necesita desplegar un contrato separado como en ERC-20.

3. Leer cuentas a través de JSON-RPC

Usamos java.net.http.HttpClient que viene en el JDK y Jackson para parsear JSON. Si tu proyecto usa Spring Boot 3.x, Jackson ya está incluido. Si no, añade la siguiente dependencia:

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

Cliente para leer cuentas:

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(); // la cuenta no existe aún
        }
        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()));
    }
}

Explicación rápida:

  • Llamamos getAccountInfo con encoding: base64. En la respuesta, data es una array [string, "base64"], así que tomamos el primer elemento y lo decodificamos.
  • Si value es null, la cuenta no existe. Este es un estado normal, no un error. Todas las direcciones son “válidas” hasta que tengan lamports.
  • Ten en cuenta que rentEpoch puede exceder el límite de long con signo (el valor u64::MAX). asLong() entonces devuelve un número negativo. Si necesitas precisión, usa Long.parseUnsignedLong o BigInteger.

Prueba llamar con Token Program en 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 es también solo una cuenta. Su propietario es BPF Loader, el programa responsable de cargar y ejecutar bytecode. El número exacto de space puede variar según el cluster.

4. Reglas de propiedad que aplica el runtime

El runtime de SVM verifica estas reglas después de cada instrucción:

  1. Solo el programa propietario puede cambiar los data de una cuenta.
  2. Solo el programa propietario puede restar lamports. Cualquier programa puede sumar lamports a una cuenta.
  3. Solo el propietario actual puede cambiar owner, y solo si los datos ya se han limpiado a 0.
  4. El campo executable de una cuenta es inmutable para instrucciones normales.

Simulamos las dos primeras reglas para ver la lógica claramente:

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

Escribimos tests para fijar el comportamiento:

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

Aquí ves por qué no puedes “retirar SOL” directamente de una token account usando System Program. Su propietario es Token Program, así que solo las instrucciones de Token Program (por ejemplo CloseAccount) pueden restar lamports.

Nota: El “propietario” en los datos de una token account (quien posee los tokens) es diferente del campo owner de la cuenta (Token Program). Este es el punto más confuso cuando empiezas a aprender Solana.

5. Rent: pagar por espacio de almacenamiento

Almacenar datos on-chain consume recursos de los validadores. Por eso cada cuenta debe tener un número mínimo de lamports para ser rent-exempt. Actualmente, toda cuenta nueva debe ser rent-exempt. El runtime rechazará crear una cuenta que no tenga saldo suficiente.

La fórmula con los parámetros por defecto de mainnet:

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

El número 128 es el overhead de metadatos fijo que el runtime suma a cada cuenta.

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 debe estar en el rango 0..10 MiB");
        }
        return (dataLen + ACCOUNT_STORAGE_OVERHEAD)
                * LAMPORTS_PER_BYTE_YEAR * EXEMPTION_YEARS;
    }
}

Tests con valores comunes:

class RentCalculatorTest {

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

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

Una billetera vacía necesita aproximadamente 0,00089 SOL. Una token account necesita aproximadamente 0,00204 SOL. Cuando cierras una cuenta, todos estos lamports se devuelven. Por eso las billeteras suelen tener un botón “cerrar cuentas de tokens vacías” para recuperar SOL.

Nota: En producción, debes llamar al RPC getMinimumBalanceForRentExemption en lugar de hard-codear constantes. Los parámetros de rent pueden cambiar mediante feature gates, y los clusters de prueba pueden usar configuraciones diferentes.

6. Decodificar datos de cuentas

data es solo una array de bytes. El programa decide el layout. Para una SPL token account (165 bytes), los tres primeros campos son:

offset 0   : mint   (32 byte)
offset 32  : owner  (32 byte) - propietario del token
offset 64  : amount (u64, little-endian)

Para mostrar pubkeys, necesitamos un codificador Base58 simple:

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) {           // cada byte 0 al inicio -> carácter '1'
            if (b != 0) break;
            sb.append('1');
        }
        return sb.reverse().toString();
    }
}

Parser para 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("No es una 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));
    }
}

El punto importante está en ByteOrder.LITTLE_ENDIAN. Solana serializa números enteros en little-endian (Borsh o layout C). ByteBuffer de Java es big-endian por defecto, así que si olvidas esta línea obtendrás saldos sin sentido.

Tests para parsear datos autogenerados:

@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 bytes de 0 se codifican en 32 caracteres 1, exactamente la dirección de System Program. Con USDC que tiene 6 decimales, amount = 1_500_000 corresponde a 1,5 USDC.

Nota: amount es u64. Los valores mayores que Long.MAX_VALUE se volverán negativos en Java. Usa Long.toUnsignedString(amount) al mostrar. Token-2022 también tiene extensiones que hacen los datos más largos que 165 bytes, así que no hagas verificaciones rígidas de longitud si necesitas soportar ambos estándares.

7. Modelo de cuenta y procesamiento paralelo

Cada transacción en Solana debe declarar de antemano todas las cuentas que tocará. Para cada cuenta, la transacción especifica los flags isSigner e isWritable. El runtime (Sealevel) usa esta lista para programar:

  • Dos transacciones que solo leen la misma cuenta aún pueden ejecutarse en paralelo.
  • Dos transacciones que escriben en la misma cuenta deben ejecutarse secuencialmente.
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 función conflictsWith devuelve true cuando dos transacciones comparten una cuenta y al menos una escribe en ella. Este es el principio de read/write lock.

La consecuencia práctica: si diseñas un programa con una cuenta de “estado global” a la que todos los usuarios escriben, has hecho el sistema secuencial. Una mejor forma es dividir el estado por usuario. Típicamente haces esto usando PDA (Program Derived Address) derivado de seeds como ["user", userPubkey].

8. Errores comunes

  • Confundir dos tipos de “owner”: el campo owner de la cuenta es el programa, no el usuario.
  • Olvidar declarar cuentas como writable: la transacción fallará con un error tipo writable privilege escalated.
  • Insuficientes lamports para rent: crear la cuenta reporta insufficient funds for rent. Calcula correctamente space antes de llamar createAccount.
  • Endianness incorrecto al parsear: siempre usa little-endian.
  • Ver una cuenta inexistente como error: value: null es un estado válido, manéjalo con Optional.
  • No uses una cuenta de datos gigantesca para todo. El límite de 10 MiB, el costo de rent y los write locks están en tu contra.

9. Conclusión

El modelo de cuenta de Solana gira en torno a algunas ideas simples. Todo es una cuenta. Los programas no guardan estado. Solo el programa propietario puede modificar datos y restar lamports. Cada byte de almacenamiento debe pagarse con rent mínimo.

Leíste cuentas a través de JSON-RPC con Java 21 y verificaste reglas de propiedad con tests. También calculaste rent-exempt y decodificaste SPL token accounts a partir de arrays de bytes crudos. Declarar cuentas de antemano es la clave para que Solana procese transacciones en paralelo.

El siguiente paso lógico es aprender sobre PDA e Invocación Entre Programas. Estos son dos mecanismos que permiten que los programas posean cuentas y se invoquen entre sí de manera segura.

solanasvmblockchainjavaaccount-model