Modelo de Contas Solana: entendendo como o SVM armazena estado
1. Visão Geral
No Solana, tudo é uma conta: carteiras, tokens, programas, caté configurações do sistema. Se você está acostumado com Ethereum, onde contratos mantêm seu próprio armazenamento, a forma como o Solana separa código e dados pode parecer estranho no início.
Este artigo explica os campos de uma conta e as regras de propriedade que o runtime aplica. Você também aprenderá como calcular rent, e como ler e decodificar dados de contas usando Java 21. Ao terminar de ler, você conseguirá chamar o RPC getAccountInfo por conta própria e compreender cada byte retornado.
2. Account no Solana: o que contém
Você pode pensar no Solana como um gigantesco armazenamento key-value. A chave é um endereço de 32 bytes, geralmente exibido em Base58. O valor é uma conta com cinco campos:
| Campo | Tipo | Significado |
|---|---|---|
lamports |
u64 | Saldo, 1 SOL = 1.000.000.000 lamports |
data |
byte[] | Dados arbitrários, máximo 10 MiB |
owner |
Pubkey | Programa que possui esta conta |
executable |
bool | Verdadeiro se a conta contém código do programa |
rent_epoch |
u64 | Campo legado, quase não é mais usado |
Modelamos uma conta usando um record no 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;
}
}
Note um ponto importante: o proprietário da conta não é a pessoa que detém a chave privada. O proprietário é o programa que tem permissão para escrever na conta. A carteira pessoal tem o System Program como proprietário (11111111111111111111111111111111). Você é apenas o signatário da transação.
2.1. Código e Dados Separados
Programas no Solana são stateless. O código fica em uma conta com executable = true. O estado fica em outras contas, e essas contas têm owner apontando para esse programa.
Por exemplo, um token USDC em sua carteira envolve quatro contas:
Sua carteira owner = System Program
Token account owner = Token Program (dados: mint, dono, saldo)
Mint account owner = Token Program (dados: suprimento total, decimais)
Token Program executable = true (contém código)
Graças a essa separação, um único Token Program pode servir todos os tipos de tokens. Cada token não precisa fazer deploy de um contrato separado como ERC-20.
3. Lendo Contas via JSON-RPC
Usamos java.net.http.HttpClient que já vem no JDK e Jackson para fazer parsing de JSON. Se seu projeto usa Spring Boot 3.x, Jackson já vem incluído. Caso contrário, adicione a dependência abaixo:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.2</version>
</dependency>
Client para ler contas:
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(); // conta ainda não existe
}
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()));
}
}
Explicação rápida:
- Chamamos
getAccountInfocomencoding: base64. Na resposta,dataé um array[string, "base64"], então pegamos o primeiro elemento e decodificamos. - Se
valuefornull, a conta não existe. Este é um estado normal, não é um erro. Todo endereço é ‘válido’ até ter lamports. - Note que
rentEpochpode exceder o limite delongcom sinal (valoru64::MAX). Nesse casoasLong()retorna um número negativo. Se você precisar de precisão, useLong.parseUnsignedLongouBigInteger.
Testando com o Token Program no 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
O Token Program também é apenas uma conta. Seu proprietário é o BPF Loader, o programa responsável por carregar e executar bytecode. O número exato de space pode variar por cluster.
4. Regras de Propriedade Aplicadas pelo Runtime
O runtime da SVM verifica as seguintes regras após cada instrução:
- Apenas o programa proprietário pode modificar
datada conta. - Apenas o programa proprietário pode debitar lamports. Qualquer programa pode creditar lamports em uma conta.
- Apenas o proprietário atual pode mudar
owner, e apenas quando os dados foram zerados. - O campo
executableé imutável para qualquer instrução comum.
Vamos simular as duas primeiras regras para ver a 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);
}
}
Vamos escrever testes para fixar o comportamento:
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 daqui você vê por que não é possível ‘sacar SOL’ diretamente de uma conta de token usando o System Program. Seu proprietário é o Token Program, portanto apenas instruções do Token Program (como CloseAccount) podem debitar lamports.
Nota: “Owner” (proprietário) nos dados da conta de token (quem possui os tokens) é diferente do campo
ownerda conta (Token Program). Este é o ponto mais comum de confusão ao aprender Solana.
5. Rent: Pagando pelo Espaço de Armazenamento
Armazenar dados on-chain consome recursos dos validadores. Portanto, cada conta deve manter um número mínimo de lamports para ser rent-exempt. Atualmente, todas as contas novas são obrigatoriamente rent-exempt. Contas sem saldo suficiente serão rejeitadas pelo runtime ao criar.
Fórmula com parâmetros padrão da mainnet:
minBalance = (dataLen + 128) * lamportsPerByteYear * exemptionYears
= (dataLen + 128) * 3480 * 2
O número 128 é um overhead de metadados fixo que o runtime adiciona para cada conta.
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 deve estar no intervalo 0..10 MiB");
}
return (dataLen + ACCOUNT_STORAGE_OVERHEAD)
* LAMPORTS_PER_BYTE_YEAR * EXEMPTION_YEARS;
}
}
Testando com valores conhecidos:
class RentCalculatorTest {
@Test
void walletAccountWithNoData() {
assertThat(RentCalculator.minimumBalance(0)).isEqualTo(890_880L);
}
@Test
void splTokenAccountOf165Bytes() {
assertThat(RentCalculator.minimumBalance(165)).isEqualTo(2_039_280L);
}
}
Uma carteira vazia precisa de cerca de 0,00089 SOL. Uma conta de token precisa de cerca de 0,00204 SOL. Quando você fecha uma conta, todos esses lamports são reembolsados. Por isso as carteiras geralmente têm um botão ‘fechar contas de token vazias’ para recuperar SOL.
Nota: Em produção, você deve chamar o RPC
getMinimumBalanceForRentExemptionem vez de hard-codar constantes. O parâmetro de rent pode mudar através de feature gates, e clusters de teste podem usar configurações diferentes.
6. Decodificando Dados de Contas
data é apenas um array de bytes. O programa decide o layout por si próprio. Para uma conta de token SPL (165 bytes), os três primeiros campos são:
offset 0 : mint (32 bytes)
offset 32 : owner (32 bytes) - dono dos tokens
offset 64 : amount (u64, little-endian)
Para exibir uma pubkey, precisamos de um codificador Base58 simples:
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 zero inicial -> caractere '1'
if (b != 0) break;
sb.append('1');
}
return sb.reverse().toString();
}
}
Um parser de conta 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("Não é uma conta de token SPL: " + 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));
}
}
O ponto importante está em ByteOrder.LITTLE_ENDIAN. Solana serializa inteiros em little-endian (Borsh ou layout C). ByteBuffer do Java é big-endian por padrão, então esquecer dessa linha você receberá saldos sem sentido.
Testando o parse com dados construídos:
@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 zero são codificados em 32 caracteres 1, exatamente o endereço do System Program. Com USDC tendo 6 decimais, amount = 1_500_000 corresponde a 1,5 USDC.
Nota:
amounté um u64. Valores maiores queLong.MAX_VALUEse tornam negativos em Java. UseLong.toUnsignedString(amount)ao exibir. Token-2022 ainda tem extensões que tornam os dados mais longos que 165 bytes, então não faça verificação de comprimento fixa se você precisar suportar ambos os padrões.
7. Modelo de Contas e Processamento Paralelo
Cada transação no Solana deve declarar antecipadamente todas as contas que ela tocará. Para cada conta, a transação especifica as flags isSigner e isWritable. O runtime (Sealevel) usa essa lista para agendamento:
- Duas transações que apenas leem a mesma conta ainda podem ser executadas em paralelo.
- Duas transações que escrevem na mesma conta devem ser executadas sequencialmente.
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())));
}
}
A função conflictsWith retorna true quando duas transações usam a mesma conta e pelo menos uma delas escreve nela. Este é exatamente o princípio de read/write lock.
Consequência prática: se você projetar um programa com uma conta de ‘estado global’ que todos os usuários escrevem, você transformou o sistema em sequencial. Uma maneira melhor é dividir o estado por usuário. Você normalmente faz isso com PDAs (Program Derived Address) derivados de seeds como ["user", userPubkey].
8. Erros Comuns
- Confundir os dois tipos de ‘owner’: o campo
ownerda conta é um programa, não um usuário. - Esquecer de declarar contas como writable: a transação falhará com um erro como
writable privilege escalated. - Lamports insuficientes para rent: criar uma conta relata
insufficient funds for rent. Calcule corretamentespaceantes de chamarcreateAccount. - Endianness errado ao fazer parsing: sempre use little-endian.
- Considerar uma conta inexistente como erro:
value: nullé um estado válido, deve ser tratado comOptional. - Não deve usar uma conta de dados gigante para tudo. O limite de 10 MiB, o custo de rent e os write locks trabalham contra você.
9. Conclusão
O modelo de contas do Solana gira em torno de alguns conceitos simples. Tudo é uma conta. Programas não mantêm estado. Apenas o programa proprietário pode modificar dados e debitar lamports. Cada byte armazenado deve ser pago com um aluguel mínimo.
Você leu contas via JSON-RPC usando Java 21 e verificou as regras de propriedade com testes. Você também calculou rent-exempt e decodificou uma conta de token SPL a partir de um array de bytes bruto. Declarar contas antecipadamente também é a chave que permite ao Solana processar transações em paralelo.
O próximo passo lógico é entender PDAs e Cross-Program Invocation. Esses são dois mecanismos que permitem aos programas possuir contas e invocar uns aos outros com segurança.