Solana Account Model: SVMがJavaコードで状態を保存する方法を理解する
1. 概要
Solana上では、ウォレット、トークン、プログラム、システム設定などすべてがアカウントです。Ethereumに慣れている場合、コントラクトが独自のストレージを保持しますが、Solanaではコードとデータを分離するため、最初は戸惑うかもしれません。
この記事では、アカウントのフィールドとランタイムが適用する所有権ルールについて説明します。また、rentの計算方法、Java 21を使ってアカウントデータを読み込んで解析する方法も見ていきます。この記事を読み終わったら、自分でRPC getAccountInfo を呼び出し、返されたすべてのバイトを理解できるようになります。
2. Solana上のアカウントは何から成り立っているのか
Solanaを巨大なkey-valueストアと考えることができます。キーは32バイトのアドレスで、通常はBase58形式で表示されます。値は5つのフィールドを持つアカウントです:
| フィールド | 型 | 説明 |
|---|---|---|
lamports |
u64 | 残高。1 SOL = 1,000,000,000 lamports |
data |
byte[] | 任意のデータ。最大10 MiB |
owner |
Pubkey | このアカウントを所有するプログラム |
executable |
bool | アカウントがプログラムコードを含む場合は true |
rent_epoch |
u64 | 古いフィールド。ほぼ使われない |
Javaの record を使ってアカウントをモデル化します:
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;
}
}
注意すべき点として、アカウントのownerはprivate keyを持つ人ではありません。owner はそのアカウントに書き込む権限を持つプログラムです。あなたの個人用ウォレットのownerはSystem Program(11111111111111111111111111111111)です。あなたはトランザクションに署名する人に過ぎません。
2.1. コードとデータの分離
Solana上のプログラムはステートレスです。コードは executable = true を持つアカウントに格納されます。状態は他のアカウントに格納され、それらのアカウントは owner がそのプログラムを指しています。
例えば、あなたのウォレット内のUSDCトークンは4つのアカウントに関連しています:
あなたのウォレット owner = System Program
Token account owner = Token Program (data: mint, owner, 残高)
Mint account owner = Token Program (data: 総供給量, decimals)
Token Program executable = true (コードを含む)
この分離により、すべてのトークンに対して1つのToken Programを使用するだけで済みます。ERC-20のように、各トークンが独自のコントラクトをデプロイする必要はありません。
3. JSON-RPC経由でアカウントを読み込む
JDKに含まれている java.net.http.HttpClient とJacksonを使ってJSONを解析します。Spring Boot 3.xを使うプロジェクトであればJacksonは既に含まれています。含まれていない場合は、以下の依存関係を追加します:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.2</version>
</dependency>
アカウントを読み込むクライアント:
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(); // アカウントが存在しません
}
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()));
}
}
簡単な説明:
encoding: base64を使ってgetAccountInfoを呼び出します。レスポンスでは、dataは配列[文字列, "base64"]なので、最初の要素を取得してデコードします。valueがnullの場合、アカウントが存在しません。これは正常な状態で、エラーではありません。すべてのアドレスは、lamportsを持つまで「有効」です。rentEpochが符号付きlongの上限を超える可能性があることに注意してください(値u64::MAX)。その場合asLong()は負の数を返します。正確性が必要な場合は、Long.parseUnsignedLongまたはBigIntegerを使用してください。
devnet上のToken Programで試しに呼び出してみます:
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も単なるアカウントです。その所有者はBPF Loaderであり、つまりバイトコードをロードして実行するプログラムです。space の正確な値はクラスタによって異なる可能性があります。
4. ランタイムが適用する所有権ルール
SVMのランタイムは、各instruction後に次のルールをチェックします:
- owner program のみがアカウントの
dataを変更できます。 - owner programのみがlamportsを減らすことができます。どのプログラムでもアカウントにlamportsを追加できます。
- 現在のownerのみが
ownerを変更でき、それもdataが0にクリアされた場合のみです。 - アカウントの
executableは通常のinstructionで不変です。
ロジックを明確に理解するために、最初の2つのルールをシミュレートします:
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);
}
}
動作を確認するためのテストを書きます:
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();
}
}
ここから、System Programを使って直接トークンアカウントからSOLを「引き出す」ことができない理由が分かります。そのownerはToken Programなので、Token Programのinstruction(例:CloseAccount)だけがlamportsを減らすことができます。
注意: トークンアカウントのデータ内の「Owner」(トークンの所有者)は、アカウントの
ownerフィールド(Token Program)とは異なります。これはSolanaを学ぶときの最も一般的な混乱ポイントです。
5. Rent:ストレージスペースの支払い
on-chainにデータを保存することは、validatorのリソースを消費します。そのため、各アカウントはrent-exemptであるために最小限のlamportsを保持する必要があります。現在、すべての新しいアカウントはrent-exemptが必須です。十分な残高がないアカウントはランタイムが作成時に拒否します。
mainnnetのデフォルトパラメータを使用した式:
minBalance = (dataLen + 128) * lamportsPerByteYear * exemptionYears
= (dataLen + 128) * 3480 * 2
値128は、ランタイムが各アカウントに追加する固定メタデータオーバーヘッドです。
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は0~10 MiBの範囲内である必要があります");
}
return (dataLen + ACCOUNT_STORAGE_OVERHEAD)
* LAMPORTS_PER_BYTE_YEAR * EXEMPTION_YEARS;
}
}
馴染みのある値でテストします:
class RentCalculatorTest {
@Test
void walletAccountWithNoData() {
assertThat(RentCalculator.minimumBalance(0)).isEqualTo(890_880L);
}
@Test
void splTokenAccountOf165Bytes() {
assertThat(RentCalculator.minimumBalance(165)).isEqualTo(2_039_280L);
}
}
空のウォレットには約0.00089 SOLが必要です。トークンアカウントには約0.00204 SOLが必要です。アカウントを閉じると、すべてのlamportsが返却されます。これが多くのウォレットに「close empty token accounts」ボタンがある理由です。SOLを取り戻すためです。
注意: 本番環境では、ハードコードされた定数の代わりにRPC
getMinimumBalanceForRentExemptionを呼び出すべきです。rentパラメータはfeature gateを通じて変更される可能性があり、テストクラスタは異なる設定を使用する可能性があります。
6. アカウントデータのデコード
data はバイト配列に過ぎません。プログラムが独自にレイアウトを決定します。SPL tokenアカウント(165バイト)の場合、最初の3つのフィールドは次のとおりです:
offset 0 : mint (32 byte)
offset 32 : owner (32 byte) - トークンの所有者
offset 64 : amount (u64, little-endian)
pubkeyを表示するには、シンプルな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) { // 先頭の各バイト0 -> 文字'1'
if (b != 0) break;
sb.append('1');
}
return sb.reverse().toString();
}
}
トークンアカウントのパーサ:
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("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));
}
}
重要なのは ByteOrder.LITTLE_ENDIAN です。Solanaはintegerをlittle-endian形式でシリアル化します(Borshまたはレイアウト)。JavaのByteBufferはデフォルトではbig-endianなので、この行を忘れると、不合理な残高を取得することになります。
自分で構築したデータでパースのテストを実行します:
@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バイトの0は32文字の 1 にエンコードされます。これは正確にSystem Programのアドレスと同じです。6小数点のUSDCでは、amount = 1_500_000 は1.5 USDCに対応します。
注意:
amountはu64です。Long.MAX_VALUEより大きい値はJavaでは負の数になります。表示するときはLong.toUnsignedString(amount)を使用してください。Token-2022はextensionを持ち、データが165バイトより長くなるため、両方の標準をサポートする必要がある場合は、長さをハードコードしてチェックしないでください。
7. Account modelと並列処理
Solana上のすべてのtransactionは、アクセスするすべてのアカウントを事前に宣言する必要があります。各アカウントについて、transactionは isSigner と isWritable フラグを明示的に指定します。ランタイム(Sealevel)はこのリストに基づいてスケジュールを作成します:
- 同じアカウントを読み込むだけの2つのtransactionは並列実行できます。
- 同じアカウントに書き込む2つのtransactionは順序列で実行する必要があります。
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())));
}
}
conflictsWith 関数は、2つのtransactionが同じアカウントを共有し、少なくとも一方が書き込む場合に true を返します。これが正にread/write lockの原則です。
実際の結果:すべてのユーザーが書き込む「global state」アカウントを持つプログラムを設計する場合、システムを順序列にしてしまいます。より良い方法は、状態をユーザーごとに分割することです。通常、["user", userPubkey] のようなseedから派生するPDA(Program Derived Address)を使用してこれを行います。
8. よくあるエラー
- 2つの異なる「owner」を混同する: アカウントの
ownerフィールドはプログラムであり、ユーザーではありません。 - writable権限のあるアカウント宣言を忘れる: transactionは
writable privilege escalatedのようなエラーで失敗します。 - rent用のlamportsが不足している: アカウント作成は
insufficient funds for rentを報告します。createAccountを呼び出す前にspaceを正しく計算してください。 - パース時のendianness間違い: 常にlittle-endianを使用してください。
- 存在しないアカウントをエラーと見なす:
value: nullは有効な状態で、Optionalで処理する必要があります。 - 不要な行為: すべてのデータに対して巨大なアカウントを使用しないでください。10 MiBの制限、rent費用、write lockすべてが制約になります。
9. 結論
SolanaのAccount modelはいくつかの単純な考え方を中心に回っています。すべてはアカウントです。プログラムは状態を保持しません。ownerプログラムのみがdataを変更してlamportsを減らすことができます。保存された各バイトはrentsで支払う必要があります。
Java 21を使ってJSON-RPC経由でアカウントを読み込み、テストで所有権ルールを検証しました。rent-exemptを計算し、生のバイト配列からSPL tokenアカウントをデコードしました。事前のアカウント宣言もSolanaがトランザクション処理を並列化するための重要な鍵です。
次の論理的なステップは、PDAとCross-Program Invocationについて学ぶことです。これらは、プログラムがアカウントを所有し、相互に安全に呼び出すのを助ける2つのメカニズムです。